edumanagerpro2/portal/src/pages/Dashboard.tsx

485 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { CreditCard, CalendarCheck, BookOpen, Clock, TrendingUp, AlertTriangle, CalendarClock } from 'lucide-react';
import type { Payment, Attendance, Class, Course, Lesson } from '../types';
import { getLessonTimeStatus, getNormalizedDate, parseLessonDateTime } from '../lib/lessonUtils';
import { useRealTimeDate } from '../hooks/useRealTimeDate';
interface DashboardData {
payments: Payment[];
attendance: Attendance[];
lessons: Lesson[];
studentClass: Class | null;
course: Course | null;
}
export default function Dashboard() {
const { student, token } = useAuth();
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
// Real-time update every 10s
const now = useRealTimeDate(10000);
useEffect(() => {
const fetchAll = async () => {
try {
const headers = { Authorization: `Bearer ${token}` };
const [finRes, freqRes, meRes, aulasRes] = await Promise.all([
fetch('/api/portal/financeiro', { headers }),
fetch('/api/portal/frequencia', { headers }),
fetch('/api/portal/me', { headers }),
fetch('/api/portal/aulas', { headers }),
]);
const finData = await finRes.json();
const freqData = await freqRes.json();
const meData = await meRes.json();
const aulasData = await aulasRes.json();
setData({
payments: finData.payments || [],
attendance: freqData.attendance || [],
lessons: aulasData.lessons || [],
studentClass: meData.class || null,
course: meData.course || null,
});
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
if (token) fetchAll();
}, [token]);
if (loading) {
return (
<div className="page-container stagger-children">
<div className="skeleton" style={{ width: 300, height: 32, marginBottom: 24 }} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
{[1, 2, 3, 4].map(i => (
<div key={i} className="skeleton" style={{ height: 140, borderRadius: 16 }} />
))}
</div>
</div>
);
}
const pendingPayments = data?.payments?.filter(p => p.status === 'pending' || p.status === 'overdue') || [];
const overduePayments = data?.payments?.filter(p => p.status === 'overdue') || [];
const totalPending = pendingPayments.reduce((s, p) => s + (p.amount - (p.discount || 0)), 0);
// Synchronized Frequency Calculation (Matches Frequencia.tsx & Manager)
let presencesCount = 0;
let validLessonsCount = 0;
if (data?.lessons && data?.attendance) {
const deduplicatedLessons = data.lessons.filter((lesson, index, self) =>
index === self.findIndex((t) => (
t.date === lesson.date && t.startTime === lesson.startTime
))
);
deduplicatedLessons.forEach(lesson => {
if (lesson.status === 'cancelled') return;
validLessonsCount++;
// Construir janela de tempo EXATAMENTE como o Manager faz
const lessonStart = new Date(lesson.date + 'T' + (lesson.startTime || '00:00') + ':00');
const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00');
const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000);
const att = data.attendance.find(a => {
if (!a.date || typeof a.date !== 'string') return false;
if ((a as any).lessonId === lesson.id) return true;
if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true;
const recordTime = new Date(a.date);
return recordTime >= presenceStartWindow && recordTime <= lessonEnd;
});
const isPresent = att && (att.type === 'presence' || (!att.type && !(att as any).isVirtual) || att.verified === true);
if (isPresent) presencesCount++;
});
}
const totalAttendance = data?.attendance?.length || 0;
const frequencyPercent = validLessonsCount > 0 ? Math.round((presencesCount / validLessonsCount) * 100) : 0;
const nextDue = pendingPayments
.sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime())[0];
const formatCurrency = (val: number) =>
val.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
const formatDate = (d: string) => {
if (!d) return '—';
const ms = parseLessonDateTime(d, '12:00', 12);
if (isNaN(ms)) return d;
return new Date(ms).toLocaleDateString('pt-BR');
};
const greeting = () => {
const hour = new Date().getHours();
if (hour < 12) return 'Bom dia';
if (hour < 18) return 'Boa tarde';
return 'Boa noite';
};
const getNext7DaysReplacements = () => {
if (!data?.lessons) return [];
const today = new Date();
today.setHours(0, 0, 0, 0);
const in7Days = new Date(today);
in7Days.setDate(in7Days.getDate() + 7);
return data.lessons.filter(l => {
if (l.status === 'cancelled') return false;
const parsedMs = parseLessonDateTime(l.date, '00:00', 0);
if (isNaN(parsedMs)) return false;
const classDate = new Date(parsedMs);
classDate.setHours(0, 0, 0, 0);
return l.type === 'reposicao' && classDate >= today && classDate <= in7Days;
});
};
const getNextOrCurrentClass = (): { lesson: Lesson; isInProgress: boolean } | null => {
if (!data?.lessons || data.lessons.length === 0) return null;
const activeLessons = data.lessons.filter(l => l.status !== 'cancelled');
// Normalize "now" date
const nowNorm = getNormalizedDate(now.toISOString());
// 1. First, priority: anything strictly "In Progress" RIGHT NOW
const currentlyPlaying = activeLessons.find(l => {
const { isInProgress } = getLessonTimeStatus(l, now);
return isInProgress;
});
if (currentlyPlaying) return { lesson: currentlyPlaying, isInProgress: true };
// 2. Secondary: If it's today and not completed yet
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const lessonsRemainingToday = activeLessons
.filter(l => {
const lessonMs = parseLessonDateTime(l.date, '12:00', 12);
const lessonDate = new Date(lessonMs);
lessonDate.setHours(0, 0, 0, 0);
const { isCompleted } = getLessonTimeStatus(l, now);
return lessonDate.getTime() === today.getTime() && !isCompleted;
})
.sort((a, b) => {
const timeA = parseLessonDateTime(a.date, a.startTime || (a as any).start_time, 0);
const timeB = parseLessonDateTime(b.date, b.startTime || (b as any).start_time, 0);
return timeA - timeB;
});
if (lessonsRemainingToday[0]) {
const { isInProgress } = getLessonTimeStatus(lessonsRemainingToday[0], now);
return { lesson: lessonsRemainingToday[0], isInProgress };
}
// 3. Last resort: Next future lesson
const nextFuture = activeLessons
.filter(l => {
const { isCompleted } = getLessonTimeStatus(l, now);
return !isCompleted;
})
.sort((a, b) => {
const dateA = parseLessonDateTime(a.date, a.startTime || (a as any).start_time, 0);
const dateB = parseLessonDateTime(b.date, b.startTime || (b as any).start_time, 0);
return dateA - dateB;
});
if (nextFuture[0]) {
const { isInProgress } = getLessonTimeStatus(nextFuture[0], now);
return { lesson: nextFuture[0], isInProgress };
}
return null;
};
const formatTime = (t?: string) => (t && typeof t === 'string') ? t.substring(0, 5) : '';
const replacements = getNext7DaysReplacements();
const nextClassInfo = getNextOrCurrentClass();
const nextClass = nextClassInfo?.lesson || null;
const isCurrentlyInProgress = nextClassInfo?.isInProgress || false;
return (
<div className="page-container">
<style>{`
@keyframes blink-status {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
@keyframes pulse-glow {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.85; }
}
`}</style>
{/* Greeting */}
<div className="animate-fade-in" style={{ marginBottom: '2rem' }}>
<h1 className="page-title">
{greeting()}, <span className="gradient-text">{student?.name.split(' ')[0]}</span>! 👋
</h1>
<p className="page-subtitle">
Aqui está um resumo da sua vida acadêmica.
</p>
{replacements.map(rep => (
<div key={rep.id} className="glass-card animate-fade-in" style={{
marginTop: '1.25rem', padding: '1rem',
background: 'var(--bg-success-alpha)', border: '1px solid var(--border-success-alpha)',
display: 'flex', alignItems: 'center', gap: '0.75rem',
color: 'var(--color-success)'
}}>
<CalendarClock size={20} />
<p style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--color-text)' }}>
🗓 <strong>Aviso:</strong> Você tem uma reposição agendada para o dia <strong>{formatDate(rep.date || '')}</strong>
{rep.startTime ? ` às ${formatTime(rep.startTime)}` : ''}.
</p>
</div>
))}
</div>
{/* Cards Grid */}
<div className="stagger-children" style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '1rem',
marginBottom: '2rem',
}}>
{/* Turma Card */}
<div className="glass-card" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{
width: 44, height: 44, borderRadius: 12,
background: 'var(--bg-primary-alpha)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<BookOpen size={22} color="var(--color-primary-light)" />
</div>
<div>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500 }}>
MINHA TURMA
</p>
</div>
</div>
<h3 style={{ fontSize: '1.25rem', fontWeight: 700 }}>
{data?.studentClass?.name || 'Não vinculado'}
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginTop: '0.375rem' }}>
{data?.course?.name || '—'}
</p>
{data?.studentClass?.schedule && (
<p style={{ fontSize: '0.75rem', color: 'var(--color-accent)', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: 4 }}>
<Clock size={14} /> {data.studentClass.schedule}
</p>
)}
{data?.studentClass?.teacher && (
<div style={{ marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid var(--glass-border)' }}>
<p style={{ fontSize: '0.7rem', color: 'var(--color-text-secondary)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 2 }}>
Professor Responsável
</p>
<p style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text-primary)' }}>
{data.studentClass.teacher}
</p>
</div>
)}
</div>
{/* Próxima Aula Card */}
<div className="glass-card" style={{
padding: '1.5rem',
border: isCurrentlyInProgress || nextClass?.type === 'extra' ? `2px solid ${nextClass?.type === 'extra' ? '#a855f7' : 'var(--color-info)'}` : undefined,
background: isCurrentlyInProgress
? (nextClass?.type === 'extra'
? 'linear-gradient(135deg, rgba(147, 51, 234, 0.25) 0%, rgba(168, 85, 247, 0.15) 100%)'
: nextClass?.type === 'reposicao'
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.25) 0%, rgba(22, 163, 74, 0.15) 100%)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.25) 0%, rgba(99, 102, 241, 0.15) 100%)')
: undefined,
boxShadow: isCurrentlyInProgress
? (nextClass?.type === 'extra' ? '0 0 30px rgba(147, 51, 234, 0.2)' : nextClass?.type === 'reposicao' ? '0 0 30px rgba(34, 197, 94, 0.2)' : '0 0 30px rgba(59, 130, 246, 0.2)')
: undefined,
animation: isCurrentlyInProgress ? 'pulse-glow 3s infinite' : undefined,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{
width: 44, height: 44, borderRadius: 12,
background: isCurrentlyInProgress
? (nextClass?.type === 'extra' ? 'rgba(147, 51, 234, 0.15)' : nextClass?.type === 'reposicao' ? 'rgba(34, 197, 94, 0.15)' : 'rgba(59, 130, 246, 0.15)')
: 'var(--bg-primary-alpha)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: isCurrentlyInProgress ? 'pulse-glow 2s infinite' : undefined,
}}>
{isCurrentlyInProgress ? (
<Clock size={22} color={nextClass?.type === 'extra' ? '#a855f7' : nextClass?.type === 'reposicao' ? '#22c55e' : 'white'} />
) : (
<CalendarClock size={22} color={nextClass?.type === 'extra' ? '#a855f7' : nextClass?.type === 'reposicao' ? '#22c55e' : 'var(--color-primary-light)'} />
)}
</div>
<div>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 6 }}>
{isCurrentlyInProgress ? (
<>
<Clock size={14} color={nextClass?.type === 'extra' ? '#a855f7' : nextClass?.type === 'reposicao' ? '#22c55e' : 'var(--color-info)'} className="animate-pulse" />
<span style={{ color: nextClass?.type === 'extra' ? '#a855f7' : nextClass?.type === 'reposicao' ? '#22c55e' : 'var(--color-info)', fontWeight: 700 }}>AULA EM ANDAMENTO</span>
</>
) : (
'PRÓXIMA AULA'
)}
</p>
</div>
</div>
{nextClass ? (
<>
<h3 style={{ fontSize: '1.125rem', fontWeight: 700, lineHeight: 1.2, color: isCurrentlyInProgress ? (nextClass.type === 'extra' ? '#a855f7' : nextClass.type === 'reposicao' ? '#22c55e' : 'var(--color-info)') : undefined }}>
{nextClass.status === 'rescheduled' ? 'Reagendada' : (nextClass.type === 'reposicao' ? 'Reposição' : (nextClass.type === 'extra' ? 'Aula Extra' : 'Aula Regular'))}
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginTop: '0.375rem' }}>
{formatDate(nextClass.date || '')}
</p>
{(nextClass.startTime || nextClass.endTime) && (
<p style={{ fontSize: '0.75rem', color: isCurrentlyInProgress ? 'var(--color-info)' : 'var(--color-accent)', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: 4 }}>
<Clock size={14} /> {formatTime(nextClass.startTime)} {nextClass.endTime && `às ${formatTime(nextClass.endTime)}`}
</p>
)}
{isCurrentlyInProgress && (
<span className="animate-pulse" style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
marginTop: '0.75rem', padding: '4px 10px', borderRadius: 6,
background: nextClass.type === 'extra' ? '#a855f7' : nextClass.type === 'reposicao' ? '#22c55e' : 'var(--color-info)', color: 'white',
fontSize: '0.7rem', fontWeight: 600,
}}>
<Clock size={12} /> AULA EM ANDAMENTO
</span>
)}
</>
) : (
<>
<h3 style={{ fontSize: '1.125rem', fontWeight: 700, color: 'var(--color-text-secondary)' }}>
Nenhuma aula
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginTop: '0.375rem' }}>
Você não possui próximas aulas
</p>
</>
)}
</div>
{/* Financeiro Card */}
<div className="glass-card" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{
width: 44, height: 44, borderRadius: 12,
background: overduePayments.length > 0 ? 'var(--bg-danger-alpha)' : 'var(--bg-success-alpha)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<CreditCard size={22} color={overduePayments.length > 0 ? 'var(--color-danger)' : 'var(--color-success)'} />
</div>
<div>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500 }}>
FINANCEIRO
</p>
</div>
</div>
<h3 style={{ fontSize: '1.25rem', fontWeight: 700 }}>
{formatCurrency(totalPending)}
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginTop: '0.375rem' }}>
{pendingPayments.length} parcela{pendingPayments.length !== 1 ? 's' : ''} pendente{pendingPayments.length !== 1 ? 's' : ''}
</p>
{overduePayments.length > 0 && (
<p style={{
fontSize: '0.75rem', color: 'var(--color-danger)',
marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: 4,
}}>
<AlertTriangle size={14} /> {overduePayments.length} atrasada{overduePayments.length !== 1 ? 's' : ''}
</p>
)}
</div>
{/* Frequência Card */}
<div className="glass-card" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{
width: 44, height: 44, borderRadius: 12,
background: frequencyPercent >= 75 ? 'var(--bg-accent-alpha)' : 'var(--bg-warning-alpha)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<CalendarCheck size={22} color={frequencyPercent >= 75 ? 'var(--color-accent-light)' : 'var(--color-warning)'} />
</div>
<div>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500 }}>
FREQUÊNCIA
</p>
</div>
</div>
<h3 style={{ fontSize: '1.25rem', fontWeight: 700 }}>
{frequencyPercent}%
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginTop: '0.375rem' }}>
{presencesCount} presença{presencesCount !== 1 ? 's' : ''} de {validLessonsCount} aula{validLessonsCount !== 1 ? 's' : ''} do curso
</p>
<div style={{
marginTop: '0.75rem', height: 6, borderRadius: 3,
background: 'var(--color-surface)',
}}>
<div style={{
height: '100%', borderRadius: 3,
width: `${frequencyPercent}%`,
background: frequencyPercent >= 75 ? 'var(--gradient-primary)' : 'var(--color-warning)',
transition: 'width 1s ease',
}} />
</div>
</div>
{/* Próximo Vencimento Card */}
<div className="glass-card" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{
width: 44, height: 44, borderRadius: 12,
background: 'var(--bg-warning-alpha)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<TrendingUp size={22} color="var(--color-warning)" />
</div>
<div>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 500 }}>
PRÓXIMO VENCIMENTO
</p>
</div>
</div>
{nextDue ? (
<>
<h3 style={{ fontSize: '1.25rem', fontWeight: 700 }}>
{formatCurrency(nextDue.amount - (nextDue.discount || 0))}
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginTop: '0.375rem' }}>
Vence em {formatDate(nextDue.dueDate)}
</p>
{nextDue.description && (
<p style={{ fontSize: '0.75rem', color: 'var(--color-accent)', marginTop: '0.5rem' }}>
{nextDue.description}
</p>
)}
</>
) : (
<>
<h3 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-success)' }}>
Em dia!
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginTop: '0.375rem' }}>
Nenhuma parcela pendente
</p>
</>
)}
</div>
</div>
</div>
);
}