page.tsx 28 KB

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