import { useEffect, useState } from 'react'; import { useAuth } from '../context/AuthContext'; import { CalendarCheck, CheckCircle2, XCircle, FileText, Send, X, Loader2, AlertTriangle, ChevronDown, Clock } from 'lucide-react'; import type { Attendance, Lesson } from '../types'; import { getLessonTimeStatus, getNormalizedDate, isLessonWithinJustificationWindow, parseLessonDateTime } from '../lib/lessonUtils'; import { useRealTimeDate } from '../hooks/useRealTimeDate'; export default function Frequencia() { const { token } = useAuth(); const [attendance, setAttendance] = useState([]); const [lessons, setLessons] = useState([]); const [activeTab, setActiveTab] = useState<'scheduled' | 'history'>('scheduled'); const [loading, setLoading] = useState(true); const [successMsg, setSuccessMsg] = useState(''); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); // Modal State const [showJustifyModal, setShowJustifyModal] = useState(false); const [selectedDate, setSelectedDate] = useState(''); const [justificationText, setJustificationText] = useState(''); const [justificationFile, setJustificationFile] = useState(null); const [submitLoading, setSubmitLoading] = useState(false); const [error, setError] = useState(''); // Update time every 10s to keep timeline ticking forward live // This hook MUST be unconditionally called at the top level const now = useRealTimeDate(10000); useEffect(() => { const fetchData = async () => { try { const headers = { Authorization: `Bearer ${token}` }; const [freqRes, aulasRes] = await Promise.all([ fetch('/api/portal/frequencia', { headers }), fetch('/api/portal/aulas', { headers }) ]); const freqData = await freqRes.json(); const aulasData = await aulasRes.json(); setAttendance(freqData.attendance || []); setLessons(aulasData.lessons || []); } catch (err) { console.error(err); } finally { setLoading(false); } }; if (token) fetchData(); }, [token]); const openJustifyModal = (preselectedTimestamp?: string) => { setShowJustifyModal(true); let initialDate = preselectedTimestamp || ''; if (!initialDate) { // Find the closest justifiable lesson const deduplicated = lessons.filter((lesson, index, self) => index === self.findIndex((t) => t.date === lesson.date && t.startTime === lesson.startTime) ); const justifiable = deduplicated.filter(l => { if (l.status === 'cancelled') return false; if (!isLessonWithinJustificationWindow(l, now)) return false; const lessonStartMs = parseLessonDateTime(l.date, l.startTime || '00:00', 0); const lessonEndMs = parseLessonDateTime(l.date, l.endTime || '23:59', 23); const presenceStartWindowMs = lessonStartMs - (30 * 60 * 1000); const att = attendance.find(a => { if (!a.date || typeof a.date !== 'string') return false; if ((a as any).lessonId === l.id) return true; const recordTime = new Date(a.date).getTime(); return recordTime >= presenceStartWindowMs && recordTime <= lessonEndMs; }); if (att) { if (att.type === 'presence' || (att.verified && att.type !== 'absence')) return false; if (att.justification) return false; } return true; }); if (justifiable.length > 0) { const closest = justifiable.sort((a, b) => { const diffA = Math.abs(now.getTime() - parseLessonDateTime(a.date, a.startTime)); const diffB = Math.abs(now.getTime() - parseLessonDateTime(b.date, b.startTime)); return diffA - diffB; })[0]; initialDate = `${closest.date}T${closest.startTime || '00:00'}:00`; } } setSelectedDate(initialDate); setJustificationText(''); setJustificationFile(null); setError(''); setSuccessMsg(''); }; const closeModal = () => { setShowJustifyModal(false); setSelectedDate(''); setJustificationText(''); setJustificationFile(null); setError(''); }; const selectedLessonHasJustification = () => { if (!selectedDate) return false; const att = attendance.find(a => { if (!a.date) return false; const cleanDate = a.date.substring(0, 19); const cleanSelected = selectedDate.substring(0, 19); return cleanDate === cleanSelected; }); return !!(att && att.justification); }; const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { setJustificationFile(file); } }; const handleJustify = async (e: React.FormEvent) => { e.preventDefault(); if (!selectedDate) { setError('Selecione a data da aula'); return; } if (!justificationText.trim()) { setError('A justificativa é obrigatória'); return; } setSubmitLoading(true); setError(''); const formData = new FormData(); formData.append('date', selectedDate); formData.append('motivo', justificationText.trim()); if (justificationFile) { formData.append('arquivo', justificationFile); } try { const res = await fetch('/api/portal/frequencia/justificar', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData, }); if (!res.ok) { const errData = await res.json().catch(() => ({})); throw new Error(errData.error || 'Erro ao enviar justificativa'); } const { record } = await res.json(); // Update local state setAttendance(prev => { const exists = prev.find(a => a.id === record.id); if (exists) return prev.map(a => a.id === record.id ? record : a); return [...prev, record]; }); closeModal(); setSuccessMsg(`Justificativa enviada com sucesso para o dia ${formatDate(selectedDate)}!`); setTimeout(() => setSuccessMsg(''), 5000); } catch (err: any) { setError(err.message || 'Erro ao comunicar com o servidor'); } finally { setSubmitLoading(false); } }; if (loading) { return (
); } // Deduplicar aulas exatamente como no Manager const deduplicatedLessons = lessons.filter((lesson, index, self) => index === self.findIndex((t) => ( t.date === lesson.date && t.startTime === lesson.startTime )) ); // Merge and Categorize — Clone EXATO do Manager (AttendanceQuery.tsx) const processedItems = deduplicatedLessons.map(lesson => { // Construir janela de tempo de forma resiliente const lessonStartMs = parseLessonDateTime(lesson.date, lesson.startTime || '00:00', 0); const lessonEndMs = parseLessonDateTime(lesson.date, lesson.endTime || '23:59', 23); // Buscar registros com a lógica priorizando presença const matchingRecords = attendance.filter(a => { if (!a.date || typeof a.date !== 'string') return false; // 1. Match por lessonId (se existir) if ((a as any).lessonId === lesson.id) return true; // 2. Match exato de string (formato do JSON/sync) if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true; // 3. Match por janela de tempo (Início ao Fim da aula - REGRA ESTREITA) const recordTimeMs = new Date(a.date).getTime(); return recordTimeMs >= lessonStartMs && recordTimeMs <= lessonEndMs; }); let record = matchingRecords.find(a => a.type === 'presence' || (a.verified === true && a.type !== 'absence')) || matchingRecords.find(a => a.type === 'absence' && a.justificationAccepted) || matchingRecords[0]; // Se não encontrou registro real, verificar se precisa de justificativa associada if (!record) { const matchingJustifications = attendance.filter(a => { if (!a.date || typeof a.date !== 'string' || !a.justification) return false; if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true; const recordTimeMs = new Date(a.date).getTime(); return recordTimeMs >= lessonStartMs && recordTimeMs <= lessonEndMs; }); record = matchingJustifications[0]; } const { isInProgress, isCompleted } = getLessonTimeStatus(lesson, now); return { lesson, attendances: record ? [record] : [], isInProgress, isCompleted }; }); // Stats calculation — Clone EXATO do Manager (AttendanceQuery.tsx linhas 548-559) let presences = 0; let absences = 0; let justified = 0; processedItems.forEach(item => { const { lesson, attendances: atts, isCompleted } = item; if (lesson.status === 'cancelled') return; const record = atts[0]; if (record) { if (record.type === 'absence') { if (record.justificationAccepted) justified++; else absences++; } else if (record.type === 'presence' || (record.verified === true && record.type !== 'absence') || (!record.type && !(record as any).isVirtual)) { presences++; } } else if (isCompleted) { absences++; } }); const totalCourseLessons = deduplicatedLessons.filter(l => l.status !== 'cancelled').length; const completedLessons = processedItems.filter(item => item.isCompleted && item.lesson.status !== 'cancelled').length; const pendingLessons = processedItems.filter(item => !item.isCompleted && item.lesson.status !== 'cancelled').length; const percentage = totalCourseLessons > 0 ? Math.round((presences / totalCourseLessons) * 100) : 0; const activeItems = processedItems.filter(item => !item.isCompleted && item.lesson.status !== 'cancelled').sort((a, b) => { const dateA = parseLessonDateTime(a.lesson.date, a.lesson.startTime, 0); const dateB = parseLessonDateTime(b.lesson.date, b.lesson.startTime, 0); return dateA - dateB; }); const historyItems = processedItems.filter(item => item.isCompleted || item.lesson.status === 'cancelled').sort((a, b) => { const dateA = parseLessonDateTime(a.lesson.date, a.lesson.startTime, 0); const dateB = parseLessonDateTime(b.lesson.date, b.lesson.startTime, 0); return dateB - dateA; // History descending }); const displayItems = activeTab === 'scheduled' ? activeItems : historyItems; // Collect lessons available for justification modal dropdown const justifiableLessons = deduplicatedLessons.filter(l => { if (l.status === 'cancelled') return false; // Check window (uses new 24h before/after logic) if (!isLessonWithinJustificationWindow(l, now)) return false; // Usar parseLessonDateTime para evitar bugs de fuso horário const lessonStartMs = parseLessonDateTime(l.date, l.startTime || '00:00', 0); const lessonEndMs = parseLessonDateTime(l.date, l.endTime || '23:59', 23); const presenceStartWindowMs = lessonStartMs - (30 * 60 * 1000); // Find if THIS SPECIFIC lesson has attendance/justification const att = attendance.find(a => { if (!a.date || typeof a.date !== 'string') return false; if ((a as any).lessonId === l.id) return true; const recordTime = new Date(a.date).getTime(); return recordTime >= presenceStartWindowMs && recordTime <= lessonEndMs; }); if (att) { if (att.type === 'presence' || (att.verified && att.type !== 'absence')) return false; } return true; }); const formatDate = (d: string) => { try { const ms = parseLessonDateTime(d, '12:00', 12); if (isNaN(ms)) return d; return new Date(ms).toLocaleDateString('pt-BR'); } catch { return d; } }; const formatDateFull = (d: string) => { try { const ms = parseLessonDateTime(d, '12:00', 12); if (isNaN(ms)) return d; return new Date(ms).toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric', }); } catch { return d; } }; const parseJustification = (j?: string): string | null => { if (!j) return null; try { const parsed = JSON.parse(j); return parsed.motivo || j; } catch { return j; } }; return (

Frequência

Acompanhe seu histórico de presença e justificativas

{/* Success message */} {successMsg && (
✅ {successMsg}
)} {/* Stats */}
= 75 ? 'var(--color-primary)' : 'var(--color-warning)'} ${percentage * 3.6}deg, var(--color-surface) 0deg)`, display: 'flex', alignItems: 'center', justifyContent: 'center', }}>
{percentage}%

FREQUÊNCIA TOTAL

{presences}

PRESENÇAS

{absences}

FALTAS

{justified}

JUSTIFICATIVAS

{totalCourseLessons}

TOTAL DE AULAS

{completedLessons}

CONCLUÍDAS

{pendingLessons}

A CONCLUIR

{/* List */} {displayItems.length === 0 ? (

Nenhuma aula encontrada no cronograma

) : (
{displayItems.map((item, idx) => { const { lesson, attendances: atts, isInProgress, isCompleted } = item; const isCancelled = lesson.status === 'cancelled'; const isRescheduled = lesson.status === 'rescheduled'; // PREREQUISITE: 'presence' type OR verified (but NOT absence) counts as real presence const isPresent = atts.some(a => a.type === 'presence' || (a.verified === true && a.type !== 'absence')); const hasJustification = atts.some(a => !!a.justification); const activeJustification = atts.find(a => !!a.justification); const justText = parseJustification(activeJustification?.justification); const isJustificationAccepted = activeJustification?.justificationAccepted === true; const isWithinWindow = isLessonWithinJustificationWindow(lesson, now); const canJustify = !isPresent && isWithinWindow && !justText && lesson.status !== 'cancelled'; return ( ); })}
Data Turma Horário Status de Aula Presença Hora Presença Justificativa Texto da Justificativa
{formatDateFull(lesson.date)} {(lesson as any).className || '—'} {typeof lesson.startTime === 'string' ? ( {lesson.startTime.substring(0, 5)}{typeof lesson.endTime === 'string' ? ` - ${lesson.endTime.substring(0, 5)}` : ''} ) : ( )}
{isInProgress && ( • AULA EM ANDAMENTO )} {isCancelled ? ( CANCELADA ) : isRescheduled ? ( REAGENDADA ) : (isCompleted || parseLessonDateTime(lesson.date || '', '23:59:59') < now.getTime()) ? ( CONCLUÍDA ) : (
AGENDADA {lesson.type === 'extra' && ( AULA EXTRA )} {lesson.type === 'reposicao' && ( REPOSIÇÃO )}
)}
{isPresent ? ( Presente ) : (!isCompleted && !isCancelled) ? ( Aguardando Presença ) : isJustificationAccepted ? ( Falta Justificada ) : hasJustification ? ( Justificativa Pendente ) : isCompleted && !isCancelled ? ( Falta ) : ( )}
{atts.length > 0 ? ( atts .filter(a => a.type === 'presence' || (a.verified === true && a.type !== 'absence')) .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) .map((a, aIdx) => { const d = new Date(a.date); if (isNaN(d.getTime())) return null; return ( {d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })} ); }) ) : ( )} {atts.length > 0 && atts.filter(a => a.type === 'presence' || a.verified).length === 0 && ( )}
{justText ? ( {isJustificationAccepted ? 'Justificativa Aceita' : 'Em Análise'} ) : canJustify ? ( ) : ( )} {justText ? (
{justText} {activeJustification?.submittedAt && ( Enviada em: {new Date(activeJustification.submittedAt).toLocaleDateString('pt-BR')} às {new Date(activeJustification.submittedAt).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })} )}
) : ( )}
)} {/* ========== MODAL DE JUSTIFICATIVA ========== */} {showJustifyModal && (
{/* Header */}

Justificar Falta

Selecione a data e descreva o motivo

{error && (
{error}
)}