From 054bd5ef7bc7cb1ca84c4d3480a56cbce0ff0431 Mon Sep 17 00:00:00 2001 From: Sidney Date: Wed, 20 May 2026 09:42:27 -0300 Subject: [PATCH] feat(dashboard): correct revenue calculations to use actual received value --- GEMINI.md | 2 + MEMORY.md | 6 +- manager/components/Dashboard.tsx | 6 +- manager/components/Messages.tsx | 107 ++++++++++++++++- manager/server.selfhosted.js | 194 ++++++++++++++++++++++++++++--- 5 files changed, 293 insertions(+), 22 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index f3ae36e..fc272c3 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -64,3 +64,5 @@ 35. **Mass Send Standard (V3)**: The mass send feature MUST use the student's or guardian's first name for the {nome} variable (via `.split(' ')[0]`). It MUST support dual dispatch (sending to both student and guardian if phones are distinct) and allow attachments (Image/PDF) handled via `multipart/form-data` and the Evolution API `sendMedia` endpoint. 36. **Frequency Visibility & Justification Tracking**: All attendance records of type `absence` MUST be excluded from the "Presence Time" calculation in the Portal, showing a dash (`—`) instead. Justifications MUST store the `submittedAt` timestamp in both SQL and JSON formats to allow auditing and clear display of submission time in the UI. 37. **Server Entry Point Safety**: The original `server.js` files in both Manager and Portal are OBSOLETE and kept only for historical context. You MUST NEVER modify or edit `server.js`. All backend changes must be applied exclusively to `server.selfhosted.js`. +38. **Database Connection & MCP Integration**: Direct PostgreSQL access has been successfully configured via the `@modelcontextprotocol/server-postgres` MCP server to connect directly to the production database on the VPS (`150.230.87.131`). Database telemetry and operations can be executed via MCP SQL tools. + diff --git a/MEMORY.md b/MEMORY.md index fbc2bfa..3c97719 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -130,6 +130,7 @@ - [x] **Filtragem Inteligente de Boletim:** Refatorada a aba de Boletim no Manager para exibir provas e atividades apenas quando houver submissão do aluno, eliminando a poluição visual de outras turmas. - [x] **Otimização Financeira (Mobile):** Cabeçalho da aba Financeiro redesenhado para ser 100% responsivo, com botões compactos e ícones inteligentes que economizam espaço em dispositivos móveis. - [x] **Disparo em Massa V3:** Implementada lógica de primeiro nome (`.split(' ')[0]`), envio simultâneo para Aluno e Responsável (quando contatos forem diferentes), painel de 25 emojis temáticos e suporte a anexos (Imagem/PDF) via Evolution API. +- [x] **Mensagens de Aniversário Automáticas:** Implementada rotina de agendamento automático diário de felicitações de aniversário via cron job (`server.selfhosted.js`) integrada ao painel de configurações na interface (`Messages.tsx`). O envio é feito apenas ao telefone cadastrado do aluno (s.phone), ignorando os responsáveis e pulando alunos sem número registrado. ## 📋 Próximos Passos Pendentes @@ -137,4 +138,7 @@ 2. **Otimização de Build:** Re-explorar o cache do Docker. 3. **Financeiro:** Implementar visualização de extrato detalhado. -140: **Nota Técnica:** O arquivo `server.js` é OBSOLETO e mantido apenas para contexto histórico. **NUNCA DEVE SER EDITADO OU ALTERADO**. Todo o foco de backend deve ser estritamente no `server.selfhosted.js`. +**Nota Técnica:** O arquivo `server.js` é OBSOLETO e mantido apenas para contexto histórico. **NUNCA DEVE SER EDITADO OU ALTERADO**. Todo o foco de backend deve ser estritamente no `server.selfhosted.js`. + +**Acesso ao Banco de Dados (MCP):** Configurado e testado o acesso MCP ao PostgreSQL da VPS (`150.230.87.131`) através do `mcp_config.json`. A porta `5432` foi liberada com sucesso nas Security Lists do console da Oracle Cloud e no firewall interno (`ufw`). A conexão está totalmente operacional e as tabelas/volumetria de linhas foram documentadas. + diff --git a/manager/components/Dashboard.tsx b/manager/components/Dashboard.tsx index e78c29d..54bbbf2 100644 --- a/manager/components/Dashboard.tsx +++ b/manager/components/Dashboard.tsx @@ -48,7 +48,7 @@ const Dashboard: React.FC = ({ data }) => { const pendingPayments = useMemo(() => data.payments.filter(p => p.status === 'pending').length, [data.payments]); const revenue = useMemo(() => data.payments .filter(p => p.status === 'paid') - .reduce((sum, p) => sum + p.amount, 0), [data.payments]); + .reduce((sum, p) => sum + (Number((p as any).valor_pago) || (Number(p.amount) - (Number(p.discount) || 0))), 0), [data.payments]); // Advanced Stats const newStudentsThisMonth = useMemo(() => { @@ -102,7 +102,7 @@ const Dashboard: React.FC = ({ data }) => { const pDate = new Date(p.paidDate || p.dueDate); return pDate.getMonth() === d.getMonth() && pDate.getFullYear() === d.getFullYear() && p.status === 'paid'; }); - const monthRevenue = monthPayments.reduce((sum, p) => sum + p.amount, 0); + const monthRevenue = monthPayments.reduce((sum, p) => sum + (Number((p as any).valor_pago) || (Number(p.amount) - (Number(p.discount) || 0))), 0); history.push({ name: monthName, revenue: monthRevenue }); } return history; @@ -122,7 +122,7 @@ const Dashboard: React.FC = ({ data }) => { ...data.payments.filter(p => p.status === 'paid').slice(-3).map(p => ({ type: 'payment', title: 'Pagamento Recebido', - desc: `R$ ${p.amount.toLocaleString()}`, + desc: `R$ ${(Number((p as any).valor_pago) || (Number(p.amount) - (Number(p.discount) || 0))).toLocaleString()}`, date: p.paidDate || p.dueDate, icon: CheckCircle2, color: 'bg-emerald-100 text-emerald-600' diff --git a/manager/components/Messages.tsx b/manager/components/Messages.tsx index 8d74436..99ab5b5 100644 --- a/manager/components/Messages.tsx +++ b/manager/components/Messages.tsx @@ -55,10 +55,17 @@ const Messages: React.FC = ({ data, updateData }) => { const [isSavingScheduleOverdue, setIsSavingScheduleOverdue] = useState(false); const [cronOverdueActive, setCronOverdueActive] = useState(false); + // Estado do Agendamento Automático - Aniversário + const [scheduleBirthdayEnabled, setScheduleBirthdayEnabled] = useState(!!initRules.autoScheduleBirthdayEnabled); + const [scheduleBirthdayTime, setScheduleBirthdayTime] = useState(initRules.autoScheduleBirthdayTime || '09:00'); + const [isSavingScheduleBirthday, setIsSavingScheduleBirthday] = useState(false); + const [cronBirthdayActive, setCronBirthdayActive] = useState(false); + useEffect(() => { fetch('/api/cron/status').then(r => r.json()).then(d => { setCronActive(d.preventive); setCronOverdueActive(d.overdue); + setCronBirthdayActive(d.birthday); }).catch(() => {}); }, []); @@ -105,7 +112,7 @@ const Messages: React.FC = ({ data, updateData }) => { try { const payloadAlunos = birthdayStudents.map(s => { const nome = s.name.split(' ')[0]; - const telefone = s.phone || s.guardianPhone; + const telefone = s.phone; return { nome, telefone }; }).filter(a => a.telefone); @@ -755,6 +762,104 @@ const Messages: React.FC = ({ data, updateData }) => { {isSendingBdays ? 'Enviando...' : 'Parabenizar Todos'} + {/* Agendamento Automático - Aniversário */} +
+
+ + +
+ + {scheduleBirthdayEnabled && ( +
+
+ +
+ setScheduleBirthdayTime(e.target.value)} + className="flex-1 px-4 py-2.5 border border-pink-200 rounded-xl text-sm font-bold text-center bg-white focus:ring-2 focus:ring-pink-500 focus:outline-none shadow-sm" + /> + +
+
+
+ + {cronBirthdayActive ? `Ativo — Próximo disparo às ${scheduleBirthdayTime}` : 'Inativo'} +
+
+ )} +
+
diff --git a/manager/server.selfhosted.js b/manager/server.selfhosted.js index d4521a6..55abd17 100644 --- a/manager/server.selfhosted.js +++ b/manager/server.selfhosted.js @@ -56,6 +56,7 @@ const sentCache = new Set(); const lockCache = new Set(); let activeCronJob = null; // Referência global para o agendamento preventivo let activeCronJobOverdue = null; // Referência global para o agendamento de inadimplência +let activeCronJobBirthday = null; // Referência global para o agendamento de aniversário // === Funções Auxiliares de Notificação === async function createAdminNotification(titulo, mensagem, metadata = {}) { @@ -1477,22 +1478,161 @@ async function executarRotinaCobrancas(tipo = 'ambos') { return { enviadasAtraso, enviadasAviso }; } +// ============================================================ +// Rotina Automática de Aniversários +// ============================================================ +async function executarRotinaAniversarios() { + try { + const appData = await getSchoolData(); + if (!appData) return 0; + + const evoConfig = appData.evolutionConfig; + if (!evoConfig?.apiUrl || !evoConfig?.apiKey || !evoConfig?.instanceName) { + console.log('[Cron:Aniversário] ⚠️ Evolution API não configurada.'); + return 0; + } + + const templates = appData.messageTemplates || {}; + const templateMsg = templates.felizAniversario || "Olá {nome}, a equipe da {escola} passa para te desejar um Feliz Aniversário! Muita saúde, paz e conquistas neste novo ciclo! 🎂🎈"; + const escolaNome = appData.profile?.name || ''; + + // Busca alunos de forma híbrida (JSON e SQL) + const { rows: sqlStudents } = await pool.query(` + SELECT id, nome as name, email, telefone as phone, data_nascimento, status, nome_responsavel as "guardianName", telefone_responsavel as "guardianPhone" + FROM alunos + WHERE status = 'active' + `); + + const jsonStudents = appData.students || []; + const studentMap = new Map(); + + // Adiciona alunos do JSON + for (const s of jsonStudents) { + if (s.status === 'active' && s.id) { + studentMap.set(String(s.id), { + id: String(s.id), + name: s.name, + phone: s.phone, + guardianPhone: s.guardianPhone, + guardianName: s.guardianName, + birthDate: s.birthDate + }); + } + } + + // Sobrescreve/adiciona do SQL + for (const s of sqlStudents) { + if (s.id) { + let birthDateStr = null; + if (s.data_nascimento) { + const d = new Date(s.data_nascimento); + if (!isNaN(d.getTime())) { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + birthDateStr = `${year}-${month}-${day}`; + } + } + const existing = studentMap.get(String(s.id)) || {}; + studentMap.set(String(s.id), { + id: String(s.id), + name: s.name || existing.name, + phone: s.phone || existing.phone, + guardianPhone: s.guardianPhone || existing.guardianPhone, + guardianName: s.guardianName || existing.guardianName, + birthDate: birthDateStr || existing.birthDate + }); + } + } + + // Filtra aniversariantes do dia + const today = new Date(); + const todayDay = today.getDate(); + const todayMonth = today.getMonth() + 1; + + const aniversariantes = []; + for (const s of studentMap.values()) { + if (!s.birthDate) continue; + const parts = s.birthDate.split('-'); + if (parts.length === 3) { + const bdayDay = parseInt(parts[2]); + const bdayMonth = parseInt(parts[1]); + if (bdayDay === todayDay && bdayMonth === todayMonth) { + aniversariantes.push(s); + } + } + } + + if (aniversariantes.length === 0) { + console.log('[Cron:Aniversário] ℹ️ Nenhum aniversariante hoje.'); + return 0; + } + + console.log(`[Cron:Aniversário] 🎉 Encontrados ${aniversariantes.length} aniversariante(s) hoje.`); + + let enviadas = 0; + for (let i = 0; i < aniversariantes.length; i++) { + const s = aniversariantes[i]; + const nomePrimeiro = s.name.split(' ')[0]; + const msg = templateMsg + .replace(/{nome}/g, nomePrimeiro) + .replace(/{escola}/g, escolaNome); + + if (!s.phone) continue; + + let cleanPhone = s.phone.replace(/\D/g, ''); + if (cleanPhone.length === 10 || cleanPhone.length === 11) cleanPhone = '55' + cleanPhone; + + try { + const url = `${evoConfig.apiUrl.replace(/\/$/, '')}/message/sendText/${evoConfig.instanceName}`; + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'apikey': evoConfig.apiKey + }, + body: JSON.stringify({ number: cleanPhone, text: msg }) + }); + if (resp.ok) { + enviadas++; + } else { + console.error(`[Cron:Aniversário] Falha ao enviar para ${cleanPhone}: status ${resp.status}`); + } + } catch (err) { + console.error(`[Cron:Aniversário] Erro ao enviar para ${cleanPhone}:`, err.message); + } + + if (i < aniversariantes.length - 1) { + await new Promise(r => setTimeout(r, 30000)); + } + } + + return enviadas; + } catch (error) { + console.error('[Cron:Aniversário] Erro na rotina de aniversário:', error.message); + return 0; + } +} + // ============================================================ // AGENDADOR AUTOMÁTICO (node-cron) — Suporte a múltiplos tipos // ============================================================ function agendarRotina(tipo, hora, minuto) { - const isPreventivo = tipo === 'preventivo'; - const label = isPreventivo ? 'Preventivo' : 'Inadimplência'; + const label = tipo === 'preventivo' ? 'Preventivo' : tipo === 'atrasado' ? 'Inadimplência' : 'Aniversário'; // Cancela job anterior do mesmo tipo - if (isPreventivo && activeCronJob) { + if (tipo === 'preventivo' && activeCronJob) { activeCronJob.stop(); activeCronJob = null; console.log(`[Cron:${label}] ⏹ Rotina anterior cancelada.`); - } else if (!isPreventivo && activeCronJobOverdue) { + } else if (tipo === 'atrasado' && activeCronJobOverdue) { activeCronJobOverdue.stop(); activeCronJobOverdue = null; console.log(`[Cron:${label}] ⏹ Rotina anterior cancelada.`); + } else if (tipo === 'aniversario' && activeCronJobBirthday) { + activeCronJobBirthday.stop(); + activeCronJobBirthday = null; + console.log(`[Cron:${label}] ⏹ Rotina anterior cancelada.`); } const h = parseInt(hora); @@ -1502,21 +1642,27 @@ function agendarRotina(tipo, hora, minuto) { return; } - const cronTipo = isPreventivo ? 'preventivo' : 'atrasado'; const cronExpression = `${m} ${h} * * *`; const job = cron.schedule(cronExpression, async () => { console.log(`[Cron:${label}] ⏰ Rotina automática iniciada às ${new Date().toLocaleTimeString('pt-BR')}`); try { - const resultado = await executarRotinaCobrancas(cronTipo); - const count = isPreventivo ? resultado.enviadasAviso : resultado.enviadasAtraso; - console.log(`[Cron:${label}] ✅ Concluído: ${count} mensagens processadas.`); + if (tipo === 'aniversario') { + const count = await executarRotinaAniversarios(); + console.log(`[Cron:${label}] ✅ Concluído: ${count} mensagens processadas.`); + } else { + const cronTipo = tipo === 'preventivo' ? 'preventivo' : 'atrasado'; + const resultado = await executarRotinaCobrancas(cronTipo); + const count = tipo === 'preventivo' ? resultado.enviadasAviso : resultado.enviadasAtraso; + console.log(`[Cron:${label}] ✅ Concluído: ${count} mensagens processadas.`); + } } catch (error) { console.error(`[Cron:${label}] ❌ Erro na rotina automática:`, error.message); } }, { timezone: 'America/Sao_Paulo' }); - if (isPreventivo) activeCronJob = job; - else activeCronJobOverdue = job; + if (tipo === 'preventivo') activeCronJob = job; + else if (tipo === 'atrasado') activeCronJobOverdue = job; + else if (tipo === 'aniversario') activeCronJobBirthday = job; console.log(`[Cron:${label}] ✅ Rotina agendada para ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')} (America/Sao_Paulo)`); } @@ -1796,6 +1942,14 @@ async function inicializarAgendamento() { } else { console.log('[Cron:Inadimplência] ℹ Agendamento desativado.'); } + + // Aniversário + if (rules.autoScheduleBirthdayEnabled && rules.autoScheduleBirthdayTime) { + const [h, m] = rules.autoScheduleBirthdayTime.split(':'); + agendarRotina('aniversario', h, m); + } else { + console.log('[Cron:Aniversário] ℹ Agendamento desativado.'); + } } catch (e) { console.error('[Cron] Erro ao inicializar agendamento:', e.message); } @@ -1853,25 +2007,28 @@ async function startServer() { } }); - // API para gerenciar o agendamento (suporte a preventivo e atrasado) + // API para gerenciar o agendamento (suporte a preventivo, atrasado e aniversário) app.get('/api/cron/status', (req, res) => { res.json({ preventive: !!activeCronJob, - overdue: !!activeCronJobOverdue + overdue: !!activeCronJobOverdue, + birthday: !!activeCronJobBirthday }); }); app.post('/api/cron/schedule', async (req, res) => { try { const { enabled, time, tipo } = req.body; - const isOverdue = tipo === 'atrasado'; const appData = await getSchoolData(); if (!appData.messageTemplates) appData.messageTemplates = {}; if (!appData.messageTemplates.automationRules) appData.messageTemplates.automationRules = {}; - if (isOverdue) { + if (tipo === 'atrasado') { appData.messageTemplates.automationRules.autoScheduleOverdueEnabled = !!enabled; appData.messageTemplates.automationRules.autoScheduleOverdueTime = time || '09:00'; + } else if (tipo === 'aniversario') { + appData.messageTemplates.automationRules.autoScheduleBirthdayEnabled = !!enabled; + appData.messageTemplates.automationRules.autoScheduleBirthdayTime = time || '09:00'; } else { appData.messageTemplates.automationRules.autoScheduleEnabled = !!enabled; appData.messageTemplates.automationRules.autoScheduleTime = time || '09:00'; @@ -1882,10 +2039,12 @@ async function startServer() { if (enabled && time) { const [h, m] = time.split(':'); - agendarRotina(isOverdue ? 'atrasado' : 'preventivo', h, m); + agendarRotina(tipo, h, m); } else { - if (isOverdue) { + if (tipo === 'atrasado') { if (activeCronJobOverdue) { activeCronJobOverdue.stop(); activeCronJobOverdue = null; } + } else if (tipo === 'aniversario') { + if (activeCronJobBirthday) { activeCronJobBirthday.stop(); activeCronJobBirthday = null; } } else { if (activeCronJob) { activeCronJob.stop(); activeCronJob = null; } } @@ -1894,7 +2053,8 @@ async function startServer() { res.json({ success: true, preventive: !!activeCronJob, - overdue: !!activeCronJobOverdue + overdue: !!activeCronJobOverdue, + birthday: !!activeCronJobBirthday }); } catch (error) { console.error('[Cron] Erro ao salvar agendamento:', error);