feat: enhance mass send (first name, dual dispatch, emojis, attachments) and refine UI
This commit is contained in:
parent
5a767ab87b
commit
88eee27265
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 !== '');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue