page.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  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 {
  12. BookOpen,
  13. Users,
  14. Calendar,
  15. Clock,
  16. TrendingUp,
  17. Search,
  18. Filter,
  19. CheckCircle,
  20. XCircle,
  21. AlertCircle,
  22. User,
  23. GraduationCap
  24. } from 'lucide-react';
  25. import { toast } from 'sonner';
  26. import { LoadingSpinner } from '@/components/ui/spinner';
  27. interface Teacher {
  28. id: string;
  29. firstName: string;
  30. lastName: string;
  31. email: string;
  32. }
  33. interface Period {
  34. id: string;
  35. name: string;
  36. startDate: string;
  37. endDate: string;
  38. isActive: boolean;
  39. }
  40. interface Class {
  41. id: string;
  42. name: string;
  43. code: string;
  44. description: string | null;
  45. period: Period;
  46. }
  47. interface Section {
  48. id: string;
  49. name: string;
  50. studentCount: number;
  51. class: Class;
  52. teachers: Teacher[];
  53. }
  54. interface AttendanceStats {
  55. totalRecords: number;
  56. present: number;
  57. absent: number;
  58. justified: number;
  59. attendanceRate: number;
  60. }
  61. interface EnrolledClass {
  62. id: string;
  63. enrolledAt: string;
  64. section: Section;
  65. attendanceStats: AttendanceStats;
  66. }
  67. interface Student {
  68. id: string;
  69. firstName: string;
  70. lastName: string;
  71. admissionNumber: string;
  72. }
  73. interface ClassesResponse {
  74. classes: EnrolledClass[];
  75. student: Student;
  76. }
  77. export default function StudentClassesPage() {
  78. const [classes, setClasses] = useState<EnrolledClass[]>([]);
  79. const [student, setStudent] = useState<Student | null>(null);
  80. const [loading, setLoading] = useState(true);
  81. const [searchTerm, setSearchTerm] = useState('');
  82. const [selectedPeriod, setSelectedPeriod] = useState<string>('all');
  83. const [selectedStatus, setSelectedStatus] = useState<string>('all');
  84. useEffect(() => {
  85. fetchClasses();
  86. }, []);
  87. const fetchClasses = async () => {
  88. try {
  89. setLoading(true);
  90. const response = await fetch('/api/student/classes');
  91. if (!response.ok) {
  92. throw new Error('Error al cargar las clases');
  93. }
  94. const data: ClassesResponse = await response.json();
  95. setClasses(data.classes);
  96. setStudent(data.student);
  97. } catch (error) {
  98. console.error('Error:', error);
  99. toast.error('Error al cargar las clases matriculadas');
  100. } finally {
  101. setLoading(false);
  102. }
  103. };
  104. const filteredClasses = classes.filter(enrolledClass => {
  105. const matchesSearch =
  106. enrolledClass.section.class.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
  107. enrolledClass.section.class.code.toLowerCase().includes(searchTerm.toLowerCase()) ||
  108. enrolledClass.section.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
  109. enrolledClass.section.class.period.name.toLowerCase().includes(searchTerm.toLowerCase());
  110. const matchesPeriod = selectedPeriod === 'all' || enrolledClass.section.class.period.id === selectedPeriod;
  111. const matchesStatus = selectedStatus === 'all' ||
  112. (selectedStatus === 'active' && enrolledClass.section.class.period.isActive) ||
  113. (selectedStatus === 'inactive' && !enrolledClass.section.class.period.isActive);
  114. return matchesSearch && matchesPeriod && matchesStatus;
  115. });
  116. const periods = Array.from(new Set(classes.map(c => c.section.class.period)))
  117. .filter((period, index, self) => self.findIndex(p => p.id === period.id) === index);
  118. const activeClasses = filteredClasses.filter(c => c.section.class.period.isActive);
  119. const totalAttendanceRecords = filteredClasses.reduce((sum, c) => sum + c.attendanceStats.totalRecords, 0);
  120. const averageAttendanceRate = filteredClasses.length > 0
  121. ? Math.round(filteredClasses.reduce((sum, c) => sum + c.attendanceStats.attendanceRate, 0) / filteredClasses.length)
  122. : 0;
  123. const getAttendanceColor = (rate: number) => {
  124. if (rate >= 90) return 'text-green-600';
  125. if (rate >= 75) return 'text-yellow-600';
  126. return 'text-red-600';
  127. };
  128. const getAttendanceBadgeVariant = (rate: number): 'default' | 'secondary' | 'destructive' => {
  129. if (rate >= 90) return 'default';
  130. if (rate >= 75) return 'secondary';
  131. return 'destructive';
  132. };
  133. const formatDate = (dateString: string) => {
  134. return new Date(dateString).toLocaleDateString('es-ES', {
  135. year: 'numeric',
  136. month: 'long',
  137. day: 'numeric'
  138. });
  139. };
  140. const getTeacherNames = (teachers: Teacher[]) => {
  141. return teachers.map(teacher => `${teacher.firstName} ${teacher.lastName}`).join(', ');
  142. };
  143. if (loading) {
  144. return (
  145. <MainLayout requiredRole="STUDENT" title="Mis Clases">
  146. <div className="flex items-center justify-center h-64">
  147. <LoadingSpinner size="lg" />
  148. </div>
  149. </MainLayout>
  150. );
  151. }
  152. return (
  153. <MainLayout requiredRole="STUDENT" title="Mis Clases">
  154. <div className="space-y-6">
  155. {/* Header */}
  156. <div className="flex justify-between items-center">
  157. <div>
  158. <h1 className="text-3xl font-bold tracking-tight">Mis Clases Matriculadas</h1>
  159. <p className="text-muted-foreground">
  160. {student && `${student.firstName} ${student.lastName} - ${student.admissionNumber}`}
  161. </p>
  162. </div>
  163. </div>
  164. {/* Estadísticas Generales */}
  165. <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
  166. <Card>
  167. <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
  168. <CardTitle className="text-sm font-medium">Total de Clases</CardTitle>
  169. <BookOpen className="h-4 w-4 text-muted-foreground" />
  170. </CardHeader>
  171. <CardContent>
  172. <div className="text-2xl font-bold">{filteredClasses.length}</div>
  173. <p className="text-xs text-muted-foreground">
  174. {activeClasses.length} activas
  175. </p>
  176. </CardContent>
  177. </Card>
  178. <Card>
  179. <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
  180. <CardTitle className="text-sm font-medium">Registros de Asistencia</CardTitle>
  181. <Calendar className="h-4 w-4 text-muted-foreground" />
  182. </CardHeader>
  183. <CardContent>
  184. <div className="text-2xl font-bold">{totalAttendanceRecords}</div>
  185. <p className="text-xs text-muted-foreground">
  186. Total acumulado
  187. </p>
  188. </CardContent>
  189. </Card>
  190. <Card>
  191. <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
  192. <CardTitle className="text-sm font-medium">Asistencia Promedio</CardTitle>
  193. <TrendingUp className="h-4 w-4 text-muted-foreground" />
  194. </CardHeader>
  195. <CardContent>
  196. <div className={`text-2xl font-bold ${getAttendanceColor(averageAttendanceRate)}`}>
  197. {averageAttendanceRate}%
  198. </div>
  199. <p className="text-xs text-muted-foreground">
  200. Todas las clases
  201. </p>
  202. </CardContent>
  203. </Card>
  204. <Card>
  205. <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
  206. <CardTitle className="text-sm font-medium">Períodos Únicos</CardTitle>
  207. <Clock className="h-4 w-4 text-muted-foreground" />
  208. </CardHeader>
  209. <CardContent>
  210. <div className="text-2xl font-bold">{periods.length}</div>
  211. <p className="text-xs text-muted-foreground">
  212. Histórico
  213. </p>
  214. </CardContent>
  215. </Card>
  216. </div>
  217. {/* Filtros */}
  218. <Card>
  219. <CardHeader>
  220. <CardTitle className="flex items-center gap-2">
  221. <Filter className="h-5 w-5" />
  222. Filtros
  223. </CardTitle>
  224. </CardHeader>
  225. <CardContent>
  226. <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
  227. <div className="space-y-2">
  228. <Label htmlFor="search">Buscar</Label>
  229. <div className="relative">
  230. <Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
  231. <Input
  232. id="search"
  233. placeholder="Buscar por clase, código o sección..."
  234. value={searchTerm}
  235. onChange={(e) => setSearchTerm(e.target.value)}
  236. className="pl-10"
  237. />
  238. </div>
  239. </div>
  240. <div className="space-y-2">
  241. <Label htmlFor="period">Período</Label>
  242. <Select value={selectedPeriod} onValueChange={setSelectedPeriod}>
  243. <SelectTrigger>
  244. <SelectValue placeholder="Filtrar por período" />
  245. </SelectTrigger>
  246. <SelectContent>
  247. <SelectItem value="all">Todos los períodos</SelectItem>
  248. {periods.map((period) => (
  249. <SelectItem key={period.id} value={period.id}>
  250. {period.name} {period.isActive && '(Activo)'}
  251. </SelectItem>
  252. ))}
  253. </SelectContent>
  254. </Select>
  255. </div>
  256. <div className="space-y-2">
  257. <Label htmlFor="status">Estado</Label>
  258. <Select value={selectedStatus} onValueChange={setSelectedStatus}>
  259. <SelectTrigger>
  260. <SelectValue placeholder="Filtrar por estado" />
  261. </SelectTrigger>
  262. <SelectContent>
  263. <SelectItem value="all">Todos los estados</SelectItem>
  264. <SelectItem value="active">Períodos activos</SelectItem>
  265. <SelectItem value="inactive">Períodos inactivos</SelectItem>
  266. </SelectContent>
  267. </Select>
  268. </div>
  269. </div>
  270. </CardContent>
  271. </Card>
  272. {/* Lista de Clases */}
  273. <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
  274. {filteredClasses.length > 0 ? (
  275. filteredClasses.map((enrolledClass) => (
  276. <Card key={enrolledClass.id} className="hover:shadow-md transition-shadow">
  277. <CardHeader>
  278. <div className="flex justify-between items-start">
  279. <div className="space-y-1">
  280. <CardTitle className="flex items-center gap-2">
  281. <GraduationCap className="h-5 w-5" />
  282. {enrolledClass.section.class.name}
  283. </CardTitle>
  284. <CardDescription>
  285. {enrolledClass.section.class.code} - {enrolledClass.section.name}
  286. </CardDescription>
  287. </div>
  288. <Badge variant={enrolledClass.section.class.period.isActive ? 'default' : 'secondary'}>
  289. {enrolledClass.section.class.period.name}
  290. </Badge>
  291. </div>
  292. </CardHeader>
  293. <CardContent className="space-y-4">
  294. {/* Información de la clase */}
  295. <div className="space-y-2">
  296. {enrolledClass.section.class.description && (
  297. <p className="text-sm text-muted-foreground">
  298. {enrolledClass.section.class.description}
  299. </p>
  300. )}
  301. <div className="flex items-center gap-4 text-sm text-muted-foreground">
  302. <div className="flex items-center gap-1">
  303. <Users className="h-4 w-4" />
  304. {enrolledClass.section.studentCount} estudiantes
  305. </div>
  306. <div className="flex items-center gap-1">
  307. <User className="h-4 w-4" />
  308. {getTeacherNames(enrolledClass.section.teachers) || 'Sin profesor asignado'}
  309. </div>
  310. </div>
  311. <div className="text-sm text-muted-foreground">
  312. <strong>Matriculado:</strong> {formatDate(enrolledClass.enrolledAt)}
  313. </div>
  314. <div className="text-sm text-muted-foreground">
  315. <strong>Período:</strong> {formatDate(enrolledClass.section.class.period.startDate)} - {formatDate(enrolledClass.section.class.period.endDate)}
  316. </div>
  317. </div>
  318. {/* Estadísticas de Asistencia */}
  319. <div className="space-y-3">
  320. <div className="flex justify-between items-center">
  321. <h4 className="text-sm font-medium">Asistencia</h4>
  322. <Badge variant={getAttendanceBadgeVariant(enrolledClass.attendanceStats.attendanceRate)}>
  323. {enrolledClass.attendanceStats.attendanceRate}%
  324. </Badge>
  325. </div>
  326. <Progress
  327. value={enrolledClass.attendanceStats.attendanceRate}
  328. className="h-2"
  329. />
  330. <div className="grid grid-cols-4 gap-2 text-xs">
  331. <div className="text-center">
  332. <div className="flex items-center justify-center gap-1">
  333. <CheckCircle className="h-3 w-3 text-green-600" />
  334. <span className="font-medium">{enrolledClass.attendanceStats.present}</span>
  335. </div>
  336. <div className="text-muted-foreground">Presente</div>
  337. </div>
  338. <div className="text-center">
  339. <div className="flex items-center justify-center gap-1">
  340. <XCircle className="h-3 w-3 text-red-600" />
  341. <span className="font-medium">{enrolledClass.attendanceStats.absent}</span>
  342. </div>
  343. <div className="text-muted-foreground">Ausente</div>
  344. </div>
  345. <div className="text-center">
  346. <div className="flex items-center justify-center gap-1">
  347. <AlertCircle className="h-3 w-3 text-yellow-600" />
  348. <span className="font-medium">{enrolledClass.attendanceStats.justified}</span>
  349. </div>
  350. <div className="text-muted-foreground">Justificado</div>
  351. </div>
  352. <div className="text-center">
  353. <div className="flex items-center justify-center gap-1">
  354. <Calendar className="h-3 w-3 text-blue-600" />
  355. <span className="font-medium">{enrolledClass.attendanceStats.totalRecords}</span>
  356. </div>
  357. <div className="text-muted-foreground">Total</div>
  358. </div>
  359. </div>
  360. </div>
  361. </CardContent>
  362. </Card>
  363. ))
  364. ) : (
  365. <div className="col-span-full">
  366. <Card>
  367. <CardContent className="flex flex-col items-center justify-center py-12">
  368. <BookOpen className="h-12 w-12 text-muted-foreground mb-4" />
  369. <h3 className="text-lg font-medium mb-2">No se encontraron clases</h3>
  370. <p className="text-muted-foreground text-center">
  371. {searchTerm || selectedPeriod !== 'all' || selectedStatus !== 'all'
  372. ? 'No hay clases que coincidan con los filtros aplicados.'
  373. : 'No tienes clases matriculadas en este momento.'}
  374. </p>
  375. {(searchTerm || selectedPeriod !== 'all' || selectedStatus !== 'all') && (
  376. <Button
  377. variant="outline"
  378. className="mt-4"
  379. onClick={() => {
  380. setSearchTerm('');
  381. setSelectedPeriod('all');
  382. setSelectedStatus('all');
  383. }}
  384. >
  385. Limpiar filtros
  386. </Button>
  387. )}
  388. </CardContent>
  389. </Card>
  390. </div>
  391. )}
  392. </div>
  393. </div>
  394. </MainLayout>
  395. );
  396. }