feat: automação de lembretes preventivos, refatoração de disparos seletivos e padronização de modais
This commit is contained in:
parent
840a8ee159
commit
065476df16
|
|
@ -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).
|
||||||
|
|
|
||||||
14
MEMORY.md
14
MEMORY.md
|
|
@ -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:**
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 tipo = req.query.tipo || 'ambos';
|
||||||
|
const appData = await getSchoolData();
|
||||||
|
const rules = appData?.messageTemplates?.automationRules || {};
|
||||||
|
const sendDaysBefore = parseInt(rules.sendDaysBefore) || 3;
|
||||||
|
const maxPreWarnings = parseInt(rules.maxPreWarnings) || 1;
|
||||||
|
|
||||||
|
let enviadasAtraso = 0;
|
||||||
|
let enviadasAviso = 0;
|
||||||
|
|
||||||
|
// 1. Processar Atrasados
|
||||||
|
if (tipo === 'atrasado' || tipo === 'ambos') {
|
||||||
const atrasados = await getCobrancasAtrasadas();
|
const atrasados = await getCobrancasAtrasadas();
|
||||||
if (atrasados.length === 0) return res.status(200).json({ message: 'Nenhuma atrasada.' });
|
|
||||||
let enviadas = 0;
|
|
||||||
for (const cob of atrasados) {
|
for (const cob of atrasados) {
|
||||||
if (cob.asaas_payment_id) { await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_OVERDUE'); enviadas++; }
|
if (cob.asaas_payment_id) {
|
||||||
|
await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_OVERDUE');
|
||||||
|
enviadasAtraso++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.' });
|
||||||
}
|
}
|
||||||
return res.status(200).json({ message: `${enviadas} mensagens processadas.` });
|
|
||||||
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Imprimir Carnê
|
// Imprimir Carnê
|
||||||
|
|
|
||||||
|
|
@ -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'"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue