feat(dashboard): correct revenue calculations to use actual received value

This commit is contained in:
Sidney 2026-05-20 09:42:27 -03:00
parent ed52d6a2fa
commit 054bd5ef7b
5 changed files with 293 additions and 22 deletions

View File

@ -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.

View File

@ -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.

View File

@ -48,7 +48,7 @@ const Dashboard: React.FC<DashboardProps> = ({ 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<DashboardProps> = ({ 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<DashboardProps> = ({ 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'

View File

@ -55,10 +55,17 @@ const Messages: React.FC<MessagesProps> = ({ 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<MessagesProps> = ({ 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<MessagesProps> = ({ data, updateData }) => {
{isSendingBdays ? 'Enviando...' : 'Parabenizar Todos'}
</button>
{/* Agendamento Automático - Aniversário */}
<div className="mt-4 pt-4 border-t border-pink-200">
<div className="flex items-center justify-between mb-4">
<label className="text-[10px] font-black text-pink-700 uppercase tracking-widest flex items-center gap-1.5">
<Power size={13} /> Rotina Automática
</label>
<button
onClick={async () => {
const newEnabled = !scheduleBirthdayEnabled;
setScheduleBirthdayEnabled(newEnabled);
setIsSavingScheduleBirthday(true);
try {
const resp = await fetch('/api/cron/schedule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: newEnabled, time: scheduleBirthdayTime, tipo: 'aniversario' })
});
const d = await resp.json();
setCronBirthdayActive(d.birthday);
// Persistir no estado global
const newRules = { ...templates.automationRules, autoScheduleBirthdayEnabled: newEnabled, autoScheduleBirthdayTime: scheduleBirthdayTime };
setTemplates(prev => ({ ...prev, automationRules: newRules }));
updateData({ messageTemplates: { ...templates, automationRules: newRules } });
showAlert('Sucesso', newEnabled ? `Rotina de aniversário ativada para ${scheduleBirthdayTime}!` : 'Rotina automática desativada.', 'success');
} catch {
showAlert('Erro', 'Erro ao salvar agendamento.', 'error');
setScheduleBirthdayEnabled(!newEnabled);
} finally {
setIsSavingScheduleBirthday(false);
}
}}
disabled={isSavingScheduleBirthday}
className={`relative w-12 h-6 rounded-full transition-all duration-300 ${
scheduleBirthdayEnabled ? 'bg-pink-600' : 'bg-slate-300'
}`}
>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow-md transition-transform duration-300 ${
scheduleBirthdayEnabled ? 'translate-x-6' : 'translate-x-0'
}`} />
</button>
</div>
{scheduleBirthdayEnabled && (
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300 mb-4">
<div>
<label className="block text-[10px] font-black text-pink-500 uppercase tracking-widest mb-1.5 ml-1">Horário do Disparo</label>
<div className="flex gap-2">
<input
type="time"
value={scheduleBirthdayTime}
onChange={(e) => 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"
/>
<button
onClick={async () => {
setIsSavingScheduleBirthday(true);
try {
const resp = await fetch('/api/cron/schedule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, time: scheduleBirthdayTime, tipo: 'aniversario' })
});
const d = await resp.json();
setCronBirthdayActive(d.birthday);
// Persistir no estado global
const newRules = { ...templates.automationRules, autoScheduleBirthdayEnabled: true, autoScheduleBirthdayTime: scheduleBirthdayTime };
setTemplates(prev => ({ ...prev, automationRules: newRules }));
updateData({ messageTemplates: { ...templates, automationRules: newRules } });
showAlert('Sucesso', `Horário atualizado para ${scheduleBirthdayTime}!`, 'success');
} catch {
showAlert('Erro', 'Erro ao atualizar horário.', 'error');
} finally {
setIsSavingScheduleBirthday(false);
}
}}
disabled={isSavingScheduleBirthday}
className="px-4 py-2.5 bg-pink-600 text-white rounded-xl font-black text-xs hover:bg-pink-700 transition-all active:scale-95 shadow-md"
>
{isSavingScheduleBirthday ? '...' : 'Salvar'}
</button>
</div>
</div>
<div className={`flex items-center gap-2 text-[10px] font-bold px-3 py-2 rounded-lg ${
cronBirthdayActive ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'
}`}>
<span className={`w-2 h-2 rounded-full ${
cronBirthdayActive ? 'bg-emerald-500 animate-pulse' : 'bg-slate-400'
}`} />
{cronBirthdayActive ? `Ativo — Próximo disparo às ${scheduleBirthdayTime}` : 'Inativo'}
</div>
</div>
)}
</div>
<div className="pt-4 border-t border-pink-100">
<label className="block text-[10px] font-black text-pink-400 uppercase tracking-widest mb-3">Próximos do Mês</label>
<div className="space-y-2 max-h-40 overflow-y-auto pr-2 custom-scrollbar">

View File

@ -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;
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);