Browse Source

ok maybe we done here

Matthew Trejo 4 months ago
parent
commit
bf23576e0d

+ 9 - 0
.env.example

@@ -0,0 +1,9 @@
+# Database
+DATABASE_URL="postgresql://usuario:contraseña@localhost:5432/nimi_db"
+
+# NextAuth
+NEXTAUTH_URL="http://localhost:3000"
+NEXTAUTH_SECRET="your-secret-key-here-fr"
+
+# App
+NEXT_PUBLIC_APP_NAME="Sistema de Gestión de Asistencia"

+ 4 - 2
.gitignore

@@ -30,8 +30,10 @@ yarn-debug.log*
 yarn-error.log*
 .pnpm-debug.log*
 
-# env files (can opt-in for committing if needed)
-.env*
+# Ignora archivos .env
+.env
+# Pero no .env.example
+!.env.example
 
 # vercel
 .vercel

+ 11 - 0
package-lock.json

@@ -22,6 +22,7 @@
         "bcryptjs": "^3.0.2",
         "class-variance-authority": "^0.7.1",
         "clsx": "^2.1.1",
+        "date-fns": "^4.1.0",
         "drizzle-kit": "^0.31.4",
         "drizzle-orm": "^0.44.4",
         "lucide-react": "^0.539.0",
@@ -4297,6 +4298,16 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/date-fns": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+      "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/kossnocorp"
+      }
+    },
     "node_modules/debug": {
       "version": "4.4.1",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",

+ 1 - 0
package.json

@@ -27,6 +27,7 @@
     "bcryptjs": "^3.0.2",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
+    "date-fns": "^4.1.0",
     "drizzle-kit": "^0.31.4",
     "drizzle-orm": "^0.44.4",
     "lucide-react": "^0.539.0",

+ 148 - 0
src/app/api/teacher/attendance-history/route.ts

@@ -0,0 +1,148 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { 
+  teacherAssignments, 
+  sections, 
+  classes,
+  periods,
+  partials,
+  studentEnrollments, 
+  users, 
+  attendance, 
+  eq, 
+  and,
+  gte,
+  lte,
+  desc
+} from '@/lib/db/schema'
+
+// GET - Fetch attendance history by date range
+export async function GET(request: NextRequest) {
+  try {
+    const session = await getServerSession(authOptions)
+
+    if (!session || session.user.role !== 'teacher') {
+      return NextResponse.json(
+        { error: 'No autorizado' },
+        { status: 401 }
+      )
+    }
+
+    const { searchParams } = new URL(request.url)
+    const sectionId = searchParams.get('sectionId')
+    const startDate = searchParams.get('startDate')
+    const endDate = searchParams.get('endDate')
+
+    if (!sectionId) {
+      return NextResponse.json(
+        { error: 'Se requiere el ID de la sección' },
+        { status: 400 }
+      )
+    }
+
+    // Verify teacher has access to this section
+    const teacherAssignment = await db
+      .select()
+      .from(teacherAssignments)
+      .where(
+        and(
+          eq(teacherAssignments.teacherId, session.user.id),
+          eq(teacherAssignments.sectionId, sectionId)
+        )
+      )
+
+    if (teacherAssignment.length === 0) {
+      return NextResponse.json(
+        { error: 'No tienes acceso a esta sección' },
+        { status: 403 }
+      )
+    }
+
+    // Build date filter conditions
+    const dateConditions = [eq(attendance.sectionId, sectionId)]
+    
+    if (startDate) {
+      dateConditions.push(gte(attendance.date, startDate))
+    }
+    
+    if (endDate) {
+      dateConditions.push(lte(attendance.date, endDate))
+    }
+
+    // Get attendance records with student and partial information
+    const attendanceRecords = await db
+      .select({
+        id: attendance.id,
+        date: attendance.date,
+        status: attendance.status,
+        reason: attendance.reason,
+        studentId: users.id,
+        studentName: users.firstName,
+        studentLastName: users.lastName,
+        studentEmail: users.email,
+        partialId: partials.id,
+        partialName: partials.name
+      })
+      .from(attendance)
+      .innerJoin(users, eq(attendance.studentId, users.id))
+      .leftJoin(partials, eq(attendance.partialId, partials.id))
+      .where(and(...dateConditions))
+      .orderBy(desc(attendance.date), users.firstName, users.lastName)
+
+    // Group records by date
+    const groupedByDate = attendanceRecords.reduce((acc, record) => {
+      const dateKey = record.date
+      
+      if (!acc[dateKey]) {
+        acc[dateKey] = {
+          date: dateKey,
+          records: [],
+          summary: {
+            total: 0,
+            present: 0,
+            absent: 0,
+            late: 0
+          }
+        }
+      }
+      
+      acc[dateKey].records.push({
+        id: record.id,
+        student: {
+          id: record.studentId,
+          name: `${record.studentName} ${record.studentLastName}`,
+          email: record.studentEmail
+        },
+        status: record.status,
+        reason: record.reason,
+        partial: record.partialId ? {
+          id: record.partialId,
+          name: record.partialName
+        } : null
+      })
+      
+      // Update summary
+      acc[dateKey].summary.total++
+      if (record.status === 'present') acc[dateKey].summary.present++
+      else if (record.status === 'absent') acc[dateKey].summary.absent++
+      else if (record.status === 'late') acc[dateKey].summary.late++
+      
+      return acc
+    }, {} as Record<string, any>)
+
+    // Convert to array and sort by date (most recent first)
+    const historyByDate = Object.values(groupedByDate).sort((a: any, b: any) => 
+      new Date(b.date).getTime() - new Date(a.date).getTime()
+    )
+
+    return NextResponse.json(historyByDate)
+  } catch (error) {
+    console.error('Error fetching attendance history:', error)
+    return NextResponse.json(
+      { error: 'Error interno del servidor' },
+      { status: 500 }
+    )
+  }
+}

+ 133 - 0
src/app/api/teacher/export-attendance/route.ts

@@ -0,0 +1,133 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { attendance, users, sections, classes, studentEnrollments } from '@/lib/db/schema'
+import { eq, and, gte, lte, desc } from 'drizzle-orm'
+import { format } from 'date-fns'
+import { es } from 'date-fns/locale'
+
+export async function GET(request: NextRequest) {
+  try {
+    const session = await getServerSession(authOptions)
+    
+    if (!session?.user?.id) {
+      return NextResponse.json({ error: 'No autorizado' }, { status: 401 })
+    }
+
+    // Verificar que el usuario sea profesor
+    const user = await db.query.users.findFirst({
+      where: eq(users.id, session.user.id)
+    })
+
+    if (!user || user.role !== 'teacher') {
+      return NextResponse.json({ error: 'Acceso denegado' }, { status: 403 })
+    }
+
+    const { searchParams } = new URL(request.url)
+    const date = searchParams.get('date')
+    const sectionId = searchParams.get('sectionId')
+
+    if (!date) {
+      return NextResponse.json({ error: 'Fecha requerida' }, { status: 400 })
+    }
+
+    // Convertir fecha a strings para comparación (el campo date es string en la DB)
+    const dateStr = date // La fecha ya viene en formato YYYY-MM-DD
+
+    // Construir query base
+    let whereConditions = [
+      eq(attendance.date, dateStr)
+    ]
+
+    // Agregar filtro de sección si se proporciona
+    if (sectionId && sectionId !== 'all') {
+      whereConditions.push(eq(attendance.sectionId, sectionId))
+    }
+
+    // Obtener registros de asistencia con información relacionada
+    const attendanceData = await db
+      .select({
+        id: attendance.id,
+        date: attendance.date,
+        status: attendance.status,
+        reason: attendance.reason,
+        studentName: users.firstName,
+        studentLastName: users.lastName,
+        studentEmail: users.email,
+        sectionName: sections.name,
+        className: classes.name,
+        classCode: classes.code
+      })
+      .from(attendance)
+      .innerJoin(users, eq(attendance.studentId, users.id))
+      .innerJoin(sections, eq(attendance.sectionId, sections.id))
+      .innerJoin(classes, eq(sections.classId, classes.id))
+      .where(and(...whereConditions))
+      .orderBy(desc(attendance.date), users.firstName, users.lastName)
+
+    // Generar contenido CSV
+    const csvHeaders = [
+      'Fecha',
+      'Estudiante',
+      'Email',
+      'Clase',
+      'Código de Clase',
+      'Sección',
+      'Estado',
+      'Razón'
+    ]
+
+    const csvRows = attendanceData.map(record => [
+      format(new Date(record.date), 'dd/MM/yyyy', { locale: es }),
+      `${record.studentName || ''} ${record.studentLastName || ''}`.trim(),
+      record.studentEmail || '',
+      record.className || '',
+      record.classCode || '',
+      record.sectionName || '',
+      getStatusText(record.status),
+      record.reason || ''
+    ])
+
+    // Construir CSV
+    const csvContent = [
+      csvHeaders.join(','),
+      ...csvRows.map(row => row.map(field => `"${field.toString().replace(/"/g, '""')}"`).join(','))
+    ].join('\n')
+
+    // Generar nombre de archivo
+    const fileName = sectionId && sectionId !== 'all' 
+      ? `asistencia_${format(new Date(date), 'yyyy-MM-dd', { locale: es })}_seccion_${sectionId}.csv`
+      : `asistencia_${format(new Date(date), 'yyyy-MM-dd', { locale: es })}.csv`
+
+    // Retornar archivo CSV
+    return new NextResponse(csvContent, {
+      status: 200,
+      headers: {
+        'Content-Type': 'text/csv; charset=utf-8',
+        'Content-Disposition': `attachment; filename="${fileName}"`,
+        'Cache-Control': 'no-cache'
+      }
+    })
+
+  } catch (error) {
+    console.error('Error al exportar asistencia:', error)
+    return NextResponse.json(
+      { error: 'Error interno del servidor' },
+      { status: 500 }
+    )
+  }
+}
+
+function getStatusText(status: string): string {
+  switch (status) {
+    case 'present':
+      return 'Presente'
+    case 'absent':
+      return 'Ausente'
+    case 'late':
+      return 'Tardanza'
+    default:
+      return status
+  }
+}

+ 77 - 0
src/app/api/teacher/partials/route.ts

@@ -0,0 +1,77 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { 
+  teacherAssignments, 
+  sections, 
+  classes, 
+  periods, 
+  partials, 
+  eq, 
+  and 
+} from '@/lib/db/schema'
+
+// GET - Fetch partials for periods where teacher has assigned sections
+export async function GET(request: NextRequest) {
+  try {
+    const session = await getServerSession(authOptions)
+
+    if (!session || session.user.role !== 'teacher') {
+      return NextResponse.json(
+        { error: 'No autorizado' },
+        { status: 401 }
+      )
+    }
+
+    // Get teacher's assigned sections with their periods
+    const teacherSections = await db
+      .select({
+        periodId: classes.periodId
+      })
+      .from(teacherAssignments)
+      .innerJoin(sections, eq(teacherAssignments.sectionId, sections.id))
+      .innerJoin(classes, eq(sections.classId, classes.id))
+      .where(eq(teacherAssignments.teacherId, session.user.id))
+
+    if (teacherSections.length === 0) {
+      return NextResponse.json([])
+    }
+
+    // Get unique period IDs
+    const periodIds = [...new Set(teacherSections.map(s => s.periodId))]
+
+    // Get active partials for these periods
+    const activePartials = await db
+      .select({
+        id: partials.id,
+        name: partials.name,
+        periodId: partials.periodId,
+        startDate: partials.startDate,
+        endDate: partials.endDate,
+        isActive: partials.isActive,
+        periodName: periods.name
+      })
+      .from(partials)
+      .innerJoin(periods, eq(partials.periodId, periods.id))
+      .where(
+        and(
+          eq(partials.isActive, true),
+          eq(periods.isActive, true)
+        )
+      )
+
+    // Filter partials that belong to teacher's periods
+    const teacherPartials = activePartials.filter(partial => 
+      periodIds.includes(partial.periodId)
+    )
+
+    return NextResponse.json(teacherPartials)
+  } catch (error) {
+    console.error('Error fetching teacher partials:', error)
+    return NextResponse.json(
+      { error: 'Error interno del servidor' },
+      { status: 500 }
+    )
+  }
+}

+ 248 - 0
src/app/api/teacher/student-attendance/route.ts

@@ -0,0 +1,248 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { 
+  teacherAssignments, 
+  sections, 
+  classes,
+  periods,
+  partials,
+  studentEnrollments, 
+  users, 
+  attendance, 
+  eq, 
+  and,
+  gte,
+  lte,
+  desc,
+  like
+} from '@/lib/db/schema'
+
+// GET - Fetch attendance history for a specific student
+export async function GET(request: NextRequest) {
+  try {
+    const session = await getServerSession(authOptions)
+
+    if (!session || session.user.role !== 'teacher') {
+      return NextResponse.json(
+        { error: 'No autorizado' },
+        { status: 401 }
+      )
+    }
+
+    const { searchParams } = new URL(request.url)
+    const studentId = searchParams.get('studentId')
+    const studentSearch = searchParams.get('studentSearch')
+    const startDate = searchParams.get('startDate')
+    const endDate = searchParams.get('endDate')
+    const classId = searchParams.get('classId')
+    const sectionId = searchParams.get('sectionId')
+
+    // If searching for students by name/email
+    if (studentSearch && !studentId) {
+      const students = await db
+        .select({
+          id: users.id,
+          firstName: users.firstName,
+          lastName: users.lastName,
+          email: users.email,
+          admissionNumber: users.admissionNumber
+        })
+        .from(users)
+        .innerJoin(studentEnrollments, eq(users.id, studentEnrollments.studentId))
+        .innerJoin(teacherAssignments, and(
+          eq(studentEnrollments.classId, teacherAssignments.classId),
+          eq(studentEnrollments.sectionId, teacherAssignments.sectionId),
+          eq(teacherAssignments.teacherId, session.user.id)
+        ))
+        .where(
+          and(
+            eq(users.role, 'student'),
+            eq(users.isActive, true),
+            like(users.firstName, `%${studentSearch}%`)
+          )
+        )
+        .groupBy(users.id, users.firstName, users.lastName, users.email, users.admissionNumber)
+
+      return NextResponse.json({ students })
+    }
+
+    if (!studentId) {
+      return NextResponse.json(
+        { error: 'Se requiere el ID del estudiante o término de búsqueda' },
+        { status: 400 }
+      )
+    }
+
+    // Verify teacher has access to this student through any of their sections
+    const teacherStudentAccess = await db
+      .select()
+      .from(studentEnrollments)
+      .innerJoin(teacherAssignments, and(
+        eq(studentEnrollments.classId, teacherAssignments.classId),
+        eq(studentEnrollments.sectionId, teacherAssignments.sectionId),
+        eq(teacherAssignments.teacherId, session.user.id)
+      ))
+      .where(eq(studentEnrollments.studentId, studentId))
+
+    if (teacherStudentAccess.length === 0) {
+      return NextResponse.json(
+        { error: 'No tienes acceso a este estudiante' },
+        { status: 403 }
+      )
+    }
+
+    // Build filter conditions
+    const conditions = [eq(attendance.studentId, studentId)]
+    
+    if (startDate) {
+      conditions.push(gte(attendance.date, startDate))
+    }
+    
+    if (endDate) {
+      conditions.push(lte(attendance.date, endDate))
+    }
+
+    if (classId) {
+      conditions.push(eq(attendance.classId, classId))
+    }
+
+    if (sectionId) {
+      conditions.push(eq(attendance.sectionId, sectionId))
+    }
+
+    // Get student information
+    const studentInfo = await db
+      .select({
+        id: users.id,
+        firstName: users.firstName,
+        lastName: users.lastName,
+        email: users.email,
+        admissionNumber: users.admissionNumber
+      })
+      .from(users)
+      .where(eq(users.id, studentId))
+
+    if (studentInfo.length === 0) {
+      return NextResponse.json(
+        { error: 'Estudiante no encontrado' },
+        { status: 404 }
+      )
+    }
+
+    // Get attendance records with class and section information
+    const attendanceRecords = await db
+      .select({
+        id: attendance.id,
+        date: attendance.date,
+        status: attendance.status,
+        reason: attendance.reason,
+        className: classes.name,
+        classCode: classes.code,
+        sectionName: sections.name,
+        partialName: partials.name,
+        classId: classes.id,
+        sectionId: sections.id,
+        partialId: partials.id
+      })
+      .from(attendance)
+      .innerJoin(classes, eq(attendance.classId, classes.id))
+      .innerJoin(sections, eq(attendance.sectionId, sections.id))
+      .leftJoin(partials, eq(attendance.partialId, partials.id))
+      .where(and(...conditions))
+      .orderBy(desc(attendance.date), classes.name, sections.name)
+
+    // Get student's enrolled classes and sections (for filter options)
+    const enrolledClasses = await db
+      .select({
+        classId: classes.id,
+        className: classes.name,
+        classCode: classes.code,
+        sectionId: sections.id,
+        sectionName: sections.name
+      })
+      .from(studentEnrollments)
+      .innerJoin(classes, eq(studentEnrollments.classId, classes.id))
+      .innerJoin(sections, eq(studentEnrollments.sectionId, sections.id))
+      .innerJoin(teacherAssignments, and(
+        eq(studentEnrollments.classId, teacherAssignments.classId),
+        eq(studentEnrollments.sectionId, teacherAssignments.sectionId),
+        eq(teacherAssignments.teacherId, session.user.id)
+      ))
+      .where(
+        and(
+          eq(studentEnrollments.studentId, studentId),
+          eq(studentEnrollments.isActive, true)
+        )
+      )
+
+    // Group records by class and section
+    const groupedRecords = attendanceRecords.reduce((acc, record) => {
+      const key = `${record.classId}-${record.sectionId}`
+      
+      if (!acc[key]) {
+        acc[key] = {
+          classInfo: {
+            id: record.classId,
+            name: record.className,
+            code: record.classCode
+          },
+          sectionInfo: {
+            id: record.sectionId,
+            name: record.sectionName
+          },
+          records: [],
+          summary: {
+            total: 0,
+            present: 0,
+            absent: 0,
+            late: 0
+          }
+        }
+      }
+      
+      acc[key].records.push({
+        id: record.id,
+        date: record.date,
+        status: record.status,
+        reason: record.reason,
+        partial: record.partialId ? {
+          id: record.partialId,
+          name: record.partialName
+        } : null
+      })
+      
+      // Update summary
+      acc[key].summary.total++
+      if (record.status === 'present') acc[key].summary.present++
+      else if (record.status === 'absent') acc[key].summary.absent++
+      else if (record.status === 'late') acc[key].summary.late++
+      
+      return acc
+    }, {} as Record<string, any>)
+
+    const attendanceByClass = Object.values(groupedRecords)
+
+    // Calculate overall summary
+    const overallSummary = {
+      total: attendanceRecords.length,
+      present: attendanceRecords.filter(r => r.status === 'present').length,
+      absent: attendanceRecords.filter(r => r.status === 'absent').length,
+      late: attendanceRecords.filter(r => r.status === 'late').length
+    }
+
+    return NextResponse.json({
+      student: studentInfo[0],
+      enrolledClasses,
+      attendanceByClass,
+      overallSummary
+    })
+  } catch (error) {
+    console.error('Error fetching student attendance:', error)
+    return NextResponse.json(
+      { error: 'Error interno del servidor' },
+      { status: 500 }
+    )
+  }
+}

+ 331 - 0
src/app/teacher/attendance-history/page.tsx

@@ -0,0 +1,331 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { DashboardLayout } from '@/components/dashboard-layout'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+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 { Badge } from '@/components/ui/badge'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
+import { Calendar, Users, Clock, UserX, UserCheck } from 'lucide-react'
+import { toast } from 'sonner'
+
+interface Section {
+  id: string
+  name: string
+  className: string
+  periodName: string
+  studentCount: number
+  isActive: boolean
+}
+
+interface AttendanceRecord {
+  id: string
+  student: {
+    id: string
+    name: string
+    email: string
+  }
+  status: 'present' | 'absent' | 'late'
+  reason?: string
+  partial: {
+    id: string
+    name: string
+  } | null
+}
+
+interface DayHistory {
+  date: string
+  records: AttendanceRecord[]
+  summary: {
+    total: number
+    present: number
+    absent: number
+    late: number
+  }
+}
+
+export default function AttendanceHistoryPage() {
+  const [sections, setSections] = useState<Section[]>([])
+  const [history, setHistory] = useState<DayHistory[]>([])
+  const [selectedSection, setSelectedSection] = useState<string>('')
+  const [startDate, setStartDate] = useState<string>('')
+  const [endDate, setEndDate] = useState<string>('')
+  const [loading, setLoading] = useState(false)
+
+  useEffect(() => {
+    fetchSections()
+    // Set default date range (last 30 days)
+    const today = new Date()
+    const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000)
+    setEndDate(today.toISOString().split('T')[0])
+    setStartDate(thirtyDaysAgo.toISOString().split('T')[0])
+  }, [])
+
+  const fetchSections = async () => {
+    try {
+      const response = await fetch('/api/teacher/sections')
+      if (response.ok) {
+        const data = await response.json()
+        setSections(data.filter((s: Section) => s.isActive))
+      }
+    } catch (error) {
+      toast.error('Error al cargar las secciones')
+    }
+  }
+
+  const fetchHistory = async () => {
+    if (!selectedSection) {
+      toast.error('Selecciona una sección')
+      return
+    }
+
+    setLoading(true)
+    try {
+      const params = new URLSearchParams({
+        sectionId: selectedSection,
+        ...(startDate && { startDate }),
+        ...(endDate && { endDate })
+      })
+
+      const response = await fetch(`/api/teacher/attendance-history?${params}`)
+      if (response.ok) {
+        const data = await response.json()
+        setHistory(data)
+      } else {
+        toast.error('Error al cargar el historial')
+      }
+    } catch (error) {
+      toast.error('Error al cargar el historial')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const getStatusIcon = (status: string) => {
+    switch (status) {
+      case 'present':
+        return <UserCheck className="h-4 w-4 text-green-600" />
+      case 'late':
+        return <Clock className="h-4 w-4 text-yellow-600" />
+      case 'absent':
+        return <UserX className="h-4 w-4 text-red-600" />
+      default:
+        return null
+    }
+  }
+
+  const getStatusBadge = (status: string) => {
+    switch (status) {
+      case 'present':
+        return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">Presente</Badge>
+      case 'late':
+        return <Badge className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100">Tardanza</Badge>
+      case 'absent':
+        return <Badge className="bg-red-100 text-red-800 hover:bg-red-100">Ausente</Badge>
+      default:
+        return null
+    }
+  }
+
+  const formatDate = (dateString: string) => {
+    return new Date(dateString).toLocaleDateString('es-ES', {
+      weekday: 'long',
+      year: 'numeric',
+      month: 'long',
+      day: 'numeric'
+    })
+  }
+
+  const breadcrumbs = [
+    { label: 'Dashboard', href: '/teacher' },
+    { label: 'Historial de Asistencia', href: '/teacher/attendance-history' }
+  ]
+
+  return (
+    <DashboardLayout breadcrumbs={breadcrumbs}>
+      <div className="container mx-auto p-6">
+        <div className="flex items-center gap-2 mb-6">
+          <Calendar className="h-6 w-6" />
+          <h1 className="text-2xl font-bold">Historial de Asistencia</h1>
+        </div>
+
+        {/* Filtros */}
+        <Card className="mb-6">
+          <CardHeader>
+            <CardTitle>Filtros</CardTitle>
+          </CardHeader>
+          <CardContent>
+            <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
+              <div>
+                <Label htmlFor="section">Sección</Label>
+                <Select value={selectedSection} onValueChange={setSelectedSection}>
+                  <SelectTrigger>
+                    <SelectValue placeholder="Seleccionar sección" />
+                  </SelectTrigger>
+                  <SelectContent>
+                    {sections.map((section) => (
+                      <SelectItem key={section.id} value={section.id}>
+                        {section.className} - {section.name} ({section.periodName})
+                      </SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+              </div>
+              
+              <div>
+                <Label htmlFor="startDate">Fecha Inicio</Label>
+                <Input
+                  id="startDate"
+                  type="date"
+                  value={startDate}
+                  onChange={(e) => setStartDate(e.target.value)}
+                />
+              </div>
+              
+              <div>
+                <Label htmlFor="endDate">Fecha Fin</Label>
+                <Input
+                  id="endDate"
+                  type="date"
+                  value={endDate}
+                  onChange={(e) => setEndDate(e.target.value)}
+                />
+              </div>
+              
+              <div className="flex items-end">
+                <Button 
+                  onClick={fetchHistory} 
+                  disabled={loading || !selectedSection}
+                  className="w-full"
+                >
+                  {loading ? 'Cargando...' : 'Buscar Historial'}
+                </Button>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+
+        {/* Resumen General */}
+        {history.length > 0 && (
+          <Card className="mb-6">
+            <CardHeader>
+              <CardTitle>Resumen del Período</CardTitle>
+            </CardHeader>
+            <CardContent>
+              <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+                <div className="text-center">
+                  <div className="text-2xl font-bold text-blue-600">
+                    {history.reduce((acc, day) => acc + day.summary.total, 0)}
+                  </div>
+                  <div className="text-sm text-gray-600">Total Registros</div>
+                </div>
+                <div className="text-center">
+                  <div className="text-2xl font-bold text-green-600">
+                    {history.reduce((acc, day) => acc + day.summary.present, 0)}
+                  </div>
+                  <div className="text-sm text-gray-600">Presentes</div>
+                </div>
+                <div className="text-center">
+                  <div className="text-2xl font-bold text-yellow-600">
+                    {history.reduce((acc, day) => acc + day.summary.late, 0)}
+                  </div>
+                  <div className="text-sm text-gray-600">Tardanzas</div>
+                </div>
+                <div className="text-center">
+                  <div className="text-2xl font-bold text-red-600">
+                    {history.reduce((acc, day) => acc + day.summary.absent, 0)}
+                  </div>
+                  <div className="text-sm text-gray-600">Ausentes</div>
+                </div>
+              </div>
+            </CardContent>
+          </Card>
+        )}
+
+        {/* Tabla de Historial */}
+        {history.length > 0 ? (
+          <Card>
+            <CardHeader>
+              <CardTitle>Historial de Asistencia</CardTitle>
+            </CardHeader>
+            <CardContent>
+              <Table>
+                <TableHeader>
+                  <TableRow>
+                    <TableHead>Fecha</TableHead>
+                    <TableHead>Estudiante</TableHead>
+                    <TableHead>Estado</TableHead>
+                    <TableHead>Parcial</TableHead>
+                    <TableHead>Razón</TableHead>
+                  </TableRow>
+                </TableHeader>
+                <TableBody>
+                  {history.map((day) =>
+                    day.records.map((record) => (
+                      <TableRow key={record.id}>
+                        <TableCell className="font-medium">
+                          {new Date(day.date).toLocaleDateString('es-ES', {
+                            day: '2-digit',
+                            month: '2-digit',
+                            year: 'numeric'
+                          })}
+                        </TableCell>
+                        <TableCell>
+                          <div>
+                            <div className="font-medium">{record.student.name}</div>
+                            <div className="text-sm text-gray-500">{record.student.email}</div>
+                          </div>
+                        </TableCell>
+                        <TableCell>
+                          <div className="flex items-center gap-2">
+                            {getStatusIcon(record.status)}
+                            {getStatusBadge(record.status)}
+                          </div>
+                        </TableCell>
+                        <TableCell>
+                          {record.partial ? (
+                            <span className="text-sm">{record.partial.name}</span>
+                          ) : (
+                            <span className="text-sm text-gray-400">-</span>
+                          )}
+                        </TableCell>
+                        <TableCell>
+                          {record.reason ? (
+                            <span className="text-sm italic">{record.reason}</span>
+                          ) : (
+                            <span className="text-sm text-gray-400">-</span>
+                          )}
+                        </TableCell>
+                      </TableRow>
+                    ))
+                  )}
+                </TableBody>
+              </Table>
+            </CardContent>
+          </Card>
+        ) : (
+          !loading && selectedSection && (
+            <Card>
+              <CardContent className="text-center py-8">
+                <Calendar className="h-12 w-12 mx-auto text-gray-400 mb-4" />
+                <p className="text-gray-500">No se encontraron registros de asistencia para el período seleccionado.</p>
+              </CardContent>
+            </Card>
+          )
+        )}
+
+        {!selectedSection && (
+          <Card>
+            <CardContent className="text-center py-8">
+              <Users className="h-12 w-12 mx-auto text-gray-400 mb-4" />
+              <p className="text-gray-500">Selecciona una sección para ver el historial de asistencia.</p>
+            </CardContent>
+          </Card>
+        )}
+      </div>
+    </DashboardLayout>
+  )
+}

+ 2 - 2
src/app/teacher/attendance/page.tsx

@@ -72,10 +72,10 @@ export default function AttendancePage() {
 
   const fetchPartials = async () => {
     try {
-      const response = await fetch('/api/admin/partials')
+      const response = await fetch('/api/teacher/partials')
       if (response.ok) {
         const data = await response.json()
-        setPartials(data.filter((p: Partial) => p.isActive))
+        setPartials(data)
       }
     } catch (error) {
       toast.error('Error al cargar los parciales')

+ 286 - 0
src/app/teacher/export-reports/page.tsx

@@ -0,0 +1,286 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { DashboardLayout } from '@/components/dashboard-layout'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+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 { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '@/components/ui/breadcrumb'
+import { Download, FileText, Calendar, Users } from 'lucide-react'
+import { format } from 'date-fns'
+import { es } from 'date-fns/locale'
+import { toast } from 'sonner'
+
+interface Section {
+  id: number
+  name: string
+  className: string
+  classCode: string
+}
+
+export default function ExportReportsPage() {
+  const [sections, setSections] = useState<Section[]>([])
+  const [selectedDate, setSelectedDate] = useState('')
+  const [selectedSection, setSelectedSection] = useState('all')
+  const [isExporting, setIsExporting] = useState(false)
+  const [loading, setLoading] = useState(true)
+
+  useEffect(() => {
+    fetchSections()
+    // Establecer fecha actual por defecto
+    const today = new Date()
+    setSelectedDate(format(today, 'yyyy-MM-dd'))
+  }, [])
+
+  const fetchSections = async () => {
+    try {
+      const response = await fetch('/api/teacher/sections')
+      if (response.ok) {
+        const data = await response.json()
+        setSections(data)
+      } else {
+        toast.error('Error al cargar las secciones')
+      }
+    } catch (error) {
+      console.error('Error:', error)
+      toast.error('Error al cargar las secciones')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const handleExport = async () => {
+    if (!selectedDate) {
+      toast.error('Por favor selecciona una fecha')
+      return
+    }
+
+    setIsExporting(true)
+    try {
+      const params = new URLSearchParams({
+        date: selectedDate,
+        sectionId: selectedSection
+      })
+
+      const response = await fetch(`/api/teacher/export-attendance?${params}`)
+      
+      if (!response.ok) {
+        const errorData = await response.json()
+        throw new Error(errorData.error || 'Error al exportar')
+      }
+
+      // Obtener el blob del archivo CSV
+      const blob = await response.blob()
+      
+      // Crear URL para descarga
+      const url = window.URL.createObjectURL(blob)
+      const link = document.createElement('a')
+      link.href = url
+      
+      // Obtener nombre del archivo desde los headers o generar uno
+      const contentDisposition = response.headers.get('Content-Disposition')
+      let filename = `asistencia_${selectedDate}.csv`
+      
+      if (contentDisposition) {
+        const filenameMatch = contentDisposition.match(/filename="(.+)"/)
+        if (filenameMatch) {
+          filename = filenameMatch[1]
+        }
+      }
+      
+      link.download = filename
+      document.body.appendChild(link)
+      link.click()
+      
+      // Limpiar
+      document.body.removeChild(link)
+      window.URL.revokeObjectURL(url)
+      
+      toast.success('Reporte exportado exitosamente')
+    } catch (error) {
+      console.error('Error al exportar:', error)
+      toast.error(error instanceof Error ? error.message : 'Error al exportar el reporte')
+    } finally {
+      setIsExporting(false)
+    }
+  }
+
+  const getSelectedSectionName = () => {
+    if (selectedSection === 'all') return 'Todas las secciones'
+    const section = sections.find(s => s.id.toString() === selectedSection)
+    return section ? `${section.className} - ${section.name}` : 'Sección desconocida'
+  }
+
+  const formatSelectedDate = () => {
+    if (!selectedDate) return ''
+    return format(new Date(selectedDate), 'dd \\de MMMM \\de yyyy', { locale: es })
+  }
+
+  if (loading) {
+    return (
+      <div className="flex items-center justify-center min-h-[400px]">
+        <div className="text-center">
+          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
+          <p className="text-muted-foreground">Cargando...</p>
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <DashboardLayout>
+      <div className="space-y-6">
+        {/* Breadcrumb */}
+        <Breadcrumb>
+          <BreadcrumbList>
+            <BreadcrumbItem>
+              <BreadcrumbLink href="/teacher">Profesor</BreadcrumbLink>
+            </BreadcrumbItem>
+            <BreadcrumbSeparator />
+            <BreadcrumbItem>
+              <BreadcrumbPage>Exportar Reportes</BreadcrumbPage>
+            </BreadcrumbItem>
+          </BreadcrumbList>
+        </Breadcrumb>
+
+      {/* Header */}
+      <div className="flex items-center gap-3">
+        <div className="p-2 bg-primary/10 rounded-lg">
+          <Download className="h-6 w-6 text-primary" />
+        </div>
+        <div>
+          <h1 className="text-2xl font-bold">Exportar Reportes de Asistencia</h1>
+          <p className="text-muted-foreground">
+            Genera y descarga reportes de asistencia en formato CSV
+          </p>
+        </div>
+      </div>
+
+      <div className="grid gap-6 md:grid-cols-2">
+        {/* Configuración de Exportación */}
+        <Card>
+          <CardHeader>
+            <CardTitle className="flex items-center gap-2">
+              <Calendar className="h-5 w-5" />
+              Configuración del Reporte
+            </CardTitle>
+            <CardDescription>
+              Selecciona la fecha y sección para exportar
+            </CardDescription>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            {/* Selector de Fecha */}
+            <div className="space-y-2">
+              <Label htmlFor="date">Fecha</Label>
+              <Input
+                id="date"
+                type="date"
+                value={selectedDate}
+                onChange={(e) => setSelectedDate(e.target.value)}
+                max={format(new Date(), 'yyyy-MM-dd')}
+              />
+            </div>
+
+            {/* Selector de Sección */}
+            <div className="space-y-2">
+              <Label htmlFor="section">Sección</Label>
+              <Select value={selectedSection} onValueChange={setSelectedSection}>
+                <SelectTrigger>
+                  <SelectValue placeholder="Selecciona una sección" />
+                </SelectTrigger>
+                <SelectContent>
+                  <SelectItem value="all">Todas las secciones</SelectItem>
+                  {sections.map((section) => (
+                    <SelectItem key={section.id} value={section.id.toString()}>
+                      {section.className} - {section.name}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+            </div>
+
+            {/* Botón de Exportación */}
+            <Button 
+              onClick={handleExport} 
+              disabled={!selectedDate || isExporting}
+              className="w-full"
+            >
+              {isExporting ? (
+                <>
+                  <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
+                  Exportando...
+                </>
+              ) : (
+                <>
+                  <Download className="h-4 w-4 mr-2" />
+                  Exportar CSV
+                </>
+              )}
+            </Button>
+          </CardContent>
+        </Card>
+
+        {/* Vista Previa de Configuración */}
+        <Card>
+          <CardHeader>
+            <CardTitle className="flex items-center gap-2">
+              <FileText className="h-5 w-5" />
+              Vista Previa del Reporte
+            </CardTitle>
+            <CardDescription>
+              Información que se incluirá en el reporte
+            </CardDescription>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            {/* Información del Reporte */}
+            <div className="space-y-3">
+              <div className="flex justify-between items-center py-2 border-b">
+                <span className="font-medium">Fecha:</span>
+                <span className="text-muted-foreground">
+                  {selectedDate ? formatSelectedDate() : 'No seleccionada'}
+                </span>
+              </div>
+              
+              <div className="flex justify-between items-center py-2 border-b">
+                <span className="font-medium">Sección:</span>
+                <span className="text-muted-foreground">
+                  {getSelectedSectionName()}
+                </span>
+              </div>
+            </div>
+
+            {/* Columnas del CSV */}
+            <div className="space-y-2">
+              <h4 className="font-medium text-sm">Columnas incluidas:</h4>
+              <div className="grid grid-cols-2 gap-2 text-sm text-muted-foreground">
+                <div>• Fecha</div>
+                <div>• Estudiante</div>
+                <div>• Email</div>
+                <div>• Clase</div>
+                <div>• Código de Clase</div>
+                <div>• Sección</div>
+                <div>• Estado</div>
+                <div>• Parcial</div>
+                <div>• Razón</div>
+              </div>
+            </div>
+
+            {/* Información adicional */}
+            <div className="bg-muted/50 p-3 rounded-lg">
+              <div className="flex items-start gap-2">
+                <Users className="h-4 w-4 mt-0.5 text-muted-foreground" />
+                <div className="text-sm text-muted-foreground">
+                  <p className="font-medium mb-1">Formato CSV</p>
+                  <p>El archivo incluirá todos los registros de asistencia del día seleccionado, ordenados por fecha y nombre del estudiante.</p>
+                </div>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+    </div>
+    </DashboardLayout>
+  )
+}

+ 504 - 0
src/app/teacher/student-attendance/page.tsx

@@ -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>
+  )
+}

+ 16 - 0
src/components/app-sidebar.tsx

@@ -14,6 +14,7 @@ import {
   UserCheck,
   UserPlus,
   School,
+  Download,
 } from "lucide-react"
 
 import {
@@ -105,6 +106,21 @@ const teacherMenuItems = [
     url: "/teacher/attendance",
     icon: UserCheck,
   },
+  {
+    title: "Historial de Asistencia",
+    url: "/teacher/attendance-history",
+    icon: ClipboardList,
+  },
+  {
+    title: "Asistencia por Estudiante",
+    url: "/teacher/student-attendance",
+    icon: Users,
+  },
+  {
+    title: "Exportar Reportes",
+    url: "/teacher/export-reports",
+    icon: Download,
+  },
 ]
 
 // Menú para Estudiante

+ 2 - 2
src/lib/db/schema.ts

@@ -1,9 +1,9 @@
 import { pgTable, text, timestamp, integer, boolean, uuid, varchar, date } from 'drizzle-orm/pg-core';
 import { relations } from 'drizzle-orm';
-import { eq, and, ne } from 'drizzle-orm';
+import { eq, and, ne, gte, lte, desc, like } from 'drizzle-orm';
 
 // Re-export drizzle operators for convenience
-export { eq, and, ne };
+export { eq, and, ne, gte, lte, desc, like };
 
 // Tabla de usuarios (administradores, docentes, estudiantes)
 export const users = pgTable('users', {