744 lines
35 KiB
TypeScript
744 lines
35 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { SchoolData, Student, Payment, Class, View } from '../types';
|
|
import {
|
|
BarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
Cell,
|
|
PieChart,
|
|
Pie,
|
|
AreaChart,
|
|
Area,
|
|
Legend,
|
|
LineChart,
|
|
Line
|
|
} from 'recharts';
|
|
import {
|
|
Users,
|
|
BookOpen,
|
|
Wallet,
|
|
Clock,
|
|
FileDown,
|
|
RefreshCw,
|
|
TrendingUp,
|
|
UserPlus,
|
|
CheckCircle2,
|
|
AlertCircle,
|
|
Calendar,
|
|
ChevronRight,
|
|
Layout,
|
|
ClipboardPen,
|
|
X
|
|
} from 'lucide-react';
|
|
import { pdfService } from '../services/pdfService';
|
|
|
|
interface DashboardProps {
|
|
data: SchoolData;
|
|
setView?: (view: View) => void;
|
|
}
|
|
|
|
const Dashboard: React.FC<DashboardProps> = ({ data, setView }) => {
|
|
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
|
|
const [dashboardView, setDashboardView] = useState<'standard' | 'detailed'>('standard');
|
|
const [dbClasses, setDbClasses] = useState<any[]>(data.classes || []);
|
|
const [prematriculas, setPrematriculas] = useState<any[]>([]);
|
|
const [showLogsModal, setShowLogsModal] = useState(false);
|
|
|
|
React.useEffect(() => {
|
|
fetch('/api/turmas')
|
|
.then(res => res.json())
|
|
.then(json => {
|
|
if (json.turmas) {
|
|
setDbClasses(json.turmas.map((t: any) => ({
|
|
id: t.id,
|
|
name: t.nome,
|
|
courseId: t.curso_id,
|
|
maxStudents: Number(t.max_alunos || 0)
|
|
})));
|
|
}
|
|
})
|
|
.catch(console.error);
|
|
|
|
fetch('/api/prematricula/inscricoes')
|
|
.then(res => res.json())
|
|
.then(json => {
|
|
if (json.inscricoes) {
|
|
setPrematriculas(json.inscricoes);
|
|
}
|
|
})
|
|
.catch(console.error);
|
|
}, []);
|
|
|
|
// Basic Stats
|
|
const activeStudents = useMemo(() => data.students.filter(s => s.status === 'active').length, [data.students]);
|
|
const totalClasses = useMemo(() => dbClasses.length, [dbClasses]);
|
|
const pendingPayments = useMemo(() => data.payments.filter(p => p.status === 'pending').length, [data.payments]);
|
|
const revenue = useMemo(() => data.payments
|
|
.filter(p => p.status === 'paid')
|
|
.reduce((sum, p) => sum + (Number((p as any).valor_pago) || (Number(p.amount) - (Number(p.discount) || 0))), 0), [data.payments]);
|
|
|
|
// Advanced Stats
|
|
const newStudentsThisMonth = useMemo(() => {
|
|
const now = new Date();
|
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
return data.students.filter(s => new Date(s.registrationDate) >= startOfMonth).length;
|
|
}, [data.students]);
|
|
|
|
const attendanceRate = useMemo(() => {
|
|
if (!data.attendance || data.attendance.length === 0) return 0;
|
|
const presents = data.attendance.filter(a => a.type === 'presence').length;
|
|
return Math.round((presents / data.attendance.length) * 100);
|
|
}, [data.attendance]);
|
|
|
|
const averagePaymentValue = useMemo(() => {
|
|
if (data.payments.length === 0) return 0;
|
|
const total = data.payments.reduce((sum, p) => sum + p.amount, 0);
|
|
return Math.round(total / data.payments.length);
|
|
}, [data.payments]);
|
|
|
|
const aulasARepor = useMemo(() => {
|
|
if (!data.lessons) return 0;
|
|
const cancelled = data.lessons.filter(l => l.status === 'cancelled');
|
|
return cancelled.filter(c => !data.lessons!.some(l => l.originalLessonId === c.id)).length;
|
|
}, [data.lessons]);
|
|
|
|
// Chart Data: Class Occupancy
|
|
const classOccupancy = useMemo(() => dbClasses.map(c => ({
|
|
name: c.name,
|
|
students: data.students.filter(s => s.classId === c.id).length,
|
|
capacity: c.maxStudents || 20 // Usando a capacidade real se disponível
|
|
})).sort((a, b) => b.students - a.students), [dbClasses, data.students]);
|
|
|
|
// Chart Data: Payment Status
|
|
const paymentStatus = useMemo(() => [
|
|
{ name: 'Pago', value: data.payments.filter(p => p.status === 'paid').length, color: '#10b981' },
|
|
{ name: 'Pendente', value: data.payments.filter(p => p.status === 'pending').length, color: '#f59e0b' },
|
|
{ name: 'Atrasado', value: data.payments.filter(p => p.status === 'overdue').length, color: '#ef4444' },
|
|
], [data.payments]);
|
|
|
|
// Chart Data: Revenue Over Time (Last 6 months)
|
|
const revenueHistory = useMemo(() => {
|
|
const months = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
|
|
const now = new Date();
|
|
const history = [];
|
|
|
|
for (let i = 5; i >= 0; i--) {
|
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
|
const monthName = months[d.getMonth()];
|
|
const monthPayments = data.payments.filter(p => {
|
|
const pDate = new Date(p.paidDate || p.dueDate);
|
|
return pDate.getMonth() === d.getMonth() && pDate.getFullYear() === d.getFullYear() && p.status === 'paid';
|
|
});
|
|
const monthRevenue = monthPayments.reduce((sum, p) => sum + (Number((p as any).valor_pago) || (Number(p.amount) - (Number(p.discount) || 0))), 0);
|
|
history.push({ name: monthName, revenue: monthRevenue });
|
|
}
|
|
return history;
|
|
}, [data.payments]);
|
|
|
|
// Recent Activity
|
|
const recentActivity = useMemo(() => {
|
|
const activities = [
|
|
...data.students.slice(-3).map(s => ({
|
|
type: 'student',
|
|
title: 'Novo Aluno',
|
|
desc: s.name,
|
|
date: s.registrationDate,
|
|
icon: UserPlus,
|
|
color: 'bg-blue-100 text-blue-600'
|
|
})),
|
|
...data.payments.filter(p => p.status === 'paid').slice(-3).map(p => ({
|
|
type: 'payment',
|
|
title: 'Pagamento Recebido',
|
|
desc: `R$ ${(Number((p as any).valor_pago) || (Number(p.amount) - (Number(p.discount) || 0))).toLocaleString()}`,
|
|
date: p.paidDate || p.dueDate,
|
|
icon: CheckCircle2,
|
|
color: 'bg-emerald-100 text-emerald-600'
|
|
}))
|
|
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5);
|
|
|
|
return activities;
|
|
}, [data.students, data.payments]);
|
|
|
|
// All activities for modal log auditing
|
|
const allActivities = useMemo(() => {
|
|
const activities = [
|
|
...data.students.map(s => ({
|
|
type: 'student',
|
|
title: 'Novo Aluno',
|
|
desc: s.name,
|
|
date: s.registrationDate,
|
|
icon: UserPlus,
|
|
color: 'bg-blue-100 text-blue-600'
|
|
})),
|
|
...data.payments.filter(p => p.status === 'paid').map(p => ({
|
|
type: 'payment',
|
|
title: 'Pagamento Recebido',
|
|
desc: `R$ ${(Number((p as any).valor_pago) || (Number(p.amount) - (Number(p.discount) || 0))).toLocaleString()}`,
|
|
date: p.paidDate || p.dueDate,
|
|
icon: CheckCircle2,
|
|
color: 'bg-emerald-100 text-emerald-600'
|
|
}))
|
|
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
|
|
return activities;
|
|
}, [data.students, data.payments]);
|
|
|
|
const handleGenerateReport = async () => {
|
|
setIsGeneratingPDF(true);
|
|
try {
|
|
await pdfService.generateFullSchoolReportPDF(data);
|
|
} catch (error) {
|
|
console.error('Error generating PDF:', error);
|
|
} finally {
|
|
setIsGeneratingPDF(false);
|
|
}
|
|
};
|
|
|
|
const stats = [
|
|
{ label: 'Alunos Ativos', value: activeStudents, icon: Users, color: 'text-blue-600', bg: 'bg-blue-100', trend: '+12%', targetView: View.Students },
|
|
{ label: 'Turmas Ativas', value: totalClasses, icon: BookOpen, color: 'text-indigo-600', bg: 'bg-indigo-100', trend: '+2', targetView: View.Classes },
|
|
{
|
|
label: 'Pré-Matrículas',
|
|
value: prematriculas.length,
|
|
icon: ClipboardPen,
|
|
color: 'text-amber-600',
|
|
bg: 'bg-amber-100',
|
|
trend: `${prematriculas.filter(p => p.status === 'pendente').length} pendentes`,
|
|
targetView: View.PreMatricula
|
|
},
|
|
{ label: 'Receita Total', value: `R$ ${revenue.toLocaleString()}`, icon: Wallet, color: 'text-emerald-600', bg: 'bg-emerald-100', trend: '+8.4%', targetView: View.Finance },
|
|
{ label: 'Taxa de Presença', value: `${attendanceRate}%`, icon: TrendingUp, color: 'text-purple-600', bg: 'bg-purple-100', trend: '+2.1%', targetView: View.AttendanceQuery },
|
|
];
|
|
|
|
const secondaryStats = [
|
|
{ label: 'Aulas a Repor', value: aulasARepor, icon: Calendar, color: 'text-red-600', targetView: View.Classes },
|
|
{ label: 'Novos Alunos (Mês)', value: newStudentsThisMonth, icon: UserPlus, color: 'text-sky-600', targetView: View.Students },
|
|
{ label: 'Pagamentos Pendentes', value: pendingPayments, icon: Clock, color: 'text-amber-600', targetView: View.Finance },
|
|
{ label: 'Ticket Médio', value: `R$ ${averagePaymentValue}`, icon: Wallet, color: 'text-slate-600', targetView: View.Finance },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
|
<header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
|
<div>
|
|
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Painel Executivo</h2>
|
|
<p className="text-slate-500 font-medium">Visão geral do desempenho da instituição.</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex bg-slate-100 p-1 rounded-lg border border-slate-200">
|
|
<button
|
|
onClick={() => setDashboardView('standard')}
|
|
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all ${dashboardView === 'standard' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
|
|
>
|
|
Padrão
|
|
</button>
|
|
<button
|
|
onClick={() => setDashboardView('detailed')}
|
|
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all ${dashboardView === 'detailed' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
|
|
>
|
|
Detalhado
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={handleGenerateReport}
|
|
disabled={isGeneratingPDF}
|
|
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg hover:bg-slate-800 transition-all shadow-md active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-sm"
|
|
>
|
|
{isGeneratingPDF ? (
|
|
<RefreshCw size={18} className="animate-spin" />
|
|
) : (
|
|
<FileDown size={18} />
|
|
)}
|
|
{isGeneratingPDF ? 'Gerando...' : 'Exportar PDF'}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main Stats Grid */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-6">
|
|
{stats.map((stat, i) => (
|
|
<div
|
|
key={i}
|
|
onClick={() => setView && setView(stat.targetView)}
|
|
className="group bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:-translate-y-1 active:scale-95 cursor-pointer transition-all duration-300 ease-out"
|
|
>
|
|
<div className="flex justify-between items-start mb-4">
|
|
<div className={`${stat.bg} ${stat.color} p-3 rounded-xl group-hover:scale-110 transition-transform`}>
|
|
<stat.icon size={24} />
|
|
</div>
|
|
<span className="text-xs font-bold text-emerald-600 bg-emerald-50 px-2 py-1 rounded-full">
|
|
{stat.trend}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-slate-500 font-bold uppercase tracking-wider mb-1">{stat.label}</p>
|
|
<h3 className="text-3xl font-black text-slate-900">{stat.value}</h3>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Secondary Stats Row */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
{secondaryStats.map((stat, i) => (
|
|
<div
|
|
key={i}
|
|
onClick={() => setView && setView(stat.targetView)}
|
|
className="bg-slate-50 p-4 rounded-xl border border-slate-200 flex items-center justify-between hover:bg-slate-100 hover:shadow-md hover:-translate-y-0.5 active:scale-95 cursor-pointer transition-all duration-300 ease-out"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className={`${stat.color}`}>
|
|
<stat.icon size={20} />
|
|
</div>
|
|
<p className="text-sm font-bold text-slate-600">{stat.label}</p>
|
|
</div>
|
|
<p className="text-lg font-black text-slate-900">{stat.value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
{/* Revenue Area Chart */}
|
|
<div
|
|
onClick={() => setView && setView(View.Finance)}
|
|
className="lg:col-span-2 bg-white p-6 rounded-2xl border border-slate-200 shadow-sm cursor-pointer hover:shadow-xl hover:-translate-y-1 transition-all duration-300 ease-out active:scale-[0.99]"
|
|
>
|
|
<div className="flex justify-between items-center mb-8">
|
|
<div>
|
|
<h3 className="text-lg font-black text-slate-900">Fluxo de Receita</h3>
|
|
<p className="text-xs text-slate-500 font-bold uppercase tracking-tighter">Últimos 6 meses</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-emerald-600 font-black text-sm">
|
|
<TrendingUp size={16} />
|
|
<span>+15.2% vs ano anterior</span>
|
|
</div>
|
|
</div>
|
|
<div className="h-80" onClick={(e) => e.stopPropagation()}>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={revenueHistory}>
|
|
<defs>
|
|
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.1}/>
|
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
|
<XAxis
|
|
dataKey="name"
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tick={{fill: '#64748b', fontSize: 12, fontWeight: 600}}
|
|
dy={10}
|
|
/>
|
|
<YAxis
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tick={{fill: '#64748b', fontSize: 12, fontWeight: 600}}
|
|
tickFormatter={(value) => `R$ ${value}`}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'}}
|
|
formatter={(value: number) => [`R$ ${value.toLocaleString()}`, 'Receita']}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="revenue"
|
|
stroke="#10b981"
|
|
strokeWidth={3}
|
|
fillOpacity={1}
|
|
fill="url(#colorRevenue)"
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Payment Status Pie Chart */}
|
|
<div
|
|
onClick={() => setView && setView(View.Finance)}
|
|
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm cursor-pointer hover:shadow-xl hover:-translate-y-1 transition-all duration-300 ease-out active:scale-[0.99]"
|
|
>
|
|
<h3 className="text-lg font-black text-slate-900 mb-2">Status Financeiro</h3>
|
|
<p className="text-xs text-slate-500 font-bold uppercase tracking-tighter mb-8">Distribuição de pagamentos</p>
|
|
<div className="h-64 relative" onClick={(e) => e.stopPropagation()}>
|
|
{data.payments.length > 0 ? (
|
|
<>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={paymentStatus}
|
|
cx="50%"
|
|
cy="50%"
|
|
innerRadius={70}
|
|
outerRadius={90}
|
|
paddingAngle={8}
|
|
dataKey="value"
|
|
stroke="none"
|
|
>
|
|
{paymentStatus.map((entry, index) => (
|
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip
|
|
contentStyle={{borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'}}
|
|
/>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
|
<span className="text-3xl font-black text-slate-900">{data.payments.length}</span>
|
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Total</span>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-slate-400 font-bold italic">Sem dados</div>
|
|
)}
|
|
</div>
|
|
<div className="mt-6 space-y-3">
|
|
{paymentStatus.map((item, i) => (
|
|
<div key={i} className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded-full" style={{backgroundColor: item.color}}></div>
|
|
<span className="text-sm font-bold text-slate-600">{item.name}</span>
|
|
</div>
|
|
<span className="text-sm font-black text-slate-900">{item.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
{/* Class Occupancy Bar Chart */}
|
|
<div
|
|
onClick={() => setView && setView(View.Classes)}
|
|
className="lg:col-span-2 bg-white p-6 rounded-2xl border border-slate-200 shadow-sm cursor-pointer hover:shadow-xl hover:-translate-y-1 transition-all duration-300 ease-out active:scale-[0.99]"
|
|
>
|
|
<div className="flex justify-between items-center mb-8">
|
|
<div>
|
|
<h3 className="text-lg font-black text-slate-900">Ocupação das Turmas</h3>
|
|
<p className="text-xs text-slate-500 font-bold uppercase tracking-tighter">Alunos por turma</p>
|
|
</div>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setView && setView(View.Classes); }}
|
|
className="text-indigo-600 text-xs font-black uppercase tracking-widest hover:underline"
|
|
>
|
|
Ver todas
|
|
</button>
|
|
</div>
|
|
<div className="h-80" onClick={(e) => e.stopPropagation()}>
|
|
{classOccupancy.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={classOccupancy} layout="vertical" margin={{left: 40}}>
|
|
<CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false} stroke="#f1f5f9" />
|
|
<XAxis type="number" hide />
|
|
<YAxis
|
|
dataKey="name"
|
|
type="category"
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tick={{fill: '#1e293b', fontSize: 11, fontWeight: 800}}
|
|
width={80}
|
|
/>
|
|
<Tooltip
|
|
cursor={{fill: '#f8fafc'}}
|
|
contentStyle={{borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'}}
|
|
/>
|
|
<Bar dataKey="students" fill="#6366f1" radius={[0, 10, 10, 0]} barSize={20}>
|
|
{classOccupancy.map((entry, index) => (
|
|
<Cell key={`cell-${index}`} fill={index === 0 ? '#4f46e5' : '#818cf8'} />
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-slate-400 font-bold italic">Sem turmas</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent Activity Feed */}
|
|
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 ease-out">
|
|
<h3 className="text-lg font-black text-slate-900 mb-6">Atividade Recente</h3>
|
|
<div className="space-y-6">
|
|
{recentActivity.length > 0 ? (
|
|
recentActivity.map((activity, i) => (
|
|
<div
|
|
key={i}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (setView) {
|
|
setView(activity.type === 'student' ? View.Students : View.Finance);
|
|
}
|
|
}}
|
|
className="flex gap-4 relative cursor-pointer hover:bg-slate-50 p-2 -m-2 rounded-xl transition-all duration-200 active:scale-98"
|
|
>
|
|
{i !== recentActivity.length - 1 && (
|
|
<div className="absolute left-5 top-10 bottom-0 w-0.5 bg-slate-100 -mb-6"></div>
|
|
)}
|
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 z-10 ${activity.color}`}>
|
|
<activity.icon size={18} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex justify-between items-start">
|
|
<p className="text-sm font-black text-slate-900 truncate">{activity.title}</p>
|
|
<span className="text-[10px] font-bold text-slate-400 whitespace-nowrap ml-2">
|
|
{new Date(activity.date).toLocaleDateString('pt-BR', {day: '2-digit', month: 'short'})}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-slate-500 font-medium truncate">{activity.desc}</p>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-center py-10 text-slate-400 font-bold italic">Nenhuma atividade recente</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => setShowLogsModal(true)}
|
|
className="w-full mt-8 py-3 rounded-xl border border-slate-200 text-slate-600 text-xs font-black uppercase tracking-widest hover:bg-slate-50 transition-colors flex items-center justify-center gap-2 active:scale-95"
|
|
>
|
|
Ver Log Completo
|
|
<ChevronRight size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Pré-Matrículas Section */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
{/* Recent Pre-Enrollments */}
|
|
<div
|
|
onClick={() => setView && setView(View.PreMatricula)}
|
|
className="lg:col-span-2 bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 ease-out cursor-pointer active:scale-[0.99]"
|
|
>
|
|
<div className="flex justify-between items-center mb-6">
|
|
<div>
|
|
<h3 className="text-lg font-black text-slate-900">Pré-Matrículas Recentes</h3>
|
|
<p className="text-xs text-slate-500 font-bold uppercase tracking-tighter">Últimas solicitações recebidas</p>
|
|
</div>
|
|
<span className="px-3 py-1 bg-amber-100 text-amber-800 rounded-full text-xs font-black">
|
|
{prematriculas.filter(p => p.status === 'pendente').length} Pendentes
|
|
</span>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto" onClick={(e) => e.stopPropagation()}>
|
|
{prematriculas.length > 0 ? (
|
|
<table className="w-full text-left border-collapse text-xs">
|
|
<thead>
|
|
<tr className="border-b border-slate-100 text-slate-400 font-bold uppercase tracking-wider">
|
|
<th className="py-3 pr-4">Candidato</th>
|
|
<th className="py-3 px-4">Contato</th>
|
|
<th className="py-3 px-4">Turma de Interesse</th>
|
|
<th className="py-3 px-4 text-center">Status</th>
|
|
<th className="py-3 pl-4 text-right">Data</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-50 font-medium text-slate-700">
|
|
{prematriculas.slice(0, 5).map((p) => {
|
|
const statusColor =
|
|
p.status === 'aprovado' ? 'bg-emerald-100 text-emerald-800 font-bold' :
|
|
p.status === 'rejeitado' ? 'bg-rose-100 text-rose-800 font-bold' :
|
|
'bg-amber-100 text-amber-800 font-bold';
|
|
|
|
return (
|
|
<tr key={p.id} className="hover:bg-slate-50 transition-colors">
|
|
<td className="py-3.5 pr-4 font-bold text-slate-900">{p.nome}</td>
|
|
<td className="py-3.5 px-4 font-mono text-slate-500">{p.telefone || p.email || '—'}</td>
|
|
<td className="py-3.5 px-4 font-bold text-indigo-600">{p.turmaNome || 'Geral'}</td>
|
|
<td className="py-3.5 px-4 text-center">
|
|
<span className={`inline-block px-2.5 py-0.5 rounded-full text-[10px] uppercase tracking-wider ${statusColor}`}>
|
|
{p.status}
|
|
</span>
|
|
</td>
|
|
<td className="py-3.5 pl-4 text-right text-slate-400">
|
|
{new Date(p.createdAt || p.created_at).toLocaleDateString('pt-BR')}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
) : (
|
|
<div className="text-center py-8 text-slate-400 font-bold italic">Nenhuma pré-matrícula recebida</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Pre-Enrollment Stats Funnel */}
|
|
<div
|
|
onClick={() => setView && setView(View.PreMatricula)}
|
|
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 ease-out cursor-pointer active:scale-[0.99]"
|
|
>
|
|
<h3 className="text-lg font-black text-slate-900 mb-2">Funil de Pré-Matrícula</h3>
|
|
<p className="text-xs text-slate-500 font-bold uppercase tracking-tighter mb-6">Conversão e status</p>
|
|
|
|
<div className="space-y-4">
|
|
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100 flex items-center justify-between shadow-sm">
|
|
<div>
|
|
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Total Geral</p>
|
|
<h4 className="text-2xl font-black text-slate-900 mt-1">{prematriculas.length}</h4>
|
|
</div>
|
|
<div className="w-10 h-10 rounded-full bg-slate-200 flex items-center justify-center text-slate-600 font-black">
|
|
Σ
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 bg-amber-50 rounded-2xl border border-amber-100 flex items-center justify-between shadow-sm">
|
|
<div>
|
|
<p className="text-[10px] font-black text-amber-500 uppercase tracking-widest">Aguardando Análise</p>
|
|
<h4 className="text-2xl font-black text-amber-800 mt-1">
|
|
{prematriculas.filter(p => p.status === 'pendente').length}
|
|
</h4>
|
|
</div>
|
|
<span className="w-8 h-8 bg-amber-200 text-amber-800 rounded-full flex items-center justify-center text-xs font-extrabold animate-pulse">
|
|
⏰
|
|
</span>
|
|
</div>
|
|
|
|
<div className="p-4 bg-emerald-50 rounded-2xl border border-emerald-100 flex items-center justify-between shadow-sm">
|
|
<div>
|
|
<p className="text-[10px] font-black text-emerald-500 uppercase tracking-widest">Aprovados</p>
|
|
<h4 className="text-2xl font-black text-emerald-800 mt-1">
|
|
{prematriculas.filter(p => p.status === 'aprovado').length}
|
|
</h4>
|
|
</div>
|
|
<span className="w-8 h-8 bg-emerald-200 text-emerald-800 rounded-full flex items-center justify-center text-xs font-extrabold">
|
|
✓
|
|
</span>
|
|
</div>
|
|
|
|
<div className="p-4 bg-rose-50 rounded-2xl border border-rose-100 flex items-center justify-between shadow-sm">
|
|
<div>
|
|
<p className="text-[10px] font-black text-rose-500 uppercase tracking-widest">Rejeitados</p>
|
|
<h4 className="text-2xl font-black text-rose-800 mt-1">
|
|
{prematriculas.filter(p => p.status === 'rejeitado').length}
|
|
</h4>
|
|
</div>
|
|
<span className="w-8 h-8 bg-rose-200 text-rose-800 rounded-full flex items-center justify-center text-xs font-extrabold">
|
|
✕
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Detailed View Expansion */}
|
|
{dashboardView === 'detailed' && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 animate-in slide-in-from-bottom-4 duration-500">
|
|
<div
|
|
onClick={() => setView && setView(View.Students)}
|
|
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 ease-out cursor-pointer active:scale-[0.99]"
|
|
>
|
|
<h3 className="text-lg font-black text-slate-900 mb-6">Distribuição por Gênero</h3>
|
|
<div className="h-48 flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={[
|
|
{ name: 'Feminino', value: data.students.filter(s => (s as any).sexo === 'Feminino' || s.gender === 'F' || (s as any).sexo === 'F').length },
|
|
{ name: 'Masculino', value: data.students.filter(s => (s as any).sexo === 'Masculino' || s.gender === 'M' || (s as any).sexo === 'M').length },
|
|
{ name: 'Outro', value: data.students.filter(s => (s as any).sexo === 'Outro' || s.gender === 'O' || (s as any).sexo === 'O').length },
|
|
]}
|
|
cx="50%"
|
|
cy="50%"
|
|
outerRadius={60}
|
|
dataKey="value"
|
|
label
|
|
>
|
|
<Cell fill="#ec4899" />
|
|
<Cell fill="#3b82f6" />
|
|
<Cell fill="#94a3b8" />
|
|
</Pie>
|
|
<Tooltip />
|
|
<Legend />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
<div
|
|
onClick={() => setView && setView(View.Students)}
|
|
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 ease-out cursor-pointer active:scale-[0.99]"
|
|
>
|
|
<h3 className="text-lg font-black text-slate-900 mb-6">Alunos por Status</h3>
|
|
<div className="h-48" onClick={(e) => e.stopPropagation()}>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={[
|
|
{ name: 'Ativo', value: data.students.filter(s => s.status === 'active').length },
|
|
{ name: 'Inativo', value: data.students.filter(s => s.status === 'inactive').length },
|
|
{ name: 'Trancado', value: data.students.filter(s => s.status === 'suspended').length },
|
|
]}>
|
|
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fontSize: 12, fontWeight: 700}} />
|
|
<Tooltip cursor={{fill: 'transparent'}} />
|
|
<Bar dataKey="value" fill="#6366f1" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Floating Audit Logs Modal (Rule 11 & 17 compliant) */}
|
|
{showLogsModal && (
|
|
<div className="fixed inset-0 bg-transparent z-50 flex items-center justify-center p-4">
|
|
<div className="bg-white rounded-3xl w-full max-w-2xl p-6 shadow-2xl border border-slate-100 flex flex-col max-h-[80vh] animate-in fade-in zoom-in-95 duration-200">
|
|
<div className="flex justify-between items-center pb-4 border-b border-slate-100 mb-4">
|
|
<div>
|
|
<h3 className="text-lg font-black text-slate-900">Histórico de Atividades</h3>
|
|
<p className="text-[10px] text-slate-400 font-black uppercase tracking-widest mt-0.5">Logs de auditoria e novos registros</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowLogsModal(false)}
|
|
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-colors"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto space-y-4 pr-2 custom-scrollbar">
|
|
{allActivities.length > 0 ? (
|
|
allActivities.map((activity, i) => (
|
|
<div
|
|
key={i}
|
|
onClick={() => {
|
|
if (setView) {
|
|
setView(activity.type === 'student' ? View.Students : View.Finance);
|
|
setShowLogsModal(false);
|
|
}
|
|
}}
|
|
className="flex gap-4 p-3 rounded-2xl hover:bg-slate-50 border border-transparent hover:border-slate-100 transition-all duration-200 cursor-pointer active:scale-98"
|
|
>
|
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${activity.color}`}>
|
|
<activity.icon size={18} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex justify-between items-start">
|
|
<p className="text-sm font-black text-slate-900 truncate">{activity.title}</p>
|
|
<span className="text-[10px] font-bold text-slate-400 whitespace-nowrap ml-2">
|
|
{new Date(activity.date).toLocaleDateString('pt-BR', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-slate-500 font-medium truncate mt-0.5">{activity.desc}</p>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-center py-10 text-slate-400 font-bold italic">Nenhuma atividade registrada</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Dashboard;
|