feat: automação de lembretes preventivos, refatoração de disparos seletivos e padronização de modais

This commit is contained in:
Sidney 2026-04-28 21:33:06 -03:00
parent 840a8ee159
commit 065476df16
8 changed files with 252 additions and 70 deletions

View File

@ -29,4 +29,6 @@
7. **Frontend Independence**: NEVER import files from `services/` or `server.js` directly into React components to prevent Node.js/SDK leakage (causes White Screen). Physical isolation is enforced: backend-only services (like MinIO/S3 storage) MUST stay outside the `src/` directory in Vite/React projects. Use `helpers.ts` for UI logic and standard `fetch` for API calls. 7. **Frontend Independence**: NEVER import files from `services/` or `server.js` directly into React components to prevent Node.js/SDK leakage (causes White Screen). Physical isolation is enforced: backend-only services (like MinIO/S3 storage) MUST stay outside the `src/` directory in Vite/React projects. Use `helpers.ts` for UI logic and standard `fetch` for API calls.
8. **Login Persistence**: Administrative sessions are persisted via `localStorage` ('edumanager_session'). The main entry point MUST validate the session on mount to ensure UX continuity. 8. **Login Persistence**: Administrative sessions are persisted via `localStorage` ('edumanager_session'). The main entry point MUST validate the session on mount to ensure UX continuity.
9. **Real-time & Sync**: In self-hosted environments, use **Intelligent Polling (30s)** to synchronize notifications and critical data between Portal and Manager, as standard Supabase Realtime is disabled. 9. **Real-time & Sync**: In self-hosted environments, use **Intelligent Polling (30s)** to synchronize notifications and critical data between Portal and Manager, as standard Supabase Realtime is disabled.
10. **Justification Logic**: Attendance justifications MUST include `fromStudentId` in notification metadata and support both `arquivo` and `arquivo_base64` keys for attachment compatibility. 10. **Justification Logic**: Attendance justifications MUST include `fromStudentId` in notification metadata and support both `arquivo` and `arquivo_base64` keys for attachment compatibility. Notifications SHOULD include the motive text in the `message` field (JSON format: `{text, motivo}`) to allow previews in the manager bell.
11. **Modal Standards**: System modals should utilize `bg-transparent` (no background darkening or blur) with `shadow-2xl` for depth, ensuring a clean and float-like aesthetic.
12. **Messages Automation**: Preventive reminders and overdue settings MUST be managed within their respective template modals (contextual logic), with independent manual triggers for each phase (Overdue vs. Upcoming).

View File

@ -11,12 +11,14 @@
- [x] Correção do Crash 404 no Portal: Injeção da pasta `src/services` no container de produção para permitir o import do `storage.js`. - [x] Correção do Crash 404 no Portal: Injeção da pasta `src/services` no container de produção para permitir o import do `storage.js`.
- [x] Correção das Imagens de Prova: Normalização das URLs nas questões de avaliações (Portal e Manager). - [x] Correção das Imagens de Prova: Normalização das URLs nas questões de avaliações (Portal e Manager).
- [x] Estabilização de CI/CD: Transição para `runs-on: self-hosted` (ARM64 nativo) eliminando lentidão e crashes do QEMU. - [x] Estabilização de CI/CD: Transição para `runs-on: self-hosted` (ARM64 nativo) eliminando lentidão e crashes do QEMU.
- [x] Correção do Sino de Notificações: Botões sempre visíveis e suporte a anexo via chave `arquivo`. - [x] Correção do Sino de Notificações: Botões sempre visíveis, suporte a anexo via chave `arquivo` e exibição do **Motivo da Falta** direto na lista do sino.
- [x] Persistência de Login (Manager): Login agora persiste no F5 via `localStorage`. - [x] **Mensagens e Automação Financeira:** Implementado sistema seletivo de disparos (Atrasados vs Preventivos) com botões independentes e lógica de servidor desacoplada.
- [x] Polling de Dados (30s): Implementada sincronização automática entre Portal e Manager para notificações instantâneas (Self-Hosted). - [x] **Configuração Contextual:** Configurações de automação (dias antes/depois, repetições) movidas da sidebar global para dentro dos modais de cada modelo de mensagem.
- [x] Deep Link de Notificação: Corrigida navegação do Sino para o Histórico de Aluno usando metadados `fromStudentId`. - [x] **Refinamento de UX/UI:** Correção de modais de Frequência para padrão `bg-transparent` e adição de cor `indigo` para identificação de lembretes preventivos.
- [x] Normalização de Anexos: Sincronização de chaves de justificativa entre Portal e página de Frequência. - [x] Registro de Frequência: Implementado ícone de olho para abrir modal com texto completo da justificativa e botão de aprovação rápida.
- [ ] Próximo Passo: Monitorar o log de acesso dos usuários após a ativação da persistência de login. - [x] Padronização Visual: Modais atualizados para `bg-transparent` (sem escurecimento/blur) com `shadow-2xl` para efeito de flutuação premium.
- [x] Backup & Sincronia: Portal agora envia metadados de justificativa filtráveis pelo Gerenciador.
- [ ] Próximo Passo: Analisar logs de comportamento dos disparos preventivos em larga escala.
### 💳 Módulo Financeiro (Portal do Aluno) ### 💳 Módulo Financeiro (Portal do Aluno)
- **Funcionalidades Implementadas:** - **Funcionalidades Implementadas:**

16
manager/check_columns.js Normal file
View File

@ -0,0 +1,16 @@
import pg from 'pg';
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://edumanager:EduManager2026!Seguro@postgres:5432/edumanager';
const pool = new pg.Pool({ connectionString: DATABASE_URL });
async function check() {
try {
const { rows } = await pool.query("SELECT column_name FROM information_schema.columns WHERE table_name = 'alunos_cobrancas'");
console.log(rows.map(r => r.column_name).join(', '));
} catch (e) {
console.error(e);
} finally {
await pool.end();
process.exit();
}
}
check();

View File

@ -348,7 +348,7 @@ const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData
{/* Confirmation Modal */} {/* Confirmation Modal */}
{showConfirmModal && capturedImage && detectedStudent && ( {showConfirmModal && capturedImage && detectedStudent && (
<div className={`fixed inset-0 bg-black/95 backdrop-blur-md z-50 flex items-center justify-center p-4 transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}> <div className={`fixed inset-0 bg-transparent z-50 flex items-center justify-center p-4 transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-3xl w-full max-w-sm overflow-hidden shadow-2xl transition-all duration-400 relative ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}> <div className={`bg-white rounded-3xl w-full max-w-sm overflow-hidden shadow-2xl transition-all duration-400 relative ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
{/* Blue Top Bar */} {/* Blue Top Bar */}
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div> <div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>

View File

@ -327,7 +327,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
{/* === MODAL 1: Lista de Alunos da Turma === */} {/* === MODAL 1: Lista de Alunos da Turma === */}
{showStudentListModal && selectedClass && ( {showStudentListModal && selectedClass && (
<div className={`fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center p-4 transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}> <div className={`fixed inset-0 bg-transparent z-50 flex items-center justify-center p-4 transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-3xl w-full max-w-2xl max-h-[85vh] overflow-hidden shadow-2xl transition-all duration-400 relative flex flex-col ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}> <div className={`bg-white rounded-3xl w-full max-w-2xl max-h-[85vh] overflow-hidden shadow-2xl transition-all duration-400 relative flex flex-col ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div> <div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>

View File

@ -14,11 +14,14 @@ const defaultTemplates = {
boletoVencido: "Olá {nome}, o boleto referente a {descricao} de R$ {valor} venceu em {vencimento}. Segue o PDF da 2ª via atualizada abaixo:", boletoVencido: "Olá {nome}, o boleto referente a {descricao} de R$ {valor} venceu em {vencimento}. Segue o PDF da 2ª via atualizada abaixo:",
cobrancaCancelada: "Olá {nome}, a cobrança referente a {descricao} foi cancelada.", cobrancaCancelada: "Olá {nome}, a cobrança referente a {descricao} foi cancelada.",
cobrancaAtualizada: "Olá {nome}, o boleto de {descricao} foi atualizado. Segue a nova versão:", cobrancaAtualizada: "Olá {nome}, o boleto de {descricao} foi atualizado. Segue a nova versão:",
boletoAVencer: "Olá {nome}, lembramos que sua cobrança referente a {descricao} no valor de R$ {valor} vencerá em {vencimento}. Segue o PDF abaixo:",
felizAniversario: "Olá {nome}, a equipe da {escola} passa para te desejar um Feliz Aniversário! Muita saúde, paz e conquistas neste novo ciclo! 🎂🎈", felizAniversario: "Olá {nome}, a equipe da {escola} passa para te desejar um Feliz Aniversário! Muita saúde, paz e conquistas neste novo ciclo! 🎂🎈",
automationRules: { automationRules: {
sendOnDueDate: true, sendOnDueDate: true,
sendDaysAfter: '1', sendDaysAfter: '1',
repeatEveryDays: '3' repeatEveryDays: '3',
sendDaysBefore: '3',
maxPreWarnings: '2'
} }
}; };
@ -117,6 +120,7 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
boletoVencido: normalizeLineBreaks(templates.boletoVencido), boletoVencido: normalizeLineBreaks(templates.boletoVencido),
cobrancaCancelada: normalizeLineBreaks(templates.cobrancaCancelada), cobrancaCancelada: normalizeLineBreaks(templates.cobrancaCancelada),
cobrancaAtualizada: normalizeLineBreaks(templates.cobrancaAtualizada), cobrancaAtualizada: normalizeLineBreaks(templates.cobrancaAtualizada),
boletoAVencer: normalizeLineBreaks(templates.boletoAVencer),
felizAniversario: normalizeLineBreaks(templates.felizAniversario) felizAniversario: normalizeLineBreaks(templates.felizAniversario)
}; };
updateData({ messageTemplates: normalizedTemplates }); updateData({ messageTemplates: normalizedTemplates });
@ -126,11 +130,11 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
const handleDispararCobrancas = async () => { const handleDispararCobrancas = async () => {
showConfirm( showConfirm(
'Disparar Cobranças', 'Disparar Cobranças',
'Tem certeza que deseja processar e enviar as mensagens para TODOS os alunos com pagamentos atrasados agora?', 'Tem certeza que deseja processar e enviar as mensagens para TODOS os alunos com pagamentos ATRASADOS agora?',
async () => { async () => {
setIsSending(true); setIsSending(true);
try { try {
const resp = await fetch('/api/disparar_cobrancas', { method: 'POST' }); const resp = await fetch('/api/disparar_cobrancas?tipo=atrasado', { method: 'POST' });
const resData = await resp.json(); const resData = await resp.json();
if (resp.ok) { if (resp.ok) {
showAlert('Sucesso', resData.message || 'Cobranças processadas e disparadas com sucesso!', 'success'); showAlert('Sucesso', resData.message || 'Cobranças processadas e disparadas com sucesso!', 'success');
@ -146,6 +150,29 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
); );
}; };
const handleDispararPreventivos = async () => {
showConfirm(
'Lembretes Preventivos',
'Tem certeza que deseja iniciar o envio dos LEMBRETES PREVENTIVOS para os boletos próximos do vencimento agora?',
async () => {
setIsSending(true);
try {
const resp = await fetch('/api/disparar_cobrancas?tipo=preventivo', { method: 'POST' });
const resData = await resp.json();
if (resp.ok) {
showAlert('Sucesso', resData.message || 'Lembretes disparados com sucesso!', 'success');
} else {
showAlert('Erro', resData.error || 'Erro ao disparar lembretes', 'error');
}
} catch (e: any) {
showAlert('Erro', 'Erro de conexão.', 'error');
} finally {
setIsSending(false);
}
}
);
};
const handleMassSend = async () => { const handleMassSend = async () => {
if (!messageText.trim()) { if (!messageText.trim()) {
return showAlert('Aviso', 'Digite uma mensagem para enviar.', 'warning'); return showAlert('Aviso', 'Digite uma mensagem para enviar.', 'warning');
@ -212,6 +239,7 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
{ key: 'boletoVencido', label: 'Boleto Vencido', desc: 'Enviado conforme automação ou disparo manual de atrasados.', color: 'red', icon: AlertTriangle, vars: ['{nome}', '{matricula}', '{descricao}', '{valor}', '{vencimento}', '{link_boleto}', '{escola}'] }, { key: 'boletoVencido', label: 'Boleto Vencido', desc: 'Enviado conforme automação ou disparo manual de atrasados.', color: 'red', icon: AlertTriangle, vars: ['{nome}', '{matricula}', '{descricao}', '{valor}', '{vencimento}', '{link_boleto}', '{escola}'] },
{ key: 'cobrancaCancelada', label: 'Cobrança Cancelada', desc: 'Enviado quando o boleto for cancelado no sistema.', color: 'slate', icon: AlertTriangle, vars: ['{nome}', '{matricula}', '{descricao}', '{escola}'] }, { key: 'cobrancaCancelada', label: 'Cobrança Cancelada', desc: 'Enviado quando o boleto for cancelado no sistema.', color: 'slate', icon: AlertTriangle, vars: ['{nome}', '{matricula}', '{descricao}', '{escola}'] },
{ key: 'cobrancaAtualizada', label: 'Cobrança Atualizada', desc: 'Enviado quando houver edição/atualização da cobrança.', color: 'amber', icon: Settings, vars: ['{nome}', '{matricula}', '{descricao}', '{valor}', '{vencimento}', '{link_boleto}', '{escola}'] }, { key: 'cobrancaAtualizada', label: 'Cobrança Atualizada', desc: 'Enviado quando houver edição/atualização da cobrança.', color: 'amber', icon: Settings, vars: ['{nome}', '{matricula}', '{descricao}', '{valor}', '{vencimento}', '{link_boleto}', '{escola}'] },
{ key: 'boletoAVencer', label: 'Boleto a Vencer', desc: 'Aviso preventivo enviado dias antes do vencimento.', color: 'indigo', icon: Clock, vars: ['{nome}', '{matricula}', '{descricao}', '{valor}', '{vencimento}', '{link_boleto}', '{escola}'] },
{ key: 'felizAniversario', label: 'Feliz Aniversário', desc: 'Mensagem carinhosa para os aniversariantes do dia.', color: 'pink', icon: Cake, vars: ['{nome}', '{escola}'] } { key: 'felizAniversario', label: 'Feliz Aniversário', desc: 'Mensagem carinhosa para os aniversariantes do dia.', color: 'pink', icon: Cake, vars: ['{nome}', '{escola}'] }
]; ];
@ -253,49 +281,6 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
{/* Lado Esquerdo - Configurações e Disparos */} {/* Lado Esquerdo - Configurações e Disparos */}
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-white border border-slate-200 p-6 rounded-2xl shadow-xl">
<h3 className="font-black text-slate-800 flex items-center gap-2 mb-6 text-sm uppercase tracking-widest text-indigo-600">
<Clock size={18} /> Automação
</h3>
<div className="space-y-5">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={templates.automationRules.sendOnDueDate}
onChange={(e) => setTemplates(p => ({ ...p, automationRules: { ...p.automationRules, sendOnDueDate: e.target.checked } }))}
className="w-5 h-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm font-bold text-slate-700">Aviso no dia do vencimento</span>
</label>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-100">
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2">1º aviso após</label>
<div className="flex items-center gap-3 text-sm text-slate-700 font-bold">
<input
type="number" min="1" max="30"
value={templates.automationRules.sendDaysAfter}
onChange={(e) => setTemplates(p => ({ ...p, automationRules: { ...p.automationRules, sendDaysAfter: e.target.value } }))}
className="w-16 px-3 py-2 border border-slate-200 rounded-lg text-center bg-white shadow-sm"
/>
<span>dias</span>
</div>
</div>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-100">
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2">Repetir a cada</label>
<div className="flex items-center gap-3 text-sm text-slate-700 font-bold">
<input
type="number" min="1" max="30"
value={templates.automationRules.repeatEveryDays}
onChange={(e) => setTemplates(p => ({ ...p, automationRules: { ...p.automationRules, repeatEveryDays: e.target.value } }))}
className="w-16 px-3 py-2 border border-slate-200 rounded-lg text-center bg-white shadow-sm"
/>
<span>dias</span>
</div>
</div>
</div>
</div>
<div className="bg-emerald-50 border border-emerald-100 p-6 rounded-2xl shadow-lg"> <div className="bg-emerald-50 border border-emerald-100 p-6 rounded-2xl shadow-lg">
<h3 className="font-black text-emerald-800 flex items-center gap-2 mb-4 text-sm uppercase tracking-widest"> <h3 className="font-black text-emerald-800 flex items-center gap-2 mb-4 text-sm uppercase tracking-widest">
@ -370,6 +355,24 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
</div> </div>
</div> </div>
<div className="bg-indigo-50 border border-indigo-200 p-6 rounded-2xl shadow-lg mb-6">
<h3 className="font-black text-indigo-800 flex items-center gap-2 mb-3 text-sm uppercase tracking-widest">
<Clock size={18} /> Lembretes Preventivos
</h3>
<p className="text-[10px] text-indigo-600 font-medium mb-4">
Envia avisos para boletos que vencem em até {templates.automationRules.sendDaysBefore} dias.
</p>
<button
onClick={handleDispararPreventivos}
disabled={isSending || !data.evolutionConfig?.apiUrl}
className={`w-full py-3.5 px-4 rounded-xl font-black text-sm text-white shadow-lg transition-all active:scale-95 ${
isSending || !data.evolutionConfig?.apiUrl ? 'bg-slate-400' : 'bg-indigo-600 hover:bg-indigo-700'
}`}
>
{isSending ? 'Processando...' : 'Enviar Lembretes Agora'}
</button>
</div>
<div className="bg-amber-50 border border-amber-200 p-6 rounded-2xl shadow-lg"> <div className="bg-amber-50 border border-amber-200 p-6 rounded-2xl shadow-lg">
<h3 className="font-black text-amber-800 flex items-center gap-2 mb-3 text-sm uppercase tracking-widest"> <h3 className="font-black text-amber-800 flex items-center gap-2 mb-3 text-sm uppercase tracking-widest">
<AlertTriangle size={18} /> Inadimplência <AlertTriangle size={18} /> Inadimplência
@ -433,6 +436,7 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
slate: 'bg-slate-50 text-slate-600', slate: 'bg-slate-50 text-slate-600',
amber: 'bg-amber-50 text-amber-600', amber: 'bg-amber-50 text-amber-600',
pink: 'bg-pink-50 text-pink-600', pink: 'bg-pink-50 text-pink-600',
indigo: 'bg-indigo-50 text-indigo-600',
}; };
return ( return (
@ -492,12 +496,91 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
id="template-editor" id="template-editor"
value={(templates[editingTemplate.key as keyof typeof templates] as string) || ''} value={(templates[editingTemplate.key as keyof typeof templates] as string) || ''}
onChange={(e) => setTemplates(p => ({ ...p, [editingTemplate.key]: e.target.value }))} onChange={(e) => setTemplates(p => ({ ...p, [editingTemplate.key]: e.target.value }))}
rows={10} rows={['boletoAVencer', 'boletoVencido'].includes(editingTemplate.key) ? 6 : 10}
className="w-full px-6 py-5 bg-slate-50 border-2 border-slate-100 rounded-[2rem] focus:border-indigo-500 focus:bg-white focus:outline-none transition-all text-slate-700 font-medium shadow-inner resize-none" className="w-full px-6 py-5 bg-slate-50 border-2 border-slate-100 rounded-[2rem] focus:border-indigo-500 focus:bg-white focus:outline-none transition-all text-slate-700 font-medium shadow-inner resize-none"
placeholder="Escreva sua mensagem..." placeholder="Escreva sua mensagem..."
/> />
<div className="flex gap-4"> {editingTemplate.key === 'boletoAVencer' && (
<div className="bg-indigo-50 border border-indigo-100 rounded-2xl p-6 mt-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
<h4 className="font-black text-indigo-800 text-sm flex items-center gap-2 uppercase tracking-widest mb-4">
<Clock size={16} /> Configuração de Disparo Preventivo
</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-black text-indigo-500 uppercase tracking-widest mb-2 ml-1">Antecedência (Dias)</label>
<div className="flex items-center bg-white border border-indigo-100 rounded-xl overflow-hidden shadow-sm focus-within:ring-2 focus-within:ring-indigo-500 transition-all">
<input
type="number" min="1" max="30"
value={templates.automationRules.sendDaysBefore}
onChange={(e) => setTemplates(p => ({ ...p, automationRules: { ...p.automationRules, sendDaysBefore: e.target.value } }))}
className="w-full px-4 py-3 text-sm font-bold text-slate-700 focus:outline-none text-center"
/>
</div>
<p className="text-[9px] text-indigo-400 mt-1.5 ml-1 font-medium">Dias antes do vencimento para avisar.</p>
</div>
<div>
<label className="block text-[10px] font-black text-indigo-500 uppercase tracking-widest mb-2 ml-1">Repetições Max.</label>
<div className="flex items-center bg-white border border-indigo-100 rounded-xl overflow-hidden shadow-sm focus-within:ring-2 focus-within:ring-indigo-500 transition-all">
<input
type="number" min="1" max="10"
value={templates.automationRules.maxPreWarnings}
onChange={(e) => setTemplates(p => ({ ...p, automationRules: { ...p.automationRules, maxPreWarnings: e.target.value } }))}
className="w-full px-4 py-3 text-sm font-bold text-slate-700 focus:outline-none text-center"
/>
</div>
<p className="text-[9px] text-indigo-400 mt-1.5 ml-1 font-medium">Limite de avisos recebidos pelo aluno.</p>
</div>
</div>
</div>
)}
{editingTemplate.key === 'boletoVencido' && (
<div className="bg-red-50 border border-red-100 rounded-2xl p-6 mt-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
<h4 className="font-black text-red-800 text-sm flex items-center gap-2 uppercase tracking-widest mb-4">
<AlertTriangle size={16} /> Configuração de Inadimplência
</h4>
<div className="space-y-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={templates.automationRules.sendOnDueDate}
onChange={(e) => setTemplates(p => ({ ...p, automationRules: { ...p.automationRules, sendOnDueDate: e.target.checked } }))}
className="w-5 h-5 rounded border-slate-300 text-red-600 focus:ring-red-500"
/>
<span className="text-sm font-bold text-red-900">Aviso no dia do vencimento</span>
</label>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-black text-red-500 uppercase tracking-widest mb-2 ml-1">1º aviso (Dias após)</label>
<div className="flex items-center bg-white border border-red-100 rounded-xl overflow-hidden shadow-sm focus-within:ring-2 focus-within:ring-red-500 transition-all">
<input
type="number" min="1" max="30"
value={templates.automationRules.sendDaysAfter}
onChange={(e) => setTemplates(p => ({ ...p, automationRules: { ...p.automationRules, sendDaysAfter: e.target.value } }))}
className="w-full px-4 py-3 text-sm font-bold text-slate-700 focus:outline-none text-center"
/>
</div>
</div>
<div>
<label className="block text-[10px] font-black text-red-500 uppercase tracking-widest mb-2 ml-1">Repetir a cada (Dias)</label>
<div className="flex items-center bg-white border border-red-100 rounded-xl overflow-hidden shadow-sm focus-within:ring-2 focus-within:ring-red-500 transition-all">
<input
type="number" min="1" max="30"
value={templates.automationRules.repeatEveryDays}
onChange={(e) => setTemplates(p => ({ ...p, automationRules: { ...p.automationRules, repeatEveryDays: e.target.value } }))}
className="w-full px-4 py-3 text-sm font-bold text-slate-700 focus:outline-none text-center"
/>
</div>
</div>
</div>
</div>
</div>
)}
<div className="flex gap-4 pt-2">
<button <button
onClick={() => setEditingTemplate(null)} onClick={() => setEditingTemplate(null)}
className="flex-1 py-4 bg-slate-100 text-slate-500 rounded-2xl font-black text-sm hover:bg-slate-200 transition-all active:scale-95" className="flex-1 py-4 bg-slate-100 text-slate-500 rounded-2xl font-black text-sm hover:bg-slate-200 transition-all active:scale-95"

View File

@ -172,6 +172,23 @@ app.put('/api/school-data', async (req, res) => {
return res.status(409).json({ success: false, reason: 'newer_version' }); return res.status(409).json({ success: false, reason: 'newer_version' });
} }
// Inicialização de colunas necessárias para automação
pool.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='pre_warnings_count') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN pre_warnings_count INTEGER DEFAULT 0;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='last_pre_warning_at') THEN
ALTER TABLE alunos_cobrancas ADD COLUMN last_pre_warning_at TIMESTAMP WITH TIME ZONE;
END IF;
END $$;
`).catch(err => console.error('[PostgreSQL] Erro ao inicializar colunas de automação:', err));
pool.on('error', (err) => {
console.error('Erro inesperado no pool:', err);
});
schoolData.lastUpdated = new Date().toISOString(); schoolData.lastUpdated = new Date().toISOString();
await saveSchoolData(schoolData); await saveSchoolData(schoolData);
res.json({ success: true }); res.json({ success: true });
@ -386,11 +403,7 @@ async function sendEvolutionMessage(asaasPaymentId, eventType, paymentPayload =
} }
} }
const fbGerado = 'Olá {nome}, sua cobrança referente a {descricao} no valor de R$ {valor} foi gerada. Vencimento: {vencimento}.'; const fbAVencer = 'Olá {nome}, lembramos que sua cobrança referente a {descricao} no valor de R$ {valor} vencerá em {vencimento}. Segue o PDF abaixo:';
const fbPago = 'Olá {nome}, confirmamos o pagamento de R$ {valor} referente a {descricao}. Muito obrigado!';
const fbAtrasado = 'Olá {nome}, o boleto referente a {descricao} de R$ {valor} venceu em {vencimento}. Segue o PDF da 2ª via atualizada abaixo:';
const fbCancelado = 'Olá {nome}, a cobrança referente a {descricao} foi cancelada.';
const fbAtualizado = 'Olá {nome}, o boleto de {descricao} foi atualizado. Segue a nova versão:';
let templateText = ''; let templateText = '';
if (eventType === 'PAYMENT_CREATED') templateText = templates?.boletoGerado || fbGerado; if (eventType === 'PAYMENT_CREATED') templateText = templates?.boletoGerado || fbGerado;
@ -398,6 +411,8 @@ async function sendEvolutionMessage(asaasPaymentId, eventType, paymentPayload =
else if (eventType === 'PAYMENT_OVERDUE') templateText = templates?.boletoVencido || fbAtrasado; else if (eventType === 'PAYMENT_OVERDUE') templateText = templates?.boletoVencido || fbAtrasado;
else if (eventType === 'PAYMENT_DELETED') templateText = templates?.cobrancaCancelada || fbCancelado; else if (eventType === 'PAYMENT_DELETED') templateText = templates?.cobrancaCancelada || fbCancelado;
else if (eventType === 'PAYMENT_UPDATED') templateText = templates?.cobrancaAtualizada || fbAtualizado; else if (eventType === 'PAYMENT_UPDATED') templateText = templates?.cobrancaAtualizada || fbAtualizado;
else if (eventType === 'PAYMENT_UPCOMING') templateText = templates?.boletoAVencer || fbAVencer;
if (!templateText) return; if (!templateText) return;
let msgFinal = templateText let msgFinal = templateText
@ -823,17 +838,74 @@ async function startServer() {
app.use(vite.middlewares); app.use(vite.middlewares);
} }
// Disparo Manual de Inadimplência // Disparo Manual de Inadimplência e Lembretes
app.post('/api/disparar_cobrancas', async (req, res) => { app.post('/api/disparar_cobrancas', async (req, res) => {
try { try {
const atrasados = await getCobrancasAtrasadas(); const tipo = req.query.tipo || 'ambos';
if (atrasados.length === 0) return res.status(200).json({ message: 'Nenhuma atrasada.' }); const appData = await getSchoolData();
let enviadas = 0; const rules = appData?.messageTemplates?.automationRules || {};
for (const cob of atrasados) { const sendDaysBefore = parseInt(rules.sendDaysBefore) || 3;
if (cob.asaas_payment_id) { await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_OVERDUE'); enviadas++; } const maxPreWarnings = parseInt(rules.maxPreWarnings) || 1;
let enviadasAtraso = 0;
let enviadasAviso = 0;
// 1. Processar Atrasados
if (tipo === 'atrasado' || tipo === 'ambos') {
const atrasados = await getCobrancasAtrasadas();
for (const cob of atrasados) {
if (cob.asaas_payment_id) {
await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_OVERDUE');
enviadasAtraso++;
}
}
} }
return res.status(200).json({ message: `${enviadas} mensagens processadas.` });
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); } // 2. Processar A Vencer (Lembretes Preventivos)
if (tipo === 'preventivo' || tipo === 'ambos') {
const pendentes = await getCobrancasPendentes();
const hoje = new Date();
hoje.setHours(0,0,0,0);
for (const cob of pendentes) {
if (!cob.asaas_payment_id || !cob.vencimento) continue;
const vencimento = new Date(cob.vencimento);
vencimento.setHours(0,0,0,0);
const diffDias = Math.ceil((vencimento.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24));
if (diffDias > 0 && diffDias <= sendDaysBefore) {
const currentCount = parseInt(cob.pre_warnings_count) || 0;
if (currentCount < maxPreWarnings) {
const lastWarn = cob.last_pre_warning_at ? new Date(cob.last_pre_warning_at) : null;
const jaEnviadoHoje = lastWarn && lastWarn.toDateString() === hoje.toDateString();
if (!jaEnviadoHoje) {
await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_UPCOMING');
await pool.query(
'UPDATE alunos_cobrancas SET pre_warnings_count = $1, last_pre_warning_at = NOW() WHERE asaas_payment_id = $2',
[currentCount + 1, cob.asaas_payment_id]
);
enviadasAviso++;
}
}
}
}
}
let msg = '';
if (tipo === 'atrasado') msg = `${enviadasAtraso} mensagens de atraso processadas.`;
else if (tipo === 'preventivo') msg = `${enviadasAviso} lembretes preventivos processados.`;
else msg = `${enviadasAtraso} mensagens de atraso e ${enviadasAviso} lembretes preventivos processados.`;
return res.status(200).json({ message: msg });
} catch (error) {
console.error('[Disparo] Erro:', error);
return res.status(500).json({ error: 'Erro interno.' });
}
}); });
// Imprimir Carnê // Imprimir Carnê

View File

@ -121,6 +121,13 @@ export async function getCobrancasByAlunoId(alunoId) {
return rows; return rows;
} }
export async function getCobrancasPendentes() {
const { rows } = await pool.query(
"SELECT * FROM alunos_cobrancas WHERE status = 'PENDENTE'"
);
return rows;
}
export async function getCobrancasAtrasadas() { export async function getCobrancasAtrasadas() {
const { rows } = await pool.query( const { rows } = await pool.query(
"SELECT * FROM alunos_cobrancas WHERE status = 'ATRASADO'" "SELECT * FROM alunos_cobrancas WHERE status = 'ATRASADO'"