feat: central de notificações profissionais (financeiro e acadêmico) e estabilização de frequência
This commit is contained in:
parent
f1b02f0337
commit
4c8ce88ca1
|
|
@ -1,5 +1,4 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import { Bell, X, CheckCircle, Trash2, ShieldCheck, FileText, Paperclip, DollarSign, AlertTriangle, Info, TrendingUp, CreditCard } from 'lucide-react';
|
||||||
import { Bell, X, CheckCircle, Trash2, ShieldCheck, FileText, Paperclip } from 'lucide-react';
|
|
||||||
import { SchoolData, Notification, View } from '../types';
|
import { SchoolData, Notification, View } from '../types';
|
||||||
import { dbService } from '../services/dbService';
|
import { dbService } from '../services/dbService';
|
||||||
|
|
||||||
|
|
@ -81,6 +80,21 @@ const AdminNotifications: React.FC<Props> = ({ data, updateData, setView, onNavi
|
||||||
const handleAction = (notif: Notification) => {
|
const handleAction = (notif: Notification) => {
|
||||||
if (!notif.read) handleMarkAsRead(notif.id);
|
if (!notif.read) handleMarkAsRead(notif.id);
|
||||||
|
|
||||||
|
const isFinance = notif.title.toLowerCase().includes('pagamento') || notif.title.toLowerCase().includes('cobrança');
|
||||||
|
const isExam = notif.title.toLowerCase().includes('prova') || notif.title.toLowerCase().includes('atividade');
|
||||||
|
|
||||||
|
if (isFinance) {
|
||||||
|
setView(View.Finance);
|
||||||
|
setIsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExam) {
|
||||||
|
setView(View.ReportCard);
|
||||||
|
setIsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (notif.title.toLowerCase().includes('justificativa') || notif.message.toLowerCase().includes('justificativa')) {
|
if (notif.title.toLowerCase().includes('justificativa') || notif.message.toLowerCase().includes('justificativa')) {
|
||||||
const targetId = (notif as any).fromStudentId || notif.studentId;
|
const targetId = (notif as any).fromStudentId || notif.studentId;
|
||||||
if (onNavigateToStudent && targetId !== 'admin') {
|
if (onNavigateToStudent && targetId !== 'admin') {
|
||||||
|
|
@ -164,7 +178,7 @@ const AdminNotifications: React.FC<Props> = ({ data, updateData, setView, onNavi
|
||||||
<div className="absolute top-14 right-0 w-80 sm:w-96 bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden animate-in slide-in-from-top-4 fade-in duration-200 flex flex-col max-h-[80vh]">
|
<div className="absolute top-14 right-0 w-80 sm:w-96 bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden animate-in slide-in-from-top-4 fade-in duration-200 flex flex-col max-h-[80vh]">
|
||||||
<div className="p-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between sticky top-0 z-10">
|
<div className="p-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between sticky top-0 z-10">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-black text-slate-800 flex items-center gap-2">Atividades/Provas Pendentes
|
<h3 className="font-black text-slate-800 flex items-center gap-2">Central de Alertas
|
||||||
{unreadCount > 0 && <span className="bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full text-[10px] font-bold">{unreadCount}</span>}
|
{unreadCount > 0 && <span className="bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full text-[10px] font-bold">{unreadCount}</span>}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -193,6 +207,7 @@ const AdminNotifications: React.FC<Props> = ({ data, updateData, setView, onNavi
|
||||||
let displayMessage = notif.message;
|
let displayMessage = notif.message;
|
||||||
let justificationMotive = '';
|
let justificationMotive = '';
|
||||||
let attachmentFromMessage = null;
|
let attachmentFromMessage = null;
|
||||||
|
let metadata: any = {};
|
||||||
|
|
||||||
if (notif.message.startsWith('{')) {
|
if (notif.message.startsWith('{')) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -203,20 +218,64 @@ const AdminNotifications: React.FC<Props> = ({ data, updateData, setView, onNavi
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (notif.anexo && notif.anexo.startsWith('{')) {
|
||||||
|
try { metadata = JSON.parse(notif.anexo); } catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
const finalAttachment = notif.attachment || attachmentFromMessage;
|
const finalAttachment = notif.attachment || attachmentFromMessage;
|
||||||
|
const isFinance = metadata.type === 'finance' || notif.title.toLowerCase().includes('pagamento') || notif.title.toLowerCase().includes('cobrança');
|
||||||
|
const isExam = metadata.type === 'exam' || notif.title.toLowerCase().includes('prova') || notif.title.toLowerCase().includes('atividade');
|
||||||
|
|
||||||
|
// Configuração dinâmica de cores e ícones
|
||||||
|
let icon = <Info size={16} />;
|
||||||
|
let colorClass = 'text-indigo-500';
|
||||||
|
let bgClass = 'bg-indigo-50';
|
||||||
|
let borderClass = 'border-indigo-100';
|
||||||
|
|
||||||
|
if (isFinance) {
|
||||||
|
if (metadata.status === 'paid' || notif.title.includes('Confirmado')) {
|
||||||
|
icon = <DollarSign size={16} />;
|
||||||
|
colorClass = 'text-emerald-500';
|
||||||
|
bgClass = 'bg-emerald-50';
|
||||||
|
borderClass = 'border-emerald-100';
|
||||||
|
} else if (metadata.status === 'overdue' || notif.title.includes('Atraso')) {
|
||||||
|
icon = <AlertTriangle size={16} />;
|
||||||
|
colorClass = 'text-red-500';
|
||||||
|
bgClass = 'bg-red-50';
|
||||||
|
borderClass = 'border-red-100';
|
||||||
|
} else {
|
||||||
|
icon = <TrendingUp size={16} />;
|
||||||
|
colorClass = 'text-blue-500';
|
||||||
|
bgClass = 'bg-blue-50';
|
||||||
|
borderClass = 'border-blue-100';
|
||||||
|
}
|
||||||
|
} else if (isExam) {
|
||||||
|
icon = <ClipboardList size={16} />;
|
||||||
|
colorClass = 'text-violet-600';
|
||||||
|
bgClass = 'bg-violet-50';
|
||||||
|
borderClass = 'border-violet-100';
|
||||||
|
} else if (isJustificativa) {
|
||||||
|
icon = <FileText size={16} />;
|
||||||
|
colorClass = 'text-amber-500';
|
||||||
|
bgClass = 'bg-amber-50';
|
||||||
|
borderClass = 'border-amber-100';
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={notif.id} onClick={() => handleAction(notif)} className={`p-3 rounded-xl border transition-all cursor-pointer relative overflow-hidden group ${notif.read ? 'bg-slate-50 border-transparent opacity-70' : 'bg-white border-indigo-100 hover:border-indigo-300 shadow-sm'}`}>
|
<div key={notif.id} onClick={() => handleAction(notif)} className={`p-3 rounded-xl border transition-all cursor-pointer relative overflow-hidden group ${notif.read ? 'bg-slate-50 border-transparent opacity-70' : `bg-white ${borderClass} hover:shadow-md shadow-sm`}`}>
|
||||||
{!notif.read && <div className="absolute left-0 top-0 bottom-0 w-1 bg-indigo-500"></div>}
|
{!notif.read && <div className={`absolute left-0 top-0 bottom-0 w-1 ${colorClass.replace('text-', 'bg-')}`}></div>}
|
||||||
<div className="flex justify-between items-start mb-1 gap-4">
|
<div className="flex justify-between items-start mb-1 gap-4">
|
||||||
<h4 className={`text-base font-black tracking-tight ${notif.read ? 'text-slate-400' : 'text-emerald-500 animate-pulse'}`}>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`${colorClass} ${bgClass} p-1 rounded-md`}>{icon}</span>
|
||||||
|
<h4 className={`text-sm font-black tracking-tight ${notif.read ? 'text-slate-400' : 'text-slate-800'}`}>
|
||||||
{notif.title}
|
{notif.title}
|
||||||
</h4>
|
</h4>
|
||||||
<span className={`text-[10px] font-bold whitespace-nowrap px-2 py-1 rounded ${notif.read ? 'bg-slate-100 text-slate-400' : 'bg-emerald-50 text-emerald-600 border border-emerald-100'}`}>
|
</div>
|
||||||
|
<span className={`text-[10px] font-bold whitespace-nowrap px-2 py-1 rounded ${notif.read ? 'bg-slate-100 text-slate-400' : `${bgClass} ${colorClass} border ${borderClass}`}`}>
|
||||||
{new Date(notif.createdAt).toLocaleDateString('pt-BR')}
|
{new Date(notif.createdAt).toLocaleDateString('pt-BR')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-sm font-medium leading-relaxed mb-2 ${notif.read ? 'text-slate-400' : 'text-emerald-600/90'}`}>
|
<p className={`text-xs font-medium leading-relaxed mb-2 ${notif.read ? 'text-slate-400' : 'text-slate-600'}`}>
|
||||||
{displayMessage}
|
{displayMessage}
|
||||||
</p>
|
</p>
|
||||||
{isJustificativa && justificationMotive && (
|
{isJustificativa && justificationMotive && (
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,20 @@ const lockCache = new Set();
|
||||||
let activeCronJob = null; // Referência global para o agendamento preventivo
|
let activeCronJob = null; // Referência global para o agendamento preventivo
|
||||||
let activeCronJobOverdue = null; // Referência global para o agendamento de inadimplência
|
let activeCronJobOverdue = null; // Referência global para o agendamento de inadimplência
|
||||||
|
|
||||||
// ============================================================
|
// === Funções Auxiliares de Notificação ===
|
||||||
|
async function createAdminNotification(titulo, mensagem, metadata = {}) {
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO notificacoes (aluno_id, titulo, mensagem, anexo) VALUES ($1, $2, $3, $4)',
|
||||||
|
['admin', titulo, mensagem, JSON.stringify(metadata)]
|
||||||
|
);
|
||||||
|
console.log(`[Notification] Alerta Admin criado: ${titulo}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Notification] Erro ao criar alerta admin:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Proxy de Imagens do MinIO (acesso público via backend)
|
// Proxy de Imagens do MinIO (acesso público via backend)
|
||||||
// ============================================================
|
|
||||||
app.get(/^\/storage\/([^\/]+)\/(.+)$/, async (req, res) => {
|
app.get(/^\/storage\/([^\/]+)\/(.+)$/, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bucket = req.params[0];
|
const bucket = req.params[0];
|
||||||
|
|
@ -724,6 +735,7 @@ app.post('/api/webhook_asaas', async (req, res) => {
|
||||||
|
|
||||||
const asaasPaymentId = payload.payment.id;
|
const asaasPaymentId = payload.payment.id;
|
||||||
let updateData = {};
|
let updateData = {};
|
||||||
|
const targetName = payload.payment.customerName || 'Cliente';
|
||||||
|
|
||||||
switch (payload.event) {
|
switch (payload.event) {
|
||||||
case 'PAYMENT_CREATED':
|
case 'PAYMENT_CREATED':
|
||||||
|
|
@ -740,23 +752,48 @@ app.post('/api/webhook_asaas', async (req, res) => {
|
||||||
if (payload.payment.transactionReceiptUrl) {
|
if (payload.payment.transactionReceiptUrl) {
|
||||||
updateData.transaction_receipt_url = payload.payment.transactionReceiptUrl;
|
updateData.transaction_receipt_url = payload.payment.transactionReceiptUrl;
|
||||||
}
|
}
|
||||||
// Chamada única: sendEvolutionMessage já possui trava interna de cache por ID de pagamento
|
|
||||||
|
// Alerta no Sino (Admin)
|
||||||
|
createAdminNotification(
|
||||||
|
'✅ Pagamento Confirmado',
|
||||||
|
`Recebemos R$ ${Number(payload.payment.value).toFixed(2)} de ${targetName}.`,
|
||||||
|
{ type: 'finance', status: 'paid', paymentId: asaasPaymentId }
|
||||||
|
);
|
||||||
|
|
||||||
sendEvolutionMessage(asaasPaymentId, 'PAYMENT_RECEIVED');
|
sendEvolutionMessage(asaasPaymentId, 'PAYMENT_RECEIVED');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'PAYMENT_OVERDUE':
|
case 'PAYMENT_OVERDUE':
|
||||||
|
updateData = { status: 'ATRASADO' };
|
||||||
|
|
||||||
|
// Alerta no Sino (Admin)
|
||||||
|
createAdminNotification(
|
||||||
|
'⚠️ Pagamento em Atraso',
|
||||||
|
`A cobrança de ${targetName} no valor de R$ ${Number(payload.payment.value).toFixed(2)} está vencida.`,
|
||||||
|
{ type: 'finance', status: 'overdue', paymentId: asaasPaymentId }
|
||||||
|
);
|
||||||
|
|
||||||
|
sendEvolutionMessage(asaasPaymentId, 'PAYMENT_OVERDUE');
|
||||||
|
break;
|
||||||
|
|
||||||
case 'PAYMENT_UPDATED':
|
case 'PAYMENT_UPDATED':
|
||||||
case 'PAYMENT_RESTORED':
|
// Alerta no Sino (Admin)
|
||||||
const statusMap = { 'PENDING': 'PENDENTE', 'OVERDUE': 'ATRASADO', 'RECEIVED': 'PAGO', 'CONFIRMED': 'PAGO', 'RECEIVED_IN_CASH': 'PAGO', 'REFUNDED': 'CANCELADO', 'DELETED': 'CANCELADO' };
|
createAdminNotification(
|
||||||
updateData = { valor: payload.payment.value, vencimento: payload.payment.dueDate, status: statusMap[payload.payment.status] || undefined };
|
'📝 Cobrança Alterada',
|
||||||
Object.keys(updateData).forEach(k => updateData[k] === undefined && delete updateData[k]);
|
`A cobrança de ${targetName} foi atualizada no Asaas.`,
|
||||||
// Ocultado PAYMENT_OVERDUE aqui para ser enviado apenas pela rotina/cron (conforme regras)
|
{ type: 'finance', status: 'updated', paymentId: asaasPaymentId }
|
||||||
// if (payload.event === 'PAYMENT_OVERDUE') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_OVERDUE');
|
);
|
||||||
if (payload.event === 'PAYMENT_UPDATED') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_UPDATED');
|
if (payload.event === 'PAYMENT_UPDATED') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_UPDATED');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'PAYMENT_DELETED':
|
case 'PAYMENT_DELETED':
|
||||||
case 'PAYMENT_CANCELED':
|
case 'PAYMENT_CANCELED':
|
||||||
|
// Alerta no Sino (Admin)
|
||||||
|
createAdminNotification(
|
||||||
|
'🗑️ Cobrança Removida',
|
||||||
|
`A cobrança de ${targetName} (R$ ${Number(payload.payment.value).toFixed(2)}) foi excluída no Asaas.`,
|
||||||
|
{ type: 'finance', status: 'deleted', paymentId: asaasPaymentId }
|
||||||
|
);
|
||||||
const installmentId = payload.payment.installment;
|
const installmentId = payload.payment.installment;
|
||||||
if (installmentId) {
|
if (installmentId) {
|
||||||
if (cancelCache.has(installmentId)) {
|
if (cancelCache.has(installmentId)) {
|
||||||
|
|
|
||||||
|
|
@ -616,6 +616,7 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
|
||||||
|
|
||||||
// Integrar com notas_boletim (Nova Tabela) em vez de school_data
|
// Integrar com notas_boletim (Nova Tabela) em vez de school_data
|
||||||
if (exam.subjectId && exam.periodId) {
|
if (exam.subjectId && exam.periodId) {
|
||||||
|
try {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO notas_boletim (aluno_id, disciplina_id, periodo_id, prova_id, valor, updated_at)
|
`INSERT INTO notas_boletim (aluno_id, disciplina_id, periodo_id, prova_id, valor, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||||
|
|
@ -623,6 +624,38 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
|
||||||
DO UPDATE SET valor = EXCLUDED.valor, updated_at = NOW()`,
|
DO UPDATE SET valor = EXCLUDED.valor, updated_at = NOW()`,
|
||||||
[req.user.studentId, exam.subjectId, exam.periodId, examId, finalScore]
|
[req.user.studentId, exam.subjectId, exam.periodId, examId, finalScore]
|
||||||
);
|
);
|
||||||
|
} catch (gradeErr) {
|
||||||
|
console.error('[Portal:Submissão] Erro ao salvar nota no boletim:', gradeErr.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inserir notificação para o ADMIN no Sino
|
||||||
|
try {
|
||||||
|
const { rows: info } = await pool.query(
|
||||||
|
`SELECT a.nome as student_name, t.nome as class_name
|
||||||
|
FROM alunos a
|
||||||
|
LEFT JOIN turmas t ON a.turma_id = t.id
|
||||||
|
WHERE a.id = $1`,
|
||||||
|
[req.user.studentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const studentName = info[0]?.student_name || req.user.name || 'Aluno';
|
||||||
|
const className = info[0]?.class_name || 'Turma não identificada';
|
||||||
|
const typeLabel = (exam as any).evaluationType === 'activity' ? 'Atividade' : 'Prova';
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO notificacoes (aluno_id, titulo, mensagem, anexo, lida, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, NOW())`,
|
||||||
|
[
|
||||||
|
'admin',
|
||||||
|
`📝 ${typeLabel} Finalizada`,
|
||||||
|
`${studentName} (${className}) finalizou a ${typeLabel.toLowerCase()} "${exam.title}" com nota ${finalScore}.`,
|
||||||
|
JSON.stringify({ type: 'exam', studentId: req.user.studentId, examId, grade: finalScore }),
|
||||||
|
false
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (notifErr) {
|
||||||
|
console.error('[Portal:Submissão] Erro ao disparar notificação admin:', notifErr.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, result: { total_questions: totalQuestions, correct_count: correctCount, wrong_count: wrongCount, percentage, final_score: finalScore } });
|
res.json({ success: true, result: { total_questions: totalQuestions, correct_count: correctCount, wrong_count: wrongCount, percentage, final_score: finalScore } });
|
||||||
|
|
|
||||||
|
|
@ -188,20 +188,20 @@ export default function Frequencia() {
|
||||||
let justified = 0;
|
let justified = 0;
|
||||||
|
|
||||||
processedItems.forEach(item => {
|
processedItems.forEach(item => {
|
||||||
const { lesson, attendances: atts } = item;
|
const { lesson, attendances: atts, isCompleted } = item;
|
||||||
if (lesson.status === 'cancelled') return;
|
if (lesson.status === 'cancelled') return;
|
||||||
|
|
||||||
const record = atts[0];
|
const record = atts[0];
|
||||||
const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00');
|
|
||||||
|
|
||||||
if (record) {
|
if (record) {
|
||||||
if (record.type === 'absence') {
|
if (record.type === 'absence') {
|
||||||
if (record.justificationAccepted) justified++;
|
if (record.justificationAccepted) justified++;
|
||||||
else absences++;
|
else absences++;
|
||||||
} else if (record.type === 'presence' || (!record.type && !(record as any).isVirtual)) {
|
} else if (record.type === 'presence' || (!record.type && !(record as any).isVirtual)) {
|
||||||
|
// No portal, só contamos como presença nas estatísticas se a aula terminou ou se já foi marcada
|
||||||
presences++;
|
presences++;
|
||||||
}
|
}
|
||||||
} else if (now > lessonEnd) {
|
} else if (isCompleted) {
|
||||||
absences++;
|
absences++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -232,18 +232,18 @@ export default function Frequencia() {
|
||||||
// Check window (uses new 24h before/after logic)
|
// Check window (uses new 24h before/after logic)
|
||||||
if (!isLessonWithinJustificationWindow(l, now)) return false;
|
if (!isLessonWithinJustificationWindow(l, now)) return false;
|
||||||
|
|
||||||
// Construir janela como o Manager
|
// Usar parseLessonDateTime para evitar bugs de fuso horário
|
||||||
const lessonStart = new Date(l.date + 'T' + (l.startTime || '00:00') + ':00');
|
const lessonStartMs = parseLessonDateTime(l.date, l.startTime || '00:00', 0);
|
||||||
const lessonEnd = new Date(l.date + 'T' + (l.endTime || '23:59') + ':00');
|
const lessonEndMs = parseLessonDateTime(l.date, l.endTime || '23:59', 23);
|
||||||
const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000);
|
const presenceStartWindowMs = lessonStartMs - (30 * 60 * 1000);
|
||||||
|
|
||||||
// Find if THIS SPECIFIC lesson has attendance/justification
|
// Find if THIS SPECIFIC lesson has attendance/justification
|
||||||
const att = attendance.find(a => {
|
const att = attendance.find(a => {
|
||||||
if (!a.date || typeof a.date !== 'string') return false;
|
if (!a.date || typeof a.date !== 'string') return false;
|
||||||
if ((a as any).lessonId === l.id) return true;
|
if ((a as any).lessonId === l.id) return true;
|
||||||
if (a.date === `${l.date}T${l.startTime || '00:00'}:00`) return true;
|
|
||||||
const recordTime = new Date(a.date);
|
const recordTime = new Date(a.date).getTime();
|
||||||
return recordTime >= presenceStartWindow && recordTime <= lessonEnd;
|
return recordTime >= presenceStartWindowMs && recordTime <= lessonEndMs;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (att) {
|
if (att) {
|
||||||
|
|
@ -547,6 +547,10 @@ export default function Frequencia() {
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--color-success)' }}>
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--color-success)' }}>
|
||||||
<CheckCircle2 size={16} /> Presente
|
<CheckCircle2 size={16} /> Presente
|
||||||
</span>
|
</span>
|
||||||
|
) : (!isCompleted && !isCancelled) ? (
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)', fontSize: '0.85rem', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Clock size={16} className="animate-pulse" /> Aguardando Presença
|
||||||
|
</span>
|
||||||
) : isJustificationAccepted ? (
|
) : isJustificationAccepted ? (
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#f59e0b', fontWeight: 600 }}>
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#f59e0b', fontWeight: 600 }}>
|
||||||
<AlertTriangle size={16} /> Falta Justificada
|
<AlertTriangle size={16} /> Falta Justificada
|
||||||
|
|
@ -555,13 +559,13 @@ export default function Frequencia() {
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#f59e0b', fontWeight: 500 }}>
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#f59e0b', fontWeight: 500 }}>
|
||||||
<Clock size={16} /> Justificativa Pendente
|
<Clock size={16} /> Justificativa Pendente
|
||||||
</span>
|
</span>
|
||||||
) : (isCompleted || parseLessonDateTime(lesson.date || '', '23:59:59') < now.getTime()) && !isCancelled ? (
|
) : isCompleted && !isCancelled ? (
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--color-danger)' }}>
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--color-danger)' }}>
|
||||||
<XCircle size={16} /> Falta
|
<XCircle size={16} /> Falta
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: 'var(--color-text-secondary)', fontSize: '0.85rem' }}>
|
<span style={{ color: 'var(--color-text-secondary)', fontSize: '0.85rem' }}>
|
||||||
Aguardando
|
—
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue