feat: enhance mass send (first name, dual dispatch, emojis, attachments) and refine UI

This commit is contained in:
Sidney 2026-05-14 09:06:22 -03:00
parent 5a767ab87b
commit 88eee27265
6 changed files with 152 additions and 46 deletions

View File

@ -61,3 +61,4 @@
32. **Numerical Type Enforcement**: When serving data from PostgreSQL `NUMERIC` columns, the backend MUST map the result to ensure amounts are sent as `Number` (not String) to prevent string concatenation bugs in the frontend.
33. **Reverse Sync Requirement (SQL -> JSON)**: To ensure visual consistency and avoid automation errors (like double billing), the system MUST perform a reverse sync from `alunos_cobrancas` (SQL) to `school_data.payments` (JSON) during server initialization and on-demand via the `/api/admin/sync-finance-json` endpoint.
34. **Deletion & Notification Order (Async)**: When processing deletions that trigger notifications (e.g., WhatsApp via Asaas Webhook), local database deletion MUST happen only AFTER the notification dispatch. Manual deletion routes should only trigger the external API delete and wait for the webhook to finalize local cleanup, ensuring data availability for message variables.
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.

View File

@ -119,6 +119,9 @@
### 📢 Automação de Mensagens
- [x] **Estabilização de Lembretes (V2):** Resolvido bug de deslocamento de fuso horário (-1 dia) nas datas de vencimento. Implementadas ferramentas de debug (ignorar trava diária e reset de contadores) e **controle manual de delay para disparos em massa** para facilitar testes e garantir segurança contra banimento.
- [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.
## 📋 Próximos Passos Pendentes

View File

@ -861,40 +861,41 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Financeiro</h2>
<p className="text-slate-500 text-sm">Gestão de mensalidades vinculadas a contratos e cursos.</p>
</div>
<div className="flex flex-wrap gap-2 w-full sm:w-auto">
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto">
<div className="relative">
<button
onClick={() => setShowPrintCarneModal(true)}
className="flex-1 sm:flex-none bg-white text-indigo-600 border border-indigo-200 px-6 py-3 rounded-xl flex items-center justify-center gap-2 hover:bg-indigo-50 transition-all shadow-sm font-bold active:scale-95"
className="flex-1 sm:flex-none bg-white text-indigo-600 border border-indigo-200 px-4 py-2 rounded-xl flex items-center justify-center gap-2 hover:bg-indigo-50 transition-all shadow-sm font-bold text-sm active:scale-95"
>
<Printer size={20} /> Imprimir Carnê
</button>
</div>
<button
onClick={() => setShowSupabaseModal(true)}
className="flex-1 sm:flex-none bg-slate-800 text-white px-6 py-3 rounded-xl flex items-center justify-center gap-2 hover:bg-slate-900 transition-all shadow-lg font-bold active:scale-95"
>
<Database size={20} /> DB Supabase
<Printer size={18} /> <span className="hidden sm:inline">Imprimir Carnê</span><span className="sm:hidden">Carnê</span>
</button>
<button
onClick={syncAsaasPayments}
disabled={isSyncing}
className="flex-1 sm:flex-none bg-emerald-600 text-white px-6 py-3 rounded-xl flex items-center justify-center gap-2 hover:bg-emerald-700 transition-all shadow-lg font-bold active:scale-95 disabled:opacity-50"
className="flex-1 sm:flex-none bg-emerald-600 text-white px-4 py-2 rounded-xl flex items-center justify-center gap-2 hover:bg-emerald-700 transition-all shadow-sm font-bold text-sm active:scale-95 disabled:opacity-50"
title="Sincronizar pagamentos com Asaas"
>
<RefreshCw size={20} className={isSyncing ? 'animate-spin' : ''} />
{isSyncing ? 'Sincronizando...' : 'Sincronizar Asaas'}
<RefreshCw size={18} className={isSyncing ? 'animate-spin' : ''} />
<span className="hidden sm:inline">{isSyncing ? 'Sincronizando...' : 'Sincronizar Asaas'}</span>
<span className="sm:hidden">{isSyncing ? '...' : 'Asaas'}</span>
</button>
<button
onClick={() => setIsModalOpen(true)}
className="flex-1 sm:flex-none bg-indigo-600 text-white px-6 py-3 rounded-xl flex items-center justify-center gap-2 hover:bg-indigo-700 transition-all shadow-lg font-bold active:scale-95"
className="flex-1 sm:flex-none bg-indigo-600 text-white px-4 py-2 rounded-xl flex items-center justify-center gap-2 hover:bg-indigo-700 transition-all shadow-sm font-bold text-sm active:scale-95"
>
<Plus size={20} /> Novo Lançamento
<Plus size={18} /> <span className="hidden sm:inline">Novo Lançamento</span><span className="sm:hidden">Novo</span>
</button>
<button
onClick={() => setShowSupabaseModal(true)}
className="flex-none bg-slate-800 text-white w-9 h-9 rounded-xl flex items-center justify-center hover:bg-slate-900 transition-all shadow-sm active:scale-95"
title="DB Supabase"
>
<Database size={18} />
</button>
</div>
</div>

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { SchoolData } from '../types';
import { useDialog } from '../DialogContext';
import { MessageSquare, Save, Info, Settings, Send, Clock, AlertTriangle, FileText, CheckCircle, Cake, X, Power, BookOpen } from 'lucide-react';
import { MessageSquare, Save, Info, Settings, Send, Clock, AlertTriangle, FileText, CheckCircle, Cake, X, Power, BookOpen, Smile, Paperclip } from 'lucide-react';
interface MessagesProps {
data: SchoolData;
@ -68,7 +68,10 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
const [messageText, setMessageText] = useState('');
const [isSendingMass, setIsSendingMass] = useState(false);
const [massDelay, setMassDelay] = useState('60');
const [massAttachment, setMassAttachment] = useState<File | null>(null);
const [isSendingBdays, setIsSendingBdays] = useState(false);
const [showMassEmojis, setShowMassEmojis] = useState(false);
const commonEmojis = ['😀', '😂', '🥰', '😎', '🎉', '👍', '🙏', '❤️', '🔥', '🚀', '✅', '❌', '⚠️', '💡', '🎓', '🏫', '📚', '📖', '✏️', '📝', '🎒', '💻', '🧠', '🤓', '🥇'];
// Modal de Edição de Modelo
const [editingTemplate, setEditingTemplate] = useState<{
@ -217,38 +220,50 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
return showAlert('Erro', 'Nenhum aluno com telefone cadastrado foi selecionado.', 'error');
}
const payloadAlunos = validStudents.map(a => {
let nome = a.name;
let telefone = a.phone;
const payloadAlunos = validStudents.flatMap(a => {
const entries = [];
if (a.birthDate) {
const birthDate = new Date(a.birthDate);
const age = Math.abs(new Date(Date.now() - birthDate.getTime()).getUTCFullYear() - 1970);
if (age < 18) {
nome = a.guardianName || a.name;
telefone = a.guardianPhone || a.phone;
}
// Entrada para o Aluno
if (a.phone) {
entries.push({
nome: a.name.split(' ')[0],
telefone: a.phone,
matricula: a.enrollmentNumber || '—'
});
}
return { nome, telefone, matricula: a.enrollmentNumber || '—' };
// Entrada para o Responsável (apenas se for um número diferente ou se o aluno não tiver número)
if (a.guardianPhone && a.guardianPhone !== a.phone) {
entries.push({
nome: (a.guardianName || a.name).split(' ')[0],
telefone: a.guardianPhone,
matricula: a.enrollmentNumber || '—'
});
}
return entries;
});
setIsSendingMass(true);
try {
const formData = new FormData();
formData.append('alunos', JSON.stringify(payloadAlunos));
formData.append('mensagem', normalizeLineBreaks(messageText));
formData.append('delay', String(parseInt(massDelay) || 60));
if (massAttachment) {
formData.append('attachment', massAttachment);
}
const resp = await fetch('/api/enviar-massa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
alunos: payloadAlunos,
mensagem: normalizeLineBreaks(messageText),
delay: parseInt(massDelay) || 60
})
body: formData
});
const resData = await resp.json();
if (resp.ok) {
setMessageText('');
setTargetId('');
setMassAttachment(null);
showAlert('Sucesso', 'Disparo iniciado no servidor! Você já pode fechar esta tela ou continuar usando o sistema.', 'success');
} else {
showAlert('Erro', resData.error || 'Erro ao iniciar disparo.', 'error');
@ -342,7 +357,7 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
<div>
<label className="block text-[10px] font-black text-emerald-600 uppercase mb-2 ml-1">Mensagem Personalizada</label>
<div className="flex flex-wrap gap-1 mb-2">
<div className="flex flex-wrap gap-1 mb-2 relative">
{['{nome}', '{matricula}'].map(v => (
<button
key={v}
@ -360,6 +375,37 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
{v}
</button>
))}
<button
onClick={() => setShowMassEmojis(!showMassEmojis)}
className="flex items-center justify-center text-[9px] bg-emerald-100/50 text-emerald-700 px-2 py-1 rounded-md border border-emerald-200 hover:bg-emerald-600 hover:text-white transition-all shadow-sm ml-auto"
title="Inserir Emoji"
>
<Smile size={12} />
</button>
{showMassEmojis && (
<div className="absolute right-0 top-8 z-10 bg-white border border-emerald-200 rounded-xl shadow-xl p-2 grid grid-cols-5 gap-1 w-48 animate-in fade-in zoom-in duration-200">
{commonEmojis.map(emoji => (
<button
key={emoji}
onClick={() => {
const textarea = document.getElementById('mass-editor') as HTMLTextAreaElement;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newText = messageText.substring(0, start) + emoji + messageText.substring(end);
setMessageText(newText);
setShowMassEmojis(false);
setTimeout(() => { textarea.focus(); textarea.setSelectionRange(start + emoji.length, start + emoji.length); }, 10);
}}
className="hover:bg-emerald-50 text-lg rounded flex items-center justify-center p-1 transition-colors"
>
{emoji}
</button>
))}
</div>
)}
</div>
<textarea
id="mass-editor"
@ -369,6 +415,34 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
/>
<div className="mt-2 flex items-center justify-between">
<div className="flex-1">
{massAttachment ? (
<div className="flex items-center gap-2 bg-emerald-50 text-emerald-700 text-[10px] font-bold px-2 py-1.5 rounded-lg border border-emerald-100">
<span className="truncate max-w-[150px]">{massAttachment.name}</span>
<button onClick={() => setMassAttachment(null)} className="text-emerald-500 hover:text-red-500" title="Remover anexo"><X size={12} /></button>
</div>
) : (
<div className="text-[9px] text-slate-400 font-medium">Nenhum anexo</div>
)}
</div>
<label className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-100 text-emerald-700 rounded-lg border border-emerald-200 text-[10px] font-black uppercase cursor-pointer hover:bg-emerald-600 hover:text-white transition-all shadow-sm">
<Paperclip size={12} />
<span>Anexar Arquivo</span>
<input
type="file"
className="hidden"
accept="image/*,application/pdf"
onChange={(e) => {
if (e.target.files && e.target.files[0]) {
setMassAttachment(e.target.files[0]);
}
}}
/>
</label>
</div>
</div>
<div className="bg-white/50 p-3 rounded-xl border border-emerald-100 mb-2">

View File

@ -187,7 +187,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const linkedExams = (data.exams || []).filter(e =>
String(e.subjectId).trim() === String(subject.id).trim() &&
String(e.periodId).trim() === String(period.id).trim() &&
(e.status === 'published' || !!studentSubmissions[String(e.id).trim()])
!!studentSubmissions[String(e.id).trim()]
);
if (linkedExams.length > 0) {
@ -596,7 +596,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
{(() => {
const linkedExams = (data.exams || []).filter(e =>
String(e.subjectId).trim() === String(subject.id).trim() &&
(e.status === 'published' || !!studentSubmissions[String(e.id).trim()])
!!studentSubmissions[String(e.id).trim()]
);
const provasCount = linkedExams.filter(e => (e as any).evaluationType !== 'activity').length;
const atividadesCount = linkedExams.filter(e => (e as any).evaluationType === 'activity').length;
@ -637,7 +637,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const linkedExams = (data.exams || []).filter(e =>
String(e.subjectId).trim() === String(subject.id).trim() &&
String(e.periodId).trim() === String(period.id).trim() &&
(e.status === 'published' || !!studentSubmissions[String(e.id).trim()])
!!studentSubmissions[String(e.id).trim()]
);
const periodGrades = studentGrades[subject.id]?.[period.id] || {};
const validPeriodValues = Object.values(periodGrades).filter(v => v !== '');

View File

@ -1094,14 +1094,24 @@ app.put('/api/notificacoes/remover-anexo/:id', async (req, res) => {
// ============================================================
// Disparo em Massa
// ============================================================
app.post('/api/enviar-massa', (req, res) => {
const { alunos, mensagem, delay } = req.body;
app.post('/api/enviar-massa', upload.single('attachment'), (req, res) => {
let { alunos, mensagem, delay } = req.body;
if (typeof alunos === 'string') {
try { alunos = JSON.parse(alunos); } catch (e) { alunos = []; }
}
if (!alunos || !Array.isArray(alunos) || alunos.length === 0) return res.status(400).json({ error: 'Nenhum aluno.' });
res.status(200).json({ success: true, message: 'Background iniciado.' });
processarFilaWhatsApp(alunos, mensagem, delay || 60);
const fileData = req.file ? {
buffer: req.file.buffer.toString('base64'),
mimetype: req.file.mimetype,
originalname: req.file.originalname
} : null;
processarFilaWhatsApp(alunos, mensagem, parseInt(delay) || 60, fileData);
});
async function processarFilaWhatsApp(alunos, mensagemTemplate, customDelay = 60) {
async function processarFilaWhatsApp(alunos, mensagemTemplate, customDelay = 60, fileData = null) {
const appData = await getSchoolData();
const evoConfig = appData?.evolutionConfig;
if (!evoConfig?.apiUrl || !evoConfig?.apiKey || !evoConfig?.instanceName) return;
@ -1112,8 +1122,25 @@ async function processarFilaWhatsApp(alunos, mensagemTemplate, customDelay = 60)
try {
let cleanPhone = aluno.telefone.replace(/\D/g, '');
if (cleanPhone.length === 10 || cleanPhone.length === 11) cleanPhone = '55' + cleanPhone;
const url = `${evoConfig.apiUrl.replace(/\/$/, '')}/message/sendText/${evoConfig.instanceName}`;
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': evoConfig.apiKey }, body: JSON.stringify({ number: cleanPhone, text: msg }) });
let url, payload;
if (fileData) {
url = `${evoConfig.apiUrl.replace(/\/$/, '')}/message/sendMedia/${evoConfig.instanceName}`;
payload = {
number: cleanPhone,
options: { delay: 1200, presence: "composing" },
mediatype: fileData.mimetype.includes('pdf') ? 'document' : 'image',
mimetype: fileData.mimetype,
fileName: fileData.originalname,
media: fileData.buffer,
caption: msg
};
} else {
url = `${evoConfig.apiUrl.replace(/\/$/, '')}/message/sendText/${evoConfig.instanceName}`;
payload = { number: cleanPhone, text: msg };
}
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': evoConfig.apiKey }, body: JSON.stringify(payload) });
} catch (error) { console.error(`[Massa] Erro ${aluno.nome}:`, error.message); }
if (i < alunos.length - 1) {
// Delay base informado pelo usuário + variância aleatória de 0-30s para evitar padrões robóticos