page.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734
  1. 'use client';
  2. import { useState, useEffect } from 'react';
  3. import { MainLayout } from '@/components/layout/main-layout';
  4. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
  5. import { Badge } from '@/components/ui/badge';
  6. import { Button } from '@/components/ui/button';
  7. import { Input } from '@/components/ui/input';
  8. import { Label } from '@/components/ui/label';
  9. import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
  10. import { Progress } from '@/components/ui/progress';
  11. import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
  12. import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
  13. import {
  14. Calendar,
  15. Clock,
  16. TrendingUp,
  17. Search,
  18. Filter,
  19. CheckCircle,
  20. XCircle,
  21. AlertCircle,
  22. User,
  23. BookOpen,
  24. BarChart3,
  25. CalendarDays,
  26. FileText,
  27. Download,
  28. ArrowUpDown
  29. } from 'lucide-react';
  30. import { toast } from 'sonner';
  31. import { AttendanceStatus } from '@prisma/client';
  32. interface Teacher {
  33. id: string;
  34. firstName: string;
  35. lastName: string;
  36. email: string;
  37. }
  38. interface Period {
  39. id: string;
  40. name: string;
  41. isActive: boolean;
  42. }
  43. interface Class {
  44. id: string;
  45. name: string;
  46. code: string;
  47. period: Period;
  48. }
  49. interface Section {
  50. id: string;
  51. name: string;
  52. class: Class;
  53. teachers: Teacher[];
  54. }
  55. interface AttendanceRecord {
  56. id: string;
  57. date: string;
  58. status: AttendanceStatus;
  59. reason: string | null;
  60. section: Section;
  61. }
  62. interface OverallStats {
  63. totalRecords: number;
  64. present: number;
  65. absent: number;
  66. justified: number;
  67. attendanceRate: number;
  68. }
  69. interface SectionStats {
  70. sectionId: string;
  71. sectionName: string;
  72. className: string;
  73. classCode: string;
  74. periodName: string;
  75. total: number;
  76. present: number;
  77. absent: number;
  78. justified: number;
  79. attendanceRate: number;
  80. }
  81. interface PeriodStats {
  82. periodId: string;
  83. periodName: string;
  84. isActive: boolean;
  85. total: number;
  86. present: number;
  87. absent: number;
  88. justified: number;
  89. attendanceRate: number;
  90. }
  91. interface FilterSection {
  92. id: string;
  93. name: string;
  94. className: string;
  95. classCode: string;
  96. periodName: string;
  97. }
  98. interface FilterPeriod {
  99. id: string;
  100. name: string;
  101. isActive: boolean;
  102. }
  103. interface Student {
  104. id: string;
  105. firstName: string;
  106. lastName: string;
  107. admissionNumber: string;
  108. }
  109. interface AttendanceResponse {
  110. student: Student;
  111. attendanceRecords: AttendanceRecord[];
  112. statistics: {
  113. overall: OverallStats;
  114. bySections: SectionStats[];
  115. byPeriods: PeriodStats[];
  116. };
  117. filters: {
  118. sections: FilterSection[];
  119. periods: FilterPeriod[];
  120. };
  121. }
  122. export default function StudentAttendancePage() {
  123. const [data, setData] = useState<AttendanceResponse | null>(null);
  124. const [loading, setLoading] = useState(true);
  125. const [searchTerm, setSearchTerm] = useState('');
  126. const [selectedSection, setSelectedSection] = useState<string>('all');
  127. const [selectedPeriod, setSelectedPeriod] = useState<string>('all');
  128. const [selectedStatus, setSelectedStatus] = useState<string>('all');
  129. const [startDate, setStartDate] = useState('');
  130. const [endDate, setEndDate] = useState('');
  131. const [sortField, setSortField] = useState<'date' | 'class' | 'status'>('date');
  132. const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
  133. useEffect(() => {
  134. fetchAttendance();
  135. }, [selectedSection, selectedPeriod, selectedStatus, startDate, endDate]);
  136. const fetchAttendance = async () => {
  137. try {
  138. setLoading(true);
  139. const params = new URLSearchParams();
  140. if (selectedSection !== 'all') params.append('sectionId', selectedSection);
  141. if (selectedPeriod !== 'all') params.append('periodId', selectedPeriod);
  142. if (selectedStatus !== 'all') params.append('status', selectedStatus);
  143. if (startDate) params.append('startDate', startDate);
  144. if (endDate) params.append('endDate', endDate);
  145. const response = await fetch(`/api/student/attendance?${params.toString()}`);
  146. if (!response.ok) {
  147. throw new Error('Error al cargar el historial de asistencia');
  148. }
  149. const responseData: AttendanceResponse = await response.json();
  150. setData(responseData);
  151. } catch (error) {
  152. console.error('Error:', error);
  153. toast.error('Error al cargar el historial de asistencia');
  154. } finally {
  155. setLoading(false);
  156. }
  157. };
  158. const filteredRecords = data?.attendanceRecords.filter(record => {
  159. const matchesSearch =
  160. record.section.class.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
  161. record.section.class.code.toLowerCase().includes(searchTerm.toLowerCase()) ||
  162. record.section.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
  163. (record.reason && record.reason.toLowerCase().includes(searchTerm.toLowerCase()));
  164. return matchesSearch;
  165. }).sort((a, b) => {
  166. let aValue: string | number;
  167. let bValue: string | number;
  168. switch (sortField) {
  169. case 'date':
  170. aValue = new Date(a.date).getTime();
  171. bValue = new Date(b.date).getTime();
  172. break;
  173. case 'class':
  174. aValue = a.section.class.name;
  175. bValue = b.section.class.name;
  176. break;
  177. case 'status':
  178. aValue = a.status;
  179. bValue = b.status;
  180. break;
  181. default:
  182. return 0;
  183. }
  184. if (sortDirection === 'asc') {
  185. return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
  186. } else {
  187. return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
  188. }
  189. }) || [];
  190. const getStatusColor = (status: AttendanceStatus) => {
  191. switch (status) {
  192. case 'PRESENT': return 'text-green-600';
  193. case 'ABSENT': return 'text-red-600';
  194. case 'JUSTIFIED': return 'text-yellow-600';
  195. default: return 'text-gray-600';
  196. }
  197. };
  198. const getStatusBadgeVariant = (status: AttendanceStatus): 'default' | 'secondary' | 'destructive' => {
  199. switch (status) {
  200. case 'PRESENT': return 'default';
  201. case 'ABSENT': return 'destructive';
  202. case 'JUSTIFIED': return 'secondary';
  203. default: return 'secondary';
  204. }
  205. };
  206. const getStatusIcon = (status: AttendanceStatus) => {
  207. switch (status) {
  208. case 'PRESENT': return <CheckCircle className="h-4 w-4" />;
  209. case 'ABSENT': return <XCircle className="h-4 w-4" />;
  210. case 'JUSTIFIED': return <AlertCircle className="h-4 w-4" />;
  211. default: return <Clock className="h-4 w-4" />;
  212. }
  213. };
  214. const getStatusText = (status: AttendanceStatus) => {
  215. switch (status) {
  216. case 'PRESENT': return 'Presente';
  217. case 'ABSENT': return 'Ausente';
  218. case 'JUSTIFIED': return 'Justificado';
  219. default: return status;
  220. }
  221. };
  222. const getAttendanceColor = (rate: number) => {
  223. if (rate >= 90) return 'text-green-600';
  224. if (rate >= 75) return 'text-yellow-600';
  225. return 'text-red-600';
  226. };
  227. const formatDate = (dateString: string) => {
  228. return new Date(dateString).toLocaleDateString('es-ES', {
  229. year: 'numeric',
  230. month: 'long',
  231. day: 'numeric',
  232. weekday: 'long'
  233. });
  234. };
  235. const formatShortDate = (dateString: string) => {
  236. return new Date(dateString).toLocaleDateString('es-ES');
  237. };
  238. const getTeacherNames = (teachers: Teacher[]) => {
  239. return teachers.map(teacher => `${teacher.firstName} ${teacher.lastName}`).join(', ');
  240. };
  241. const clearFilters = () => {
  242. setSearchTerm('');
  243. setSelectedSection('all');
  244. setSelectedPeriod('all');
  245. setSelectedStatus('all');
  246. setStartDate('');
  247. setEndDate('');
  248. };
  249. const handleSort = (field: 'date' | 'class' | 'status') => {
  250. if (sortField === field) {
  251. setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
  252. } else {
  253. setSortField(field);
  254. setSortDirection('desc');
  255. }
  256. };
  257. if (loading) {
  258. return (
  259. <MainLayout requiredRole="STUDENT" title="Historial de Asistencia">
  260. <div className="flex items-center justify-center h-64">
  261. <div className="text-lg">Cargando historial de asistencia...</div>
  262. </div>
  263. </MainLayout>
  264. );
  265. }
  266. if (!data) {
  267. return (
  268. <MainLayout requiredRole="STUDENT" title="Historial de Asistencia">
  269. <div className="flex items-center justify-center h-64">
  270. <div className="text-lg text-red-600">Error al cargar los datos</div>
  271. </div>
  272. </MainLayout>
  273. );
  274. }
  275. return (
  276. <MainLayout requiredRole="STUDENT" title="Historial de Asistencia">
  277. <div className="space-y-6">
  278. {/* Header */}
  279. <div className="flex justify-between items-center">
  280. <div>
  281. <h1 className="text-3xl font-bold tracking-tight">Historial de Asistencia</h1>
  282. <p className="text-muted-foreground">
  283. {data.student.firstName} {data.student.lastName} - {data.student.admissionNumber}
  284. </p>
  285. </div>
  286. </div>
  287. {/* Estadísticas Generales */}
  288. <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
  289. <Card>
  290. <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
  291. <CardTitle className="text-sm font-medium">Total de Registros</CardTitle>
  292. <Calendar className="h-4 w-4 text-muted-foreground" />
  293. </CardHeader>
  294. <CardContent>
  295. <div className="text-2xl font-bold">{data.statistics.overall.totalRecords}</div>
  296. <p className="text-xs text-muted-foreground">
  297. Histórico completo
  298. </p>
  299. </CardContent>
  300. </Card>
  301. <Card>
  302. <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
  303. <CardTitle className="text-sm font-medium">Asistencia Promedio</CardTitle>
  304. <TrendingUp className="h-4 w-4 text-muted-foreground" />
  305. </CardHeader>
  306. <CardContent>
  307. <div className={`text-2xl font-bold ${getAttendanceColor(data.statistics.overall.attendanceRate)}`}>
  308. {data.statistics.overall.attendanceRate}%
  309. </div>
  310. <p className="text-xs text-muted-foreground">
  311. Todas las clases
  312. </p>
  313. </CardContent>
  314. </Card>
  315. <Card>
  316. <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
  317. <CardTitle className="text-sm font-medium">Días Presente</CardTitle>
  318. <CheckCircle className="h-4 w-4 text-green-600" />
  319. </CardHeader>
  320. <CardContent>
  321. <div className="text-2xl font-bold text-green-600">{data.statistics.overall.present}</div>
  322. <p className="text-xs text-muted-foreground">
  323. {Math.round((data.statistics.overall.present / data.statistics.overall.totalRecords) * 100)}% del total
  324. </p>
  325. </CardContent>
  326. </Card>
  327. <Card>
  328. <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
  329. <CardTitle className="text-sm font-medium">Días Ausente</CardTitle>
  330. <XCircle className="h-4 w-4 text-red-600" />
  331. </CardHeader>
  332. <CardContent>
  333. <div className="text-2xl font-bold text-red-600">{data.statistics.overall.absent}</div>
  334. <p className="text-xs text-muted-foreground">
  335. {data.statistics.overall.justified} justificados
  336. </p>
  337. </CardContent>
  338. </Card>
  339. </div>
  340. {/* Filtros */}
  341. <Card>
  342. <CardHeader>
  343. <CardTitle className="flex items-center gap-2">
  344. <Filter className="h-5 w-5" />
  345. Filtros
  346. </CardTitle>
  347. </CardHeader>
  348. <CardContent>
  349. <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
  350. <div className="space-y-2">
  351. <Label htmlFor="search">Buscar</Label>
  352. <div className="relative">
  353. <Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
  354. <Input
  355. id="search"
  356. placeholder="Buscar..."
  357. value={searchTerm}
  358. onChange={(e) => setSearchTerm(e.target.value)}
  359. className="pl-10"
  360. />
  361. </div>
  362. </div>
  363. <div className="space-y-2">
  364. <Label htmlFor="section">Sección</Label>
  365. <Select value={selectedSection} onValueChange={setSelectedSection}>
  366. <SelectTrigger>
  367. <SelectValue placeholder="Todas las secciones" />
  368. </SelectTrigger>
  369. <SelectContent>
  370. <SelectItem value="all">Todas las secciones</SelectItem>
  371. {data.filters.sections.map((section) => (
  372. <SelectItem key={section.id} value={section.id}>
  373. {section.className} - {section.name}
  374. </SelectItem>
  375. ))}
  376. </SelectContent>
  377. </Select>
  378. </div>
  379. <div className="space-y-2">
  380. <Label htmlFor="period">Período</Label>
  381. <Select value={selectedPeriod} onValueChange={setSelectedPeriod}>
  382. <SelectTrigger>
  383. <SelectValue placeholder="Todos los períodos" />
  384. </SelectTrigger>
  385. <SelectContent>
  386. <SelectItem value="all">Todos los períodos</SelectItem>
  387. {data.filters.periods.map((period) => (
  388. <SelectItem key={period.id} value={period.id}>
  389. {period.name} {period.isActive && '(Activo)'}
  390. </SelectItem>
  391. ))}
  392. </SelectContent>
  393. </Select>
  394. </div>
  395. <div className="space-y-2">
  396. <Label htmlFor="status">Estado</Label>
  397. <Select value={selectedStatus} onValueChange={setSelectedStatus}>
  398. <SelectTrigger>
  399. <SelectValue placeholder="Todos los estados" />
  400. </SelectTrigger>
  401. <SelectContent>
  402. <SelectItem value="all">Todos los estados</SelectItem>
  403. <SelectItem value="PRESENT">Presente</SelectItem>
  404. <SelectItem value="ABSENT">Ausente</SelectItem>
  405. <SelectItem value="JUSTIFIED">Justificado</SelectItem>
  406. </SelectContent>
  407. </Select>
  408. </div>
  409. <div className="space-y-2">
  410. <Label htmlFor="startDate">Fecha Inicio</Label>
  411. <Input
  412. id="startDate"
  413. type="date"
  414. value={startDate}
  415. onChange={(e) => setStartDate(e.target.value)}
  416. />
  417. </div>
  418. <div className="space-y-2">
  419. <Label htmlFor="endDate">Fecha Fin</Label>
  420. <Input
  421. id="endDate"
  422. type="date"
  423. value={endDate}
  424. onChange={(e) => setEndDate(e.target.value)}
  425. />
  426. </div>
  427. </div>
  428. <div className="flex justify-end mt-4">
  429. <Button variant="outline" onClick={clearFilters}>
  430. Limpiar filtros
  431. </Button>
  432. </div>
  433. </CardContent>
  434. </Card>
  435. {/* Contenido con Tabs */}
  436. <Tabs defaultValue="records" className="space-y-4">
  437. <TabsList>
  438. <TabsTrigger value="records" className="flex items-center gap-2">
  439. <FileText className="h-4 w-4" />
  440. Registros ({filteredRecords.length})
  441. </TabsTrigger>
  442. <TabsTrigger value="sections" className="flex items-center gap-2">
  443. <BookOpen className="h-4 w-4" />
  444. Por Sección ({data.statistics.bySections.length})
  445. </TabsTrigger>
  446. <TabsTrigger value="periods" className="flex items-center gap-2">
  447. <BarChart3 className="h-4 w-4" />
  448. Por Período ({data.statistics.byPeriods.length})
  449. </TabsTrigger>
  450. </TabsList>
  451. {/* Tab de Registros */}
  452. <TabsContent value="records" className="space-y-4">
  453. {filteredRecords.length > 0 ? (
  454. <Card>
  455. <CardContent className="p-0">
  456. <Table>
  457. <TableHeader>
  458. <TableRow>
  459. <TableHead
  460. className="cursor-pointer hover:bg-muted/50 select-none"
  461. onClick={() => handleSort('date')}
  462. >
  463. <div className="flex items-center gap-2">
  464. Fecha
  465. <ArrowUpDown className="h-4 w-4" />
  466. </div>
  467. </TableHead>
  468. <TableHead
  469. className="cursor-pointer hover:bg-muted/50 select-none"
  470. onClick={() => handleSort('class')}
  471. >
  472. <div className="flex items-center gap-2">
  473. Clase
  474. <ArrowUpDown className="h-4 w-4" />
  475. </div>
  476. </TableHead>
  477. <TableHead>Sección</TableHead>
  478. <TableHead>Profesor</TableHead>
  479. <TableHead
  480. className="cursor-pointer hover:bg-muted/50 select-none"
  481. onClick={() => handleSort('status')}
  482. >
  483. <div className="flex items-center gap-2">
  484. Estado
  485. <ArrowUpDown className="h-4 w-4" />
  486. </div>
  487. </TableHead>
  488. <TableHead>Observación</TableHead>
  489. </TableRow>
  490. </TableHeader>
  491. <TableBody>
  492. {filteredRecords.map((record) => (
  493. <TableRow key={record.id} className="hover:bg-muted/50">
  494. <TableCell className="font-medium">
  495. <div className="space-y-1">
  496. <div>{formatShortDate(record.date)}</div>
  497. <div className="text-xs text-muted-foreground">
  498. {new Date(record.date).toLocaleDateString('es-ES', { weekday: 'short' })}
  499. </div>
  500. </div>
  501. </TableCell>
  502. <TableCell>
  503. <div className="space-y-1">
  504. <div className="font-medium">{record.section.class.name}</div>
  505. <div className="text-sm text-muted-foreground">
  506. {record.section.class.code} • {record.section.class.period.name}
  507. </div>
  508. </div>
  509. </TableCell>
  510. <TableCell>
  511. <Badge variant="outline">{record.section.name}</Badge>
  512. </TableCell>
  513. <TableCell>
  514. <div className="text-sm">
  515. {getTeacherNames(record.section.teachers) || 'Sin asignar'}
  516. </div>
  517. </TableCell>
  518. <TableCell>
  519. <div className={`flex items-center gap-2 ${getStatusColor(record.status)}`}>
  520. {getStatusIcon(record.status)}
  521. <Badge variant={getStatusBadgeVariant(record.status)}>
  522. {getStatusText(record.status)}
  523. </Badge>
  524. </div>
  525. </TableCell>
  526. <TableCell>
  527. {record.reason ? (
  528. <div className="max-w-xs">
  529. <div className="text-sm truncate" title={record.reason}>
  530. {record.reason}
  531. </div>
  532. </div>
  533. ) : (
  534. <span className="text-muted-foreground text-sm">-</span>
  535. )}
  536. </TableCell>
  537. </TableRow>
  538. ))}
  539. </TableBody>
  540. </Table>
  541. </CardContent>
  542. </Card>
  543. ) : (
  544. <Card>
  545. <CardContent className="flex flex-col items-center justify-center py-12">
  546. <Calendar className="h-12 w-12 text-muted-foreground mb-4" />
  547. <h3 className="text-lg font-medium mb-2">No se encontraron registros</h3>
  548. <p className="text-muted-foreground text-center">
  549. No hay registros de asistencia que coincidan con los filtros aplicados.
  550. </p>
  551. </CardContent>
  552. </Card>
  553. )}
  554. </TabsContent>
  555. {/* Tab de Estadísticas por Sección */}
  556. <TabsContent value="sections" className="space-y-4">
  557. <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
  558. {data.statistics.bySections.map((section) => (
  559. <Card key={section.sectionId}>
  560. <CardHeader>
  561. <CardTitle className="text-lg">{section.className}</CardTitle>
  562. <CardDescription>
  563. {section.sectionName} • {section.periodName}
  564. </CardDescription>
  565. </CardHeader>
  566. <CardContent className="space-y-4">
  567. <div className="flex justify-between items-center">
  568. <span className="text-sm font-medium">Asistencia</span>
  569. <Badge variant={section.attendanceRate >= 90 ? 'default' : section.attendanceRate >= 75 ? 'secondary' : 'destructive'}>
  570. {section.attendanceRate}%
  571. </Badge>
  572. </div>
  573. <Progress value={section.attendanceRate} className="h-2" />
  574. <div className="grid grid-cols-4 gap-2 text-xs">
  575. <div className="text-center">
  576. <div className="flex items-center justify-center gap-1">
  577. <CheckCircle className="h-3 w-3 text-green-600" />
  578. <span className="font-medium">{section.present}</span>
  579. </div>
  580. <div className="text-muted-foreground">Presente</div>
  581. </div>
  582. <div className="text-center">
  583. <div className="flex items-center justify-center gap-1">
  584. <XCircle className="h-3 w-3 text-red-600" />
  585. <span className="font-medium">{section.absent}</span>
  586. </div>
  587. <div className="text-muted-foreground">Ausente</div>
  588. </div>
  589. <div className="text-center">
  590. <div className="flex items-center justify-center gap-1">
  591. <AlertCircle className="h-3 w-3 text-yellow-600" />
  592. <span className="font-medium">{section.justified}</span>
  593. </div>
  594. <div className="text-muted-foreground">Justificado</div>
  595. </div>
  596. <div className="text-center">
  597. <div className="flex items-center justify-center gap-1">
  598. <Calendar className="h-3 w-3 text-blue-600" />
  599. <span className="font-medium">{section.total}</span>
  600. </div>
  601. <div className="text-muted-foreground">Total</div>
  602. </div>
  603. </div>
  604. </CardContent>
  605. </Card>
  606. ))}
  607. </div>
  608. </TabsContent>
  609. {/* Tab de Estadísticas por Período */}
  610. <TabsContent value="periods" className="space-y-4">
  611. <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
  612. {data.statistics.byPeriods.map((period) => (
  613. <Card key={period.periodId}>
  614. <CardHeader>
  615. <div className="flex justify-between items-start">
  616. <div>
  617. <CardTitle className="text-lg">{period.periodName}</CardTitle>
  618. <CardDescription>
  619. {period.total} registros de asistencia
  620. </CardDescription>
  621. </div>
  622. <Badge variant={period.isActive ? 'default' : 'secondary'}>
  623. {period.isActive ? 'Activo' : 'Inactivo'}
  624. </Badge>
  625. </div>
  626. </CardHeader>
  627. <CardContent className="space-y-4">
  628. <div className="flex justify-between items-center">
  629. <span className="text-sm font-medium">Asistencia</span>
  630. <Badge variant={period.attendanceRate >= 90 ? 'default' : period.attendanceRate >= 75 ? 'secondary' : 'destructive'}>
  631. {period.attendanceRate}%
  632. </Badge>
  633. </div>
  634. <Progress value={period.attendanceRate} className="h-2" />
  635. <div className="grid grid-cols-4 gap-2 text-xs">
  636. <div className="text-center">
  637. <div className="flex items-center justify-center gap-1">
  638. <CheckCircle className="h-3 w-3 text-green-600" />
  639. <span className="font-medium">{period.present}</span>
  640. </div>
  641. <div className="text-muted-foreground">Presente</div>
  642. </div>
  643. <div className="text-center">
  644. <div className="flex items-center justify-center gap-1">
  645. <XCircle className="h-3 w-3 text-red-600" />
  646. <span className="font-medium">{period.absent}</span>
  647. </div>
  648. <div className="text-muted-foreground">Ausente</div>
  649. </div>
  650. <div className="text-center">
  651. <div className="flex items-center justify-center gap-1">
  652. <AlertCircle className="h-3 w-3 text-yellow-600" />
  653. <span className="font-medium">{period.justified}</span>
  654. </div>
  655. <div className="text-muted-foreground">Justificado</div>
  656. </div>
  657. <div className="text-center">
  658. <div className="flex items-center justify-center gap-1">
  659. <Calendar className="h-3 w-3 text-blue-600" />
  660. <span className="font-medium">{period.total}</span>
  661. </div>
  662. <div className="text-muted-foreground">Total</div>
  663. </div>
  664. </div>
  665. </CardContent>
  666. </Card>
  667. ))}
  668. </div>
  669. </TabsContent>
  670. </Tabs>
  671. </div>
  672. </MainLayout>
  673. );
  674. }