|
|
@@ -0,0 +1,648 @@
|
|
|
+'use client';
|
|
|
+
|
|
|
+import { useState, useEffect } from 'react';
|
|
|
+import { MainLayout } from '@/components/layout/main-layout';
|
|
|
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
|
+import { Badge } from '@/components/ui/badge';
|
|
|
+import { Button } from '@/components/ui/button';
|
|
|
+import { Input } from '@/components/ui/input';
|
|
|
+import { Label } from '@/components/ui/label';
|
|
|
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
|
+import { Progress } from '@/components/ui/progress';
|
|
|
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
|
+import {
|
|
|
+ Calendar,
|
|
|
+ Clock,
|
|
|
+ TrendingUp,
|
|
|
+ Search,
|
|
|
+ Filter,
|
|
|
+ CheckCircle,
|
|
|
+ XCircle,
|
|
|
+ AlertCircle,
|
|
|
+ User,
|
|
|
+ BookOpen,
|
|
|
+ BarChart3,
|
|
|
+ CalendarDays,
|
|
|
+ FileText,
|
|
|
+ Download
|
|
|
+} from 'lucide-react';
|
|
|
+import { toast } from 'sonner';
|
|
|
+import { AttendanceStatus } from '@prisma/client';
|
|
|
+
|
|
|
+interface Teacher {
|
|
|
+ id: string;
|
|
|
+ firstName: string;
|
|
|
+ lastName: string;
|
|
|
+ email: string;
|
|
|
+}
|
|
|
+
|
|
|
+interface Period {
|
|
|
+ id: string;
|
|
|
+ name: string;
|
|
|
+ isActive: boolean;
|
|
|
+}
|
|
|
+
|
|
|
+interface Class {
|
|
|
+ id: string;
|
|
|
+ name: string;
|
|
|
+ code: string;
|
|
|
+ period: Period;
|
|
|
+}
|
|
|
+
|
|
|
+interface Section {
|
|
|
+ id: string;
|
|
|
+ name: string;
|
|
|
+ class: Class;
|
|
|
+ teachers: Teacher[];
|
|
|
+}
|
|
|
+
|
|
|
+interface AttendanceRecord {
|
|
|
+ id: string;
|
|
|
+ date: string;
|
|
|
+ status: AttendanceStatus;
|
|
|
+ reason: string | null;
|
|
|
+ section: Section;
|
|
|
+}
|
|
|
+
|
|
|
+interface OverallStats {
|
|
|
+ totalRecords: number;
|
|
|
+ present: number;
|
|
|
+ absent: number;
|
|
|
+ justified: number;
|
|
|
+ attendanceRate: number;
|
|
|
+}
|
|
|
+
|
|
|
+interface SectionStats {
|
|
|
+ sectionId: string;
|
|
|
+ sectionName: string;
|
|
|
+ className: string;
|
|
|
+ classCode: string;
|
|
|
+ periodName: string;
|
|
|
+ total: number;
|
|
|
+ present: number;
|
|
|
+ absent: number;
|
|
|
+ justified: number;
|
|
|
+ attendanceRate: number;
|
|
|
+}
|
|
|
+
|
|
|
+interface PeriodStats {
|
|
|
+ periodId: string;
|
|
|
+ periodName: string;
|
|
|
+ isActive: boolean;
|
|
|
+ total: number;
|
|
|
+ present: number;
|
|
|
+ absent: number;
|
|
|
+ justified: number;
|
|
|
+ attendanceRate: number;
|
|
|
+}
|
|
|
+
|
|
|
+interface FilterSection {
|
|
|
+ id: string;
|
|
|
+ name: string;
|
|
|
+ className: string;
|
|
|
+ classCode: string;
|
|
|
+ periodName: string;
|
|
|
+}
|
|
|
+
|
|
|
+interface FilterPeriod {
|
|
|
+ id: string;
|
|
|
+ name: string;
|
|
|
+ isActive: boolean;
|
|
|
+}
|
|
|
+
|
|
|
+interface Student {
|
|
|
+ id: string;
|
|
|
+ firstName: string;
|
|
|
+ lastName: string;
|
|
|
+ admissionNumber: string;
|
|
|
+}
|
|
|
+
|
|
|
+interface AttendanceResponse {
|
|
|
+ student: Student;
|
|
|
+ attendanceRecords: AttendanceRecord[];
|
|
|
+ statistics: {
|
|
|
+ overall: OverallStats;
|
|
|
+ bySections: SectionStats[];
|
|
|
+ byPeriods: PeriodStats[];
|
|
|
+ };
|
|
|
+ filters: {
|
|
|
+ sections: FilterSection[];
|
|
|
+ periods: FilterPeriod[];
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+export default function StudentAttendancePage() {
|
|
|
+ const [data, setData] = useState<AttendanceResponse | null>(null);
|
|
|
+ const [loading, setLoading] = useState(true);
|
|
|
+ const [searchTerm, setSearchTerm] = useState('');
|
|
|
+ const [selectedSection, setSelectedSection] = useState<string>('all');
|
|
|
+ const [selectedPeriod, setSelectedPeriod] = useState<string>('all');
|
|
|
+ const [selectedStatus, setSelectedStatus] = useState<string>('all');
|
|
|
+ const [startDate, setStartDate] = useState('');
|
|
|
+ const [endDate, setEndDate] = useState('');
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ fetchAttendance();
|
|
|
+ }, [selectedSection, selectedPeriod, selectedStatus, startDate, endDate]);
|
|
|
+
|
|
|
+ const fetchAttendance = async () => {
|
|
|
+ try {
|
|
|
+ setLoading(true);
|
|
|
+ const params = new URLSearchParams();
|
|
|
+
|
|
|
+ if (selectedSection !== 'all') params.append('sectionId', selectedSection);
|
|
|
+ if (selectedPeriod !== 'all') params.append('periodId', selectedPeriod);
|
|
|
+ if (selectedStatus !== 'all') params.append('status', selectedStatus);
|
|
|
+ if (startDate) params.append('startDate', startDate);
|
|
|
+ if (endDate) params.append('endDate', endDate);
|
|
|
+
|
|
|
+ const response = await fetch(`/api/student/attendance?${params.toString()}`);
|
|
|
+ if (!response.ok) {
|
|
|
+ throw new Error('Error al cargar el historial de asistencia');
|
|
|
+ }
|
|
|
+ const responseData: AttendanceResponse = await response.json();
|
|
|
+ setData(responseData);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Error:', error);
|
|
|
+ toast.error('Error al cargar el historial de asistencia');
|
|
|
+ } finally {
|
|
|
+ setLoading(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const filteredRecords = data?.attendanceRecords.filter(record => {
|
|
|
+ const matchesSearch =
|
|
|
+ record.section.class.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
|
+ record.section.class.code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
|
+ record.section.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
|
+ (record.reason && record.reason.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
|
+
|
|
|
+ return matchesSearch;
|
|
|
+ }) || [];
|
|
|
+
|
|
|
+ const getStatusColor = (status: AttendanceStatus) => {
|
|
|
+ switch (status) {
|
|
|
+ case 'PRESENT': return 'text-green-600';
|
|
|
+ case 'ABSENT': return 'text-red-600';
|
|
|
+ case 'JUSTIFIED': return 'text-yellow-600';
|
|
|
+ default: return 'text-gray-600';
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const getStatusBadgeVariant = (status: AttendanceStatus): 'default' | 'secondary' | 'destructive' => {
|
|
|
+ switch (status) {
|
|
|
+ case 'PRESENT': return 'default';
|
|
|
+ case 'ABSENT': return 'destructive';
|
|
|
+ case 'JUSTIFIED': return 'secondary';
|
|
|
+ default: return 'secondary';
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const getStatusIcon = (status: AttendanceStatus) => {
|
|
|
+ switch (status) {
|
|
|
+ case 'PRESENT': return <CheckCircle className="h-4 w-4" />;
|
|
|
+ case 'ABSENT': return <XCircle className="h-4 w-4" />;
|
|
|
+ case 'JUSTIFIED': return <AlertCircle className="h-4 w-4" />;
|
|
|
+ default: return <Clock className="h-4 w-4" />;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const getStatusText = (status: AttendanceStatus) => {
|
|
|
+ switch (status) {
|
|
|
+ case 'PRESENT': return 'Presente';
|
|
|
+ case 'ABSENT': return 'Ausente';
|
|
|
+ case 'JUSTIFIED': return 'Justificado';
|
|
|
+ default: return status;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const getAttendanceColor = (rate: number) => {
|
|
|
+ if (rate >= 90) return 'text-green-600';
|
|
|
+ if (rate >= 75) return 'text-yellow-600';
|
|
|
+ return 'text-red-600';
|
|
|
+ };
|
|
|
+
|
|
|
+ const formatDate = (dateString: string) => {
|
|
|
+ return new Date(dateString).toLocaleDateString('es-ES', {
|
|
|
+ year: 'numeric',
|
|
|
+ month: 'long',
|
|
|
+ day: 'numeric',
|
|
|
+ weekday: 'long'
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const formatShortDate = (dateString: string) => {
|
|
|
+ return new Date(dateString).toLocaleDateString('es-ES');
|
|
|
+ };
|
|
|
+
|
|
|
+ const getTeacherNames = (teachers: Teacher[]) => {
|
|
|
+ return teachers.map(teacher => `${teacher.firstName} ${teacher.lastName}`).join(', ');
|
|
|
+ };
|
|
|
+
|
|
|
+ const clearFilters = () => {
|
|
|
+ setSearchTerm('');
|
|
|
+ setSelectedSection('all');
|
|
|
+ setSelectedPeriod('all');
|
|
|
+ setSelectedStatus('all');
|
|
|
+ setStartDate('');
|
|
|
+ setEndDate('');
|
|
|
+ };
|
|
|
+
|
|
|
+ if (loading) {
|
|
|
+ return (
|
|
|
+ <MainLayout requiredRole="STUDENT" title="Historial de Asistencia">
|
|
|
+ <div className="flex items-center justify-center h-64">
|
|
|
+ <div className="text-lg">Cargando historial de asistencia...</div>
|
|
|
+ </div>
|
|
|
+ </MainLayout>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!data) {
|
|
|
+ return (
|
|
|
+ <MainLayout requiredRole="STUDENT" title="Historial de Asistencia">
|
|
|
+ <div className="flex items-center justify-center h-64">
|
|
|
+ <div className="text-lg text-red-600">Error al cargar los datos</div>
|
|
|
+ </div>
|
|
|
+ </MainLayout>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <MainLayout requiredRole="STUDENT" title="Historial de Asistencia">
|
|
|
+ <div className="space-y-6">
|
|
|
+ {/* Header */}
|
|
|
+ <div className="flex justify-between items-center">
|
|
|
+ <div>
|
|
|
+ <h1 className="text-3xl font-bold tracking-tight">Historial de Asistencia</h1>
|
|
|
+ <p className="text-muted-foreground">
|
|
|
+ {data.student.firstName} {data.student.lastName} - {data.student.admissionNumber}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Estadísticas Generales */}
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
|
+ <Card>
|
|
|
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
|
+ <CardTitle className="text-sm font-medium">Total de Registros</CardTitle>
|
|
|
+ <Calendar className="h-4 w-4 text-muted-foreground" />
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <div className="text-2xl font-bold">{data.statistics.overall.totalRecords}</div>
|
|
|
+ <p className="text-xs text-muted-foreground">
|
|
|
+ Histórico completo
|
|
|
+ </p>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Card>
|
|
|
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
|
+ <CardTitle className="text-sm font-medium">Asistencia Promedio</CardTitle>
|
|
|
+ <TrendingUp className="h-4 w-4 text-muted-foreground" />
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <div className={`text-2xl font-bold ${getAttendanceColor(data.statistics.overall.attendanceRate)}`}>
|
|
|
+ {data.statistics.overall.attendanceRate}%
|
|
|
+ </div>
|
|
|
+ <p className="text-xs text-muted-foreground">
|
|
|
+ Todas las clases
|
|
|
+ </p>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Card>
|
|
|
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
|
+ <CardTitle className="text-sm font-medium">Días Presente</CardTitle>
|
|
|
+ <CheckCircle className="h-4 w-4 text-green-600" />
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <div className="text-2xl font-bold text-green-600">{data.statistics.overall.present}</div>
|
|
|
+ <p className="text-xs text-muted-foreground">
|
|
|
+ {Math.round((data.statistics.overall.present / data.statistics.overall.totalRecords) * 100)}% del total
|
|
|
+ </p>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Card>
|
|
|
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
|
+ <CardTitle className="text-sm font-medium">Días Ausente</CardTitle>
|
|
|
+ <XCircle className="h-4 w-4 text-red-600" />
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <div className="text-2xl font-bold text-red-600">{data.statistics.overall.absent}</div>
|
|
|
+ <p className="text-xs text-muted-foreground">
|
|
|
+ {data.statistics.overall.justified} justificados
|
|
|
+ </p>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Filtros */}
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle className="flex items-center gap-2">
|
|
|
+ <Filter className="h-5 w-5" />
|
|
|
+ Filtros
|
|
|
+ </CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="search">Buscar</Label>
|
|
|
+ <div className="relative">
|
|
|
+ <Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
|
|
+ <Input
|
|
|
+ id="search"
|
|
|
+ placeholder="Buscar..."
|
|
|
+ value={searchTerm}
|
|
|
+ onChange={(e) => setSearchTerm(e.target.value)}
|
|
|
+ className="pl-10"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="section">Sección</Label>
|
|
|
+ <Select value={selectedSection} onValueChange={setSelectedSection}>
|
|
|
+ <SelectTrigger>
|
|
|
+ <SelectValue placeholder="Todas las secciones" />
|
|
|
+ </SelectTrigger>
|
|
|
+ <SelectContent>
|
|
|
+ <SelectItem value="all">Todas las secciones</SelectItem>
|
|
|
+ {data.filters.sections.map((section) => (
|
|
|
+ <SelectItem key={section.id} value={section.id}>
|
|
|
+ {section.className} - {section.name}
|
|
|
+ </SelectItem>
|
|
|
+ ))}
|
|
|
+ </SelectContent>
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="period">Período</Label>
|
|
|
+ <Select value={selectedPeriod} onValueChange={setSelectedPeriod}>
|
|
|
+ <SelectTrigger>
|
|
|
+ <SelectValue placeholder="Todos los períodos" />
|
|
|
+ </SelectTrigger>
|
|
|
+ <SelectContent>
|
|
|
+ <SelectItem value="all">Todos los períodos</SelectItem>
|
|
|
+ {data.filters.periods.map((period) => (
|
|
|
+ <SelectItem key={period.id} value={period.id}>
|
|
|
+ {period.name} {period.isActive && '(Activo)'}
|
|
|
+ </SelectItem>
|
|
|
+ ))}
|
|
|
+ </SelectContent>
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="status">Estado</Label>
|
|
|
+ <Select value={selectedStatus} onValueChange={setSelectedStatus}>
|
|
|
+ <SelectTrigger>
|
|
|
+ <SelectValue placeholder="Todos los estados" />
|
|
|
+ </SelectTrigger>
|
|
|
+ <SelectContent>
|
|
|
+ <SelectItem value="all">Todos los estados</SelectItem>
|
|
|
+ <SelectItem value="PRESENT">Presente</SelectItem>
|
|
|
+ <SelectItem value="ABSENT">Ausente</SelectItem>
|
|
|
+ <SelectItem value="JUSTIFIED">Justificado</SelectItem>
|
|
|
+ </SelectContent>
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="startDate">Fecha Inicio</Label>
|
|
|
+ <Input
|
|
|
+ id="startDate"
|
|
|
+ type="date"
|
|
|
+ value={startDate}
|
|
|
+ onChange={(e) => setStartDate(e.target.value)}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label htmlFor="endDate">Fecha Fin</Label>
|
|
|
+ <Input
|
|
|
+ id="endDate"
|
|
|
+ type="date"
|
|
|
+ value={endDate}
|
|
|
+ onChange={(e) => setEndDate(e.target.value)}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex justify-end mt-4">
|
|
|
+ <Button variant="outline" onClick={clearFilters}>
|
|
|
+ Limpiar filtros
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ {/* Contenido con Tabs */}
|
|
|
+ <Tabs defaultValue="records" className="space-y-4">
|
|
|
+ <TabsList>
|
|
|
+ <TabsTrigger value="records" className="flex items-center gap-2">
|
|
|
+ <FileText className="h-4 w-4" />
|
|
|
+ Registros ({filteredRecords.length})
|
|
|
+ </TabsTrigger>
|
|
|
+ <TabsTrigger value="sections" className="flex items-center gap-2">
|
|
|
+ <BookOpen className="h-4 w-4" />
|
|
|
+ Por Sección ({data.statistics.bySections.length})
|
|
|
+ </TabsTrigger>
|
|
|
+ <TabsTrigger value="periods" className="flex items-center gap-2">
|
|
|
+ <BarChart3 className="h-4 w-4" />
|
|
|
+ Por Período ({data.statistics.byPeriods.length})
|
|
|
+ </TabsTrigger>
|
|
|
+ </TabsList>
|
|
|
+
|
|
|
+ {/* Tab de Registros */}
|
|
|
+ <TabsContent value="records" className="space-y-4">
|
|
|
+ {filteredRecords.length > 0 ? (
|
|
|
+ <div className="grid grid-cols-1 gap-4">
|
|
|
+ {filteredRecords.map((record) => (
|
|
|
+ <Card key={record.id} className="hover:shadow-md transition-shadow">
|
|
|
+ <CardContent className="p-4">
|
|
|
+ <div className="flex justify-between items-start">
|
|
|
+ <div className="space-y-2 flex-1">
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
+ <div className={`flex items-center gap-2 ${getStatusColor(record.status)}`}>
|
|
|
+ {getStatusIcon(record.status)}
|
|
|
+ <Badge variant={getStatusBadgeVariant(record.status)}>
|
|
|
+ {getStatusText(record.status)}
|
|
|
+ </Badge>
|
|
|
+ </div>
|
|
|
+ <div className="text-sm text-muted-foreground">
|
|
|
+ {formatDate(record.date)}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="space-y-1">
|
|
|
+ <div className="font-medium">
|
|
|
+ {record.section.class.name} - {record.section.name}
|
|
|
+ </div>
|
|
|
+ <div className="text-sm text-muted-foreground">
|
|
|
+ {record.section.class.code} • {record.section.class.period.name}
|
|
|
+ </div>
|
|
|
+ <div className="text-sm text-muted-foreground">
|
|
|
+ <User className="h-3 w-3 inline mr-1" />
|
|
|
+ {getTeacherNames(record.section.teachers) || 'Sin profesor asignado'}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {record.reason && (
|
|
|
+ <div className="text-sm bg-muted p-2 rounded">
|
|
|
+ <strong>Observación:</strong> {record.reason}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <Card>
|
|
|
+ <CardContent className="flex flex-col items-center justify-center py-12">
|
|
|
+ <Calendar className="h-12 w-12 text-muted-foreground mb-4" />
|
|
|
+ <h3 className="text-lg font-medium mb-2">No se encontraron registros</h3>
|
|
|
+ <p className="text-muted-foreground text-center">
|
|
|
+ No hay registros de asistencia que coincidan con los filtros aplicados.
|
|
|
+ </p>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
+ </TabsContent>
|
|
|
+
|
|
|
+ {/* Tab de Estadísticas por Sección */}
|
|
|
+ <TabsContent value="sections" className="space-y-4">
|
|
|
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
|
+ {data.statistics.bySections.map((section) => (
|
|
|
+ <Card key={section.sectionId}>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle className="text-lg">{section.className}</CardTitle>
|
|
|
+ <CardDescription>
|
|
|
+ {section.sectionName} • {section.periodName}
|
|
|
+ </CardDescription>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent className="space-y-4">
|
|
|
+ <div className="flex justify-between items-center">
|
|
|
+ <span className="text-sm font-medium">Asistencia</span>
|
|
|
+ <Badge variant={section.attendanceRate >= 90 ? 'default' : section.attendanceRate >= 75 ? 'secondary' : 'destructive'}>
|
|
|
+ {section.attendanceRate}%
|
|
|
+ </Badge>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Progress value={section.attendanceRate} className="h-2" />
|
|
|
+
|
|
|
+ <div className="grid grid-cols-4 gap-2 text-xs">
|
|
|
+ <div className="text-center">
|
|
|
+ <div className="flex items-center justify-center gap-1">
|
|
|
+ <CheckCircle className="h-3 w-3 text-green-600" />
|
|
|
+ <span className="font-medium">{section.present}</span>
|
|
|
+ </div>
|
|
|
+ <div className="text-muted-foreground">Presente</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="text-center">
|
|
|
+ <div className="flex items-center justify-center gap-1">
|
|
|
+ <XCircle className="h-3 w-3 text-red-600" />
|
|
|
+ <span className="font-medium">{section.absent}</span>
|
|
|
+ </div>
|
|
|
+ <div className="text-muted-foreground">Ausente</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="text-center">
|
|
|
+ <div className="flex items-center justify-center gap-1">
|
|
|
+ <AlertCircle className="h-3 w-3 text-yellow-600" />
|
|
|
+ <span className="font-medium">{section.justified}</span>
|
|
|
+ </div>
|
|
|
+ <div className="text-muted-foreground">Justificado</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="text-center">
|
|
|
+ <div className="flex items-center justify-center gap-1">
|
|
|
+ <Calendar className="h-3 w-3 text-blue-600" />
|
|
|
+ <span className="font-medium">{section.total}</span>
|
|
|
+ </div>
|
|
|
+ <div className="text-muted-foreground">Total</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </TabsContent>
|
|
|
+
|
|
|
+ {/* Tab de Estadísticas por Período */}
|
|
|
+ <TabsContent value="periods" className="space-y-4">
|
|
|
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
|
+ {data.statistics.byPeriods.map((period) => (
|
|
|
+ <Card key={period.periodId}>
|
|
|
+ <CardHeader>
|
|
|
+ <div className="flex justify-between items-start">
|
|
|
+ <div>
|
|
|
+ <CardTitle className="text-lg">{period.periodName}</CardTitle>
|
|
|
+ <CardDescription>
|
|
|
+ {period.total} registros de asistencia
|
|
|
+ </CardDescription>
|
|
|
+ </div>
|
|
|
+ <Badge variant={period.isActive ? 'default' : 'secondary'}>
|
|
|
+ {period.isActive ? 'Activo' : 'Inactivo'}
|
|
|
+ </Badge>
|
|
|
+ </div>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent className="space-y-4">
|
|
|
+ <div className="flex justify-between items-center">
|
|
|
+ <span className="text-sm font-medium">Asistencia</span>
|
|
|
+ <Badge variant={period.attendanceRate >= 90 ? 'default' : period.attendanceRate >= 75 ? 'secondary' : 'destructive'}>
|
|
|
+ {period.attendanceRate}%
|
|
|
+ </Badge>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Progress value={period.attendanceRate} className="h-2" />
|
|
|
+
|
|
|
+ <div className="grid grid-cols-4 gap-2 text-xs">
|
|
|
+ <div className="text-center">
|
|
|
+ <div className="flex items-center justify-center gap-1">
|
|
|
+ <CheckCircle className="h-3 w-3 text-green-600" />
|
|
|
+ <span className="font-medium">{period.present}</span>
|
|
|
+ </div>
|
|
|
+ <div className="text-muted-foreground">Presente</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="text-center">
|
|
|
+ <div className="flex items-center justify-center gap-1">
|
|
|
+ <XCircle className="h-3 w-3 text-red-600" />
|
|
|
+ <span className="font-medium">{period.absent}</span>
|
|
|
+ </div>
|
|
|
+ <div className="text-muted-foreground">Ausente</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="text-center">
|
|
|
+ <div className="flex items-center justify-center gap-1">
|
|
|
+ <AlertCircle className="h-3 w-3 text-yellow-600" />
|
|
|
+ <span className="font-medium">{period.justified}</span>
|
|
|
+ </div>
|
|
|
+ <div className="text-muted-foreground">Justificado</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="text-center">
|
|
|
+ <div className="flex items-center justify-center gap-1">
|
|
|
+ <Calendar className="h-3 w-3 text-blue-600" />
|
|
|
+ <span className="font-medium">{period.total}</span>
|
|
|
+ </div>
|
|
|
+ <div className="text-muted-foreground">Total</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </TabsContent>
|
|
|
+ </Tabs>
|
|
|
+ </div>
|
|
|
+ </MainLayout>
|
|
|
+ );
|
|
|
+}
|