| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735 |
- '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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
- import {
- Calendar,
- Clock,
- TrendingUp,
- Search,
- Filter,
- CheckCircle,
- XCircle,
- AlertCircle,
- User,
- BookOpen,
- BarChart3,
- CalendarDays,
- FileText,
- Download,
- ArrowUpDown
- } from 'lucide-react';
- import { toast } from 'sonner';
- import { AttendanceStatus } from '@prisma/client';
- import { LoadingSpinner } from '@/components/ui/spinner';
- 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('');
- const [sortField, setSortField] = useState<'date' | 'class' | 'status'>('date');
- const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
- 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;
- }).sort((a, b) => {
- let aValue: string | number;
- let bValue: string | number;
-
- switch (sortField) {
- case 'date':
- aValue = new Date(a.date).getTime();
- bValue = new Date(b.date).getTime();
- break;
- case 'class':
- aValue = a.section.class.name;
- bValue = b.section.class.name;
- break;
- case 'status':
- aValue = a.status;
- bValue = b.status;
- break;
- default:
- return 0;
- }
-
- if (sortDirection === 'asc') {
- return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
- } else {
- return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
- }
- }) || [];
- 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('');
- };
- const handleSort = (field: 'date' | 'class' | 'status') => {
- if (sortField === field) {
- setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
- } else {
- setSortField(field);
- setSortDirection('desc');
- }
- };
- if (loading) {
- return (
- <MainLayout requiredRole="STUDENT" title="Historial de Asistencia">
- <div className="flex items-center justify-center h-64">
- <LoadingSpinner size="lg" />
- </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 ? (
- <Card>
- <CardContent className="p-0">
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead
- className="cursor-pointer hover:bg-muted/50 select-none"
- onClick={() => handleSort('date')}
- >
- <div className="flex items-center gap-2">
- Fecha
- <ArrowUpDown className="h-4 w-4" />
- </div>
- </TableHead>
- <TableHead
- className="cursor-pointer hover:bg-muted/50 select-none"
- onClick={() => handleSort('class')}
- >
- <div className="flex items-center gap-2">
- Clase
- <ArrowUpDown className="h-4 w-4" />
- </div>
- </TableHead>
- <TableHead>Sección</TableHead>
- <TableHead>Profesor</TableHead>
- <TableHead
- className="cursor-pointer hover:bg-muted/50 select-none"
- onClick={() => handleSort('status')}
- >
- <div className="flex items-center gap-2">
- Estado
- <ArrowUpDown className="h-4 w-4" />
- </div>
- </TableHead>
- <TableHead>Observación</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {filteredRecords.map((record) => (
- <TableRow key={record.id} className="hover:bg-muted/50">
- <TableCell className="font-medium">
- <div className="space-y-1">
- <div>{formatShortDate(record.date)}</div>
- <div className="text-xs text-muted-foreground">
- {new Date(record.date).toLocaleDateString('es-ES', { weekday: 'short' })}
- </div>
- </div>
- </TableCell>
- <TableCell>
- <div className="space-y-1">
- <div className="font-medium">{record.section.class.name}</div>
- <div className="text-sm text-muted-foreground">
- {record.section.class.code} • {record.section.class.period.name}
- </div>
- </div>
- </TableCell>
- <TableCell>
- <Badge variant="outline">{record.section.name}</Badge>
- </TableCell>
- <TableCell>
- <div className="text-sm">
- {getTeacherNames(record.section.teachers) || 'Sin asignar'}
- </div>
- </TableCell>
- <TableCell>
- <div className={`flex items-center gap-2 ${getStatusColor(record.status)}`}>
- {getStatusIcon(record.status)}
- <Badge variant={getStatusBadgeVariant(record.status)}>
- {getStatusText(record.status)}
- </Badge>
- </div>
- </TableCell>
- <TableCell>
- {record.reason ? (
- <div className="max-w-xs">
- <div className="text-sm truncate" title={record.reason}>
- {record.reason}
- </div>
- </div>
- ) : (
- <span className="text-muted-foreground text-sm">-</span>
- )}
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </CardContent>
- </Card>
- ) : (
- <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>
- );
- }
|