|
|
@@ -0,0 +1,504 @@
|
|
|
+'use client'
|
|
|
+
|
|
|
+import { useState, useEffect } from 'react'
|
|
|
+import { DashboardLayout } from '@/components/dashboard-layout'
|
|
|
+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 {
|
|
|
+ Card,
|
|
|
+ CardContent,
|
|
|
+ CardDescription,
|
|
|
+ CardHeader,
|
|
|
+ CardTitle,
|
|
|
+} from '@/components/ui/card'
|
|
|
+import { Badge } from '@/components/ui/badge'
|
|
|
+import { Search, User, Calendar, BookOpen, Users } from 'lucide-react'
|
|
|
+import { format } from 'date-fns'
|
|
|
+import { es } from 'date-fns/locale'
|
|
|
+
|
|
|
+interface Student {
|
|
|
+ id: string
|
|
|
+ firstName: string
|
|
|
+ lastName: string
|
|
|
+ email: string
|
|
|
+ admissionNumber: string
|
|
|
+}
|
|
|
+
|
|
|
+interface EnrolledClass {
|
|
|
+ classId: string
|
|
|
+ className: string
|
|
|
+ classCode: string
|
|
|
+ sectionId: string
|
|
|
+ sectionName: string
|
|
|
+}
|
|
|
+
|
|
|
+interface AttendanceRecord {
|
|
|
+ id: string
|
|
|
+ date: string
|
|
|
+ status: 'present' | 'absent' | 'late'
|
|
|
+ reason?: string
|
|
|
+ partial?: {
|
|
|
+ id: string
|
|
|
+ name: string
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+interface AttendanceByClass {
|
|
|
+ classInfo: {
|
|
|
+ id: string
|
|
|
+ name: string
|
|
|
+ code: string
|
|
|
+ }
|
|
|
+ sectionInfo: {
|
|
|
+ id: string
|
|
|
+ name: string
|
|
|
+ }
|
|
|
+ records: AttendanceRecord[]
|
|
|
+ summary: {
|
|
|
+ total: number
|
|
|
+ present: number
|
|
|
+ absent: number
|
|
|
+ late: number
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+interface StudentAttendanceData {
|
|
|
+ student: Student
|
|
|
+ enrolledClasses: EnrolledClass[]
|
|
|
+ attendanceByClass: AttendanceByClass[]
|
|
|
+ overallSummary: {
|
|
|
+ total: number
|
|
|
+ present: number
|
|
|
+ absent: number
|
|
|
+ late: number
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export default function StudentAttendancePage() {
|
|
|
+ const [studentSearch, setStudentSearch] = useState('')
|
|
|
+ const [searchResults, setSearchResults] = useState<Student[]>([])
|
|
|
+ const [selectedStudent, setSelectedStudent] = useState<Student | null>(null)
|
|
|
+ const [attendanceData, setAttendanceData] = useState<StudentAttendanceData | null>(null)
|
|
|
+ const [startDate, setStartDate] = useState('')
|
|
|
+ const [endDate, setEndDate] = useState('')
|
|
|
+ const [selectedClass, setSelectedClass] = useState('')
|
|
|
+ const [selectedSection, setSelectedSection] = useState('')
|
|
|
+ const [loading, setLoading] = useState(false)
|
|
|
+ const [searching, setSearching] = useState(false)
|
|
|
+
|
|
|
+ const breadcrumbs = [
|
|
|
+ { label: 'Dashboard', href: '/teacher' },
|
|
|
+ { label: 'Asistencia por Estudiante', href: '/teacher/student-attendance' },
|
|
|
+ ]
|
|
|
+
|
|
|
+ // Search for students
|
|
|
+ const searchStudents = async () => {
|
|
|
+ if (!studentSearch.trim()) {
|
|
|
+ setSearchResults([])
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ setSearching(true)
|
|
|
+ try {
|
|
|
+ const response = await fetch(
|
|
|
+ `/api/teacher/student-attendance?studentSearch=${encodeURIComponent(studentSearch)}`
|
|
|
+ )
|
|
|
+ if (response.ok) {
|
|
|
+ const data = await response.json()
|
|
|
+ setSearchResults(data.students || [])
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Error searching students:', error)
|
|
|
+ } finally {
|
|
|
+ setSearching(false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Load student attendance data
|
|
|
+ const loadStudentAttendance = async () => {
|
|
|
+ if (!selectedStudent) return
|
|
|
+
|
|
|
+ setLoading(true)
|
|
|
+ try {
|
|
|
+ const params = new URLSearchParams({
|
|
|
+ studentId: selectedStudent.id,
|
|
|
+ })
|
|
|
+
|
|
|
+ if (startDate) params.append('startDate', startDate)
|
|
|
+ if (endDate) params.append('endDate', endDate)
|
|
|
+ if (selectedClass && selectedClass !== 'all') params.append('classId', selectedClass)
|
|
|
+ if (selectedSection && selectedSection !== 'all') params.append('sectionId', selectedSection)
|
|
|
+
|
|
|
+ const response = await fetch(`/api/teacher/student-attendance?${params}`)
|
|
|
+ if (response.ok) {
|
|
|
+ const data = await response.json()
|
|
|
+ setAttendanceData(data)
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Error loading student attendance:', error)
|
|
|
+ } finally {
|
|
|
+ setLoading(false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Load attendance data when student or filters change
|
|
|
+ useEffect(() => {
|
|
|
+ if (selectedStudent) {
|
|
|
+ loadStudentAttendance()
|
|
|
+ }
|
|
|
+ }, [selectedStudent, startDate, endDate, selectedClass, selectedSection])
|
|
|
+
|
|
|
+ // Search students when search term changes
|
|
|
+ useEffect(() => {
|
|
|
+ const timeoutId = setTimeout(() => {
|
|
|
+ searchStudents()
|
|
|
+ }, 300)
|
|
|
+
|
|
|
+ return () => clearTimeout(timeoutId)
|
|
|
+ }, [studentSearch])
|
|
|
+
|
|
|
+ const getStatusBadge = (status: string) => {
|
|
|
+ switch (status) {
|
|
|
+ case 'present':
|
|
|
+ return <Badge className="bg-green-100 text-green-800">Presente</Badge>
|
|
|
+ case 'absent':
|
|
|
+ return <Badge className="bg-red-100 text-red-800">Ausente</Badge>
|
|
|
+ case 'late':
|
|
|
+ return <Badge className="bg-yellow-100 text-yellow-800">Tardanza</Badge>
|
|
|
+ default:
|
|
|
+ return <Badge variant="secondary">{status}</Badge>
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const getAttendancePercentage = (present: number, total: number) => {
|
|
|
+ if (total === 0) return 0
|
|
|
+ return Math.round((present / total) * 100)
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <DashboardLayout breadcrumbs={breadcrumbs}>
|
|
|
+ <div className="space-y-6">
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <div>
|
|
|
+ <h1 className="text-3xl font-bold tracking-tight">Asistencia por Estudiante</h1>
|
|
|
+ <p className="text-muted-foreground">
|
|
|
+ Consulta el historial detallado de asistencia de un estudiante específico
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Student Search */}
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle className="flex items-center gap-2">
|
|
|
+ <Search className="h-5 w-5" />
|
|
|
+ Buscar Estudiante
|
|
|
+ </CardTitle>
|
|
|
+ <CardDescription>
|
|
|
+ Busca un estudiante por nombre para ver su historial de asistencia
|
|
|
+ </CardDescription>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent className="space-y-4">
|
|
|
+ <div className="flex gap-4">
|
|
|
+ <div className="flex-1">
|
|
|
+ <Label htmlFor="student-search">Nombre del estudiante</Label>
|
|
|
+ <Input
|
|
|
+ id="student-search"
|
|
|
+ placeholder="Escribe el nombre del estudiante..."
|
|
|
+ value={studentSearch}
|
|
|
+ onChange={(e) => setStudentSearch(e.target.value)}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Search Results */}
|
|
|
+ {searching && (
|
|
|
+ <div className="text-sm text-muted-foreground">Buscando estudiantes...</div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {searchResults.length > 0 && (
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Label>Resultados de búsqueda:</Label>
|
|
|
+ <div className="grid gap-2 max-h-40 overflow-y-auto">
|
|
|
+ {searchResults.map((student) => (
|
|
|
+ <div
|
|
|
+ key={student.id}
|
|
|
+ className={`p-3 border rounded-lg cursor-pointer hover:bg-muted transition-colors ${
|
|
|
+ selectedStudent?.id === student.id ? 'bg-muted border-primary' : ''
|
|
|
+ }`}
|
|
|
+ onClick={() => {
|
|
|
+ setSelectedStudent(student)
|
|
|
+ setSearchResults([])
|
|
|
+ setStudentSearch(`${student.firstName} ${student.lastName}`)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <div>
|
|
|
+ <div className="font-medium">
|
|
|
+ {student.firstName} {student.lastName}
|
|
|
+ </div>
|
|
|
+ <div className="text-sm text-muted-foreground">{student.email}</div>
|
|
|
+ </div>
|
|
|
+ <div className="text-sm text-muted-foreground">
|
|
|
+ {student.admissionNumber}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ {/* Filters */}
|
|
|
+ {selectedStudent && attendanceData && (
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle className="flex items-center gap-2">
|
|
|
+ <User className="h-5 w-5" />
|
|
|
+ {selectedStudent.firstName} {selectedStudent.lastName}
|
|
|
+ </CardTitle>
|
|
|
+ <CardDescription>
|
|
|
+ {selectedStudent.email} • {selectedStudent.admissionNumber}
|
|
|
+ </CardDescription>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent className="space-y-4">
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
|
+ <div>
|
|
|
+ <Label htmlFor="start-date">Fecha inicio</Label>
|
|
|
+ <Input
|
|
|
+ id="start-date"
|
|
|
+ type="date"
|
|
|
+ value={startDate}
|
|
|
+ onChange={(e) => setStartDate(e.target.value)}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <Label htmlFor="end-date">Fecha fin</Label>
|
|
|
+ <Input
|
|
|
+ id="end-date"
|
|
|
+ type="date"
|
|
|
+ value={endDate}
|
|
|
+ onChange={(e) => setEndDate(e.target.value)}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <Label htmlFor="class-filter">Clase</Label>
|
|
|
+ <Select value={selectedClass} onValueChange={setSelectedClass}>
|
|
|
+ <SelectTrigger>
|
|
|
+ <SelectValue placeholder="Todas las clases" />
|
|
|
+ </SelectTrigger>
|
|
|
+ <SelectContent>
|
|
|
+ <SelectItem value="all">Todas las clases</SelectItem>
|
|
|
+ {attendanceData.enrolledClasses.map((cls) => (
|
|
|
+ <SelectItem key={cls.classId} value={cls.classId}>
|
|
|
+ {cls.className} ({cls.classCode})
|
|
|
+ </SelectItem>
|
|
|
+ ))}
|
|
|
+ </SelectContent>
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <Label htmlFor="section-filter">Sección</Label>
|
|
|
+ <Select value={selectedSection} onValueChange={setSelectedSection}>
|
|
|
+ <SelectTrigger>
|
|
|
+ <SelectValue placeholder="Todas las secciones" />
|
|
|
+ </SelectTrigger>
|
|
|
+ <SelectContent>
|
|
|
+ <SelectItem value="all">Todas las secciones</SelectItem>
|
|
|
+ {attendanceData.enrolledClasses
|
|
|
+ .filter((cls) => !selectedClass || selectedClass === 'all' || cls.classId === selectedClass)
|
|
|
+ .map((cls) => (
|
|
|
+ <SelectItem key={cls.sectionId} value={cls.sectionId}>
|
|
|
+ {cls.className} - {cls.sectionName}
|
|
|
+ </SelectItem>
|
|
|
+ ))}
|
|
|
+ </SelectContent>
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Overall Summary */}
|
|
|
+ {attendanceData && (
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
|
+ <Card>
|
|
|
+ <CardContent className="p-6">
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <div>
|
|
|
+ <p className="text-sm font-medium text-muted-foreground">Total Registros</p>
|
|
|
+ <p className="text-2xl font-bold">{attendanceData.overallSummary.total}</p>
|
|
|
+ </div>
|
|
|
+ <Calendar className="h-8 w-8 text-muted-foreground" />
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ <Card>
|
|
|
+ <CardContent className="p-6">
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <div>
|
|
|
+ <p className="text-sm font-medium text-muted-foreground">Presente</p>
|
|
|
+ <p className="text-2xl font-bold text-green-600">
|
|
|
+ {attendanceData.overallSummary.present}
|
|
|
+ </p>
|
|
|
+ <p className="text-xs text-muted-foreground">
|
|
|
+ {getAttendancePercentage(
|
|
|
+ attendanceData.overallSummary.present,
|
|
|
+ attendanceData.overallSummary.total
|
|
|
+ )}%
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ <Card>
|
|
|
+ <CardContent className="p-6">
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <div>
|
|
|
+ <p className="text-sm font-medium text-muted-foreground">Tardanzas</p>
|
|
|
+ <p className="text-2xl font-bold text-yellow-600">
|
|
|
+ {attendanceData.overallSummary.late}
|
|
|
+ </p>
|
|
|
+ <p className="text-xs text-muted-foreground">
|
|
|
+ {getAttendancePercentage(
|
|
|
+ attendanceData.overallSummary.late,
|
|
|
+ attendanceData.overallSummary.total
|
|
|
+ )}%
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ <Card>
|
|
|
+ <CardContent className="p-6">
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <div>
|
|
|
+ <p className="text-sm font-medium text-muted-foreground">Ausente</p>
|
|
|
+ <p className="text-2xl font-bold text-red-600">
|
|
|
+ {attendanceData.overallSummary.absent}
|
|
|
+ </p>
|
|
|
+ <p className="text-xs text-muted-foreground">
|
|
|
+ {getAttendancePercentage(
|
|
|
+ attendanceData.overallSummary.absent,
|
|
|
+ attendanceData.overallSummary.total
|
|
|
+ )}%
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Attendance by Class */}
|
|
|
+ {loading && (
|
|
|
+ <Card>
|
|
|
+ <CardContent className="p-6">
|
|
|
+ <div className="text-center text-muted-foreground">Cargando historial de asistencia...</div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {attendanceData && attendanceData.attendanceByClass.length > 0 && (
|
|
|
+ <div className="space-y-6">
|
|
|
+ <h2 className="text-2xl font-bold">Historial por Clase</h2>
|
|
|
+ {attendanceData.attendanceByClass.map((classData) => (
|
|
|
+ <Card key={`${classData.classInfo.id}-${classData.sectionInfo.id}`}>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle className="flex items-center gap-2">
|
|
|
+ <BookOpen className="h-5 w-5" />
|
|
|
+ {classData.classInfo.name} ({classData.classInfo.code})
|
|
|
+ </CardTitle>
|
|
|
+ <CardDescription className="flex items-center gap-2">
|
|
|
+ <Users className="h-4 w-4" />
|
|
|
+ Sección: {classData.sectionInfo.name}
|
|
|
+ </CardDescription>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent className="space-y-4">
|
|
|
+ {/* Class Summary */}
|
|
|
+ <div className="grid grid-cols-4 gap-4 p-4 bg-muted rounded-lg">
|
|
|
+ <div className="text-center">
|
|
|
+ <div className="text-2xl font-bold">{classData.summary.total}</div>
|
|
|
+ <div className="text-sm text-muted-foreground">Total</div>
|
|
|
+ </div>
|
|
|
+ <div className="text-center">
|
|
|
+ <div className="text-2xl font-bold text-green-600">{classData.summary.present}</div>
|
|
|
+ <div className="text-sm text-muted-foreground">
|
|
|
+ Presente ({getAttendancePercentage(classData.summary.present, classData.summary.total)}%)
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="text-center">
|
|
|
+ <div className="text-2xl font-bold text-yellow-600">{classData.summary.late}</div>
|
|
|
+ <div className="text-sm text-muted-foreground">
|
|
|
+ Tardanza ({getAttendancePercentage(classData.summary.late, classData.summary.total)}%)
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="text-center">
|
|
|
+ <div className="text-2xl font-bold text-red-600">{classData.summary.absent}</div>
|
|
|
+ <div className="text-sm text-muted-foreground">
|
|
|
+ Ausente ({getAttendancePercentage(classData.summary.absent, classData.summary.total)}%)
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Attendance Records */}
|
|
|
+ <div className="space-y-2">
|
|
|
+ <h4 className="font-medium">Registros de Asistencia</h4>
|
|
|
+ <div className="max-h-60 overflow-y-auto space-y-2">
|
|
|
+ {classData.records.map((record) => (
|
|
|
+ <div
|
|
|
+ key={record.id}
|
|
|
+ className="flex items-center justify-between p-3 border rounded-lg"
|
|
|
+ >
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
+ <div>
|
|
|
+ <div className="font-medium">
|
|
|
+ {format(new Date(record.date), 'dd/MM/yyyy', { locale: es })}
|
|
|
+ </div>
|
|
|
+ {record.partial && (
|
|
|
+ <div className="text-sm text-muted-foreground">
|
|
|
+ {record.partial.name}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ {getStatusBadge(record.status)}
|
|
|
+ {record.reason && (
|
|
|
+ <span className="text-sm text-muted-foreground">({record.reason})</span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {attendanceData && attendanceData.attendanceByClass.length === 0 && (
|
|
|
+ <Card>
|
|
|
+ <CardContent className="p-6">
|
|
|
+ <div className="text-center text-muted-foreground">
|
|
|
+ No se encontraron registros de asistencia para los filtros seleccionados.
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </DashboardLayout>
|
|
|
+ )
|
|
|
+}
|