Matthew Trejo před 4 měsíci
rodič
revize
184faa7335

+ 22 - 37
package-lock.json

@@ -25,7 +25,9 @@
         "postgres": "^3.4.7",
         "react": "19.1.0",
         "react-dom": "19.1.0",
-        "tailwind-merge": "^3.3.1"
+        "sonner": "^2.0.7",
+        "tailwind-merge": "^3.3.1",
+        "zod": "^4.0.17"
       },
       "devDependencies": {
         "@eslint/eslintrc": "^3",
@@ -36,7 +38,6 @@
         "eslint": "^9",
         "eslint-config-next": "15.4.6",
         "tailwindcss": "^4",
-        "tsx": "^4.20.4",
         "tw-animate-css": "^1.3.7",
         "typescript": "^5"
       }
@@ -5173,21 +5174,6 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/fsevents": {
-      "version": "2.3.3",
-      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
-      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
-      "dev": true,
-      "hasInstallScript": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
-      }
-    },
     "node_modules/function-bind": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -7657,6 +7643,16 @@
         "is-arrayish": "^0.3.1"
       }
     },
+    "node_modules/sonner": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
+      "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
+        "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+      }
+    },
     "node_modules/source-map": {
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -8029,26 +8025,6 @@
       "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
       "license": "0BSD"
     },
-    "node_modules/tsx": {
-      "version": "4.20.4",
-      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.4.tgz",
-      "integrity": "sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "esbuild": "~0.25.0",
-        "get-tsconfig": "^4.7.5"
-      },
-      "bin": {
-        "tsx": "dist/cli.mjs"
-      },
-      "engines": {
-        "node": ">=18.0.0"
-      },
-      "optionalDependencies": {
-        "fsevents": "~2.3.3"
-      }
-    },
     "node_modules/tw-animate-css": {
       "version": "1.3.7",
       "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.7.tgz",
@@ -8432,6 +8408,15 @@
       "funding": {
         "url": "https://github.com/sponsors/sindresorhus"
       }
+    },
+    "node_modules/zod": {
+      "version": "4.0.17",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.17.tgz",
+      "integrity": "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
     }
   }
 }

+ 4 - 1
package.json

@@ -15,6 +15,7 @@
   "dependencies": {
     "@auth/drizzle-adapter": "^1.10.0",
     "@radix-ui/react-label": "^2.1.7",
+    "@radix-ui/react-select": "^2.2.6",
     "@radix-ui/react-slot": "^1.2.3",
     "@types/bcryptjs": "^2.4.6",
     "@types/pg": "^8.15.5",
@@ -29,7 +30,9 @@
     "postgres": "^3.4.7",
     "react": "19.1.0",
     "react-dom": "19.1.0",
-    "tailwind-merge": "^3.3.1"
+    "sonner": "^2.0.7",
+    "tailwind-merge": "^3.3.1",
+    "zod": "^4.0.17"
   },
   "devDependencies": {
     "@eslint/eslintrc": "^3",

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

@@ -0,0 +1,207 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { 
+  teacherAssignments, 
+  sections, 
+  studentEnrollments, 
+  users, 
+  attendance, 
+  eq, 
+  and 
+} from '@/lib/db/schema'
+import { z } from 'zod'
+
+const attendanceSchema = z.object({
+  attendance: z.array(z.object({
+    studentId: z.string(),
+    sectionId: z.string(),
+    partialId: z.string(),
+    date: z.string(),
+    status: z.enum(['present', 'absent', 'late'])
+  }))
+})
+
+// GET - Fetch students and their attendance for a specific section, partial, and date
+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 partialId = searchParams.get('partialId')
+    const date = searchParams.get('date')
+
+    if (!sectionId || !partialId || !date) {
+      return NextResponse.json(
+        { error: 'Faltan parámetros requeridos' },
+        { 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 }
+      )
+    }
+
+    // Get enrolled students
+    const enrolledStudents = await db
+      .select({
+        studentId: users.id,
+        studentFirstName: users.firstName,
+        studentLastName: users.lastName,
+        studentEmail: users.email
+      })
+      .from(studentEnrollments)
+      .innerJoin(users, eq(studentEnrollments.studentId, users.id))
+      .where(eq(studentEnrollments.sectionId, sectionId))
+
+    // Get existing attendance records for this date
+    const existingAttendance = await db
+      .select()
+      .from(attendance)
+      .where(
+        and(
+          eq(attendance.sectionId, sectionId),
+          eq(attendance.partialId, partialId),
+          eq(attendance.date, date)
+        )
+      )
+
+    // Combine student data with attendance data
+    const studentsWithAttendance = enrolledStudents.map(student => {
+      const attendanceRecord = existingAttendance.find(
+        record => record.studentId === student.studentId
+      )
+
+      return {
+          id: student.studentId,
+          name: `${student.studentFirstName} ${student.studentLastName}`,
+          email: student.studentEmail,
+          attendance: attendanceRecord ? {
+            id: attendanceRecord.id,
+            status: attendanceRecord.status,
+            date: attendanceRecord.date
+          } : undefined
+        }
+    })
+
+    return NextResponse.json(studentsWithAttendance)
+  } catch (error) {
+    console.error('Error fetching attendance data:', error)
+    return NextResponse.json(
+      { error: 'Error interno del servidor' },
+      { status: 500 }
+    )
+  }
+}
+
+// POST - Save attendance records
+export async function POST(request: NextRequest) {
+  try {
+    const session = await getServerSession(authOptions)
+
+    if (!session || session.user.role !== 'teacher') {
+      return NextResponse.json(
+        { error: 'No autorizado' },
+        { status: 401 }
+      )
+    }
+
+    const body = await request.json()
+    const { attendance: attendanceRecords } = attendanceSchema.parse(body)
+
+    if (attendanceRecords.length === 0) {
+      return NextResponse.json(
+        { error: 'No hay registros de asistencia para guardar' },
+        { status: 400 }
+      )
+    }
+
+    // Verify teacher has access to the section
+    const sectionId = attendanceRecords[0].sectionId
+    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 }
+      )
+    }
+
+    // Process each attendance record
+    for (const record of attendanceRecords) {
+      // Check if attendance record already exists
+      const existingRecord = await db
+        .select()
+        .from(attendance)
+        .where(
+          and(
+            eq(attendance.studentId, record.studentId),
+            eq(attendance.sectionId, record.sectionId),
+            eq(attendance.partialId, record.partialId),
+            eq(attendance.date, record.date)
+          )
+        )
+
+      if (existingRecord.length > 0) {
+        // Update existing record
+        await db
+          .update(attendance)
+          .set({
+            status: record.status
+          })
+          .where(eq(attendance.id, existingRecord[0].id))
+      } else {
+        // Create new record
+        await db
+          .insert(attendance)
+          .values({
+            studentId: record.studentId,
+            sectionId: record.sectionId,
+            partialId: record.partialId,
+            date: record.date,
+            status: record.status,
+            createdAt: new Date()
+          })
+      }
+    }
+
+    return NextResponse.json({ message: 'Asistencia guardada correctamente' })
+  } catch (error) {
+    console.error('Error saving attendance:', error)
+    return NextResponse.json(
+      { error: 'Error interno del servidor' },
+      { status: 500 }
+    )
+  }
+}

+ 69 - 0
src/app/api/teacher/sections/route.ts

@@ -0,0 +1,69 @@
+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, studentEnrollments } from '@/lib/db/schema'
+import { count, eq } from 'drizzle-orm'
+
+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 teacherId = session.user.id
+
+    // Get assigned sections with class and period information
+    const result = await db
+      .select({
+        sectionId: sections.id,
+        sectionName: sections.name,
+        className: classes.name,
+        classId: classes.id,
+        periodName: periods.name,
+        periodId: periods.id,
+        maxStudents: sections.maxStudents,
+        isActive: periods.isActive
+      })
+      .from(teacherAssignments)
+      .innerJoin(sections, eq(teacherAssignments.sectionId, sections.id))
+      .innerJoin(classes, eq(sections.classId, classes.id))
+      .innerJoin(periods, eq(sections.periodId, periods.id))
+      .where(eq(teacherAssignments.teacherId, teacherId))
+
+    // Get student count for each section
+    const sectionsWithStudents = await Promise.all(
+      result.map(async (section) => {
+        const studentCount = await db
+          .select({ count: count(studentEnrollments.id) })
+          .from(studentEnrollments)
+          .where(eq(studentEnrollments.sectionId, section.sectionId as string))
+
+        return {
+          id: section.sectionId,
+          name: section.sectionName,
+          className: section.className,
+          classId: section.classId,
+          periodName: section.periodName,
+          periodId: section.periodId,
+          studentCount: studentCount[0]?.count || 0,
+          maxStudents: section.maxStudents || 0,
+          isActive: section.isActive
+        }
+      })
+    )
+
+    return NextResponse.json(sectionsWithStudents)
+  } catch (error) {
+    console.error('Error fetching teacher sections:', error)
+    return NextResponse.json(
+      { error: 'Error interno del servidor' },
+      { status: 500 }
+    )
+  }
+}

+ 79 - 0
src/app/api/teacher/stats/route.ts

@@ -0,0 +1,79 @@
+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, studentEnrollments, attendance } from '@/lib/db/schema'
+import { count, eq, and } from 'drizzle-orm'
+
+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 teacherId = session.user.id
+
+    // Get teacher's assigned sections
+    const assignedSections = await db
+      .select({ sectionId: teacherAssignments.sectionId })
+      .from(teacherAssignments)
+      .where(eq(teacherAssignments.teacherId, teacherId))
+
+    const sectionIds = assignedSections.map(a => a.sectionId)
+
+    if (sectionIds.length === 0) {
+      return NextResponse.json({
+        totalSections: 0,
+        totalStudents: 0,
+        attendanceRecords: 0,
+        activePeriods: 0
+      })
+    }
+
+    // Count total students enrolled in teacher's sections
+    let totalStudents = 0
+    for (const sectionId of sectionIds) {
+      const studentCount = await db
+        .select({ count: count(studentEnrollments.id) })
+        .from(studentEnrollments)
+        .where(eq(studentEnrollments.sectionId, sectionId as string))
+      
+      totalStudents += studentCount[0]?.count || 0
+    }
+
+    // Count attendance records for teacher's sections
+    let attendanceRecords = 0
+    for (const sectionId of sectionIds) {
+      const attendanceCount = await db
+        .select({ count: count(attendance.id) })
+        .from(attendance)
+        .where(eq(attendance.sectionId, sectionId as string))
+      
+      attendanceRecords += attendanceCount[0]?.count || 0
+    }
+
+    // Count active periods
+    const activePeriods = await db
+      .select({ count: count(periods.id) })
+      .from(periods)
+      .where(eq(periods.isActive, true))
+
+    return NextResponse.json({
+      totalSections: sectionIds.length,
+      totalStudents,
+      attendanceRecords,
+      activePeriods: activePeriods[0]?.count || 0
+    })
+  } catch (error) {
+    console.error('Error fetching teacher stats:', error)
+    return NextResponse.json(
+      { error: 'Error interno del servidor' },
+      { status: 500 }
+    )
+  }
+}

+ 114 - 0
src/app/api/teacher/students/route.ts

@@ -0,0 +1,114 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { 
+  teacherAssignments, 
+  studentEnrollments, 
+  users, 
+  attendance
+} from '@/lib/db/schema'
+import { count, eq, and } from 'drizzle-orm'
+
+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')
+
+    if (!sectionId) {
+      return NextResponse.json(
+        { error: 'ID de sección requerido' },
+        { 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 }
+      )
+    }
+
+    // Get enrolled students with enrollment date
+    const enrolledStudents = await db
+      .select({
+        studentId: users.id,
+        studentFirstName: users.firstName,
+        studentLastName: users.lastName,
+        studentEmail: users.email,
+        enrollmentDate: studentEnrollments.createdAt
+      })
+      .from(studentEnrollments)
+      .innerJoin(users, eq(studentEnrollments.studentId, users.id))
+      .where(eq(studentEnrollments.sectionId, sectionId))
+      .orderBy(users.firstName)
+
+    // Get attendance statistics for each student
+    const studentsWithStats = await Promise.all(
+      enrolledStudents.map(async (student) => {
+        // Get all attendance records for this student in this section
+        const attendanceRecords = await db
+          .select()
+          .from(attendance)
+          .where(
+            and(
+              eq(attendance.studentId, student.studentId),
+              eq(attendance.sectionId, sectionId)
+            )
+          )
+
+        // Calculate attendance statistics
+        const totalClasses = attendanceRecords.length
+        const presentCount = attendanceRecords.filter(record => record.status === 'present').length
+        const lateCount = attendanceRecords.filter(record => record.status === 'late').length
+        const absentCount = attendanceRecords.filter(record => record.status === 'absent').length
+        
+        // Calculate attendance percentage (present + late counts as attended)
+        const attendedClasses = presentCount + lateCount
+        const attendancePercentage = totalClasses > 0 ? (attendedClasses / totalClasses) * 100 : 0
+
+        return {
+          id: student.studentId,
+          name: `${student.studentFirstName} ${student.studentLastName}`,
+          email: student.studentEmail,
+          enrollmentDate: student.enrollmentDate?.toISOString() || new Date().toISOString(),
+          attendanceStats: totalClasses > 0 ? {
+            totalClasses,
+            presentCount,
+            lateCount,
+            absentCount,
+            attendancePercentage
+          } : undefined
+        }
+      })
+    )
+
+    return NextResponse.json(studentsWithStats)
+  } catch (error) {
+    console.error('Error fetching students:', error)
+    return NextResponse.json(
+      { error: 'Error interno del servidor' },
+      { status: 500 }
+    )
+  }
+}

+ 2 - 0
src/app/layout.tsx

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
 import { Inter } from "next/font/google";
 import "./globals.css";
 import { AuthProvider } from "@/components/providers/auth-provider";
+import { Toaster } from "sonner";
 
 const inter = Inter({
   subsets: ["latin"],
@@ -26,6 +27,7 @@ export default function RootLayout({
         <AuthProvider>
           {children}
         </AuthProvider>
+        <Toaster />
       </body>
     </html>
   );

+ 322 - 0
src/app/teacher/attendance/page.tsx

@@ -0,0 +1,322 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Badge } from '@/components/ui/badge'
+import { CheckCircle, XCircle, Clock, Users } from 'lucide-react'
+import { toast } from 'sonner'
+
+interface Section {
+  id: string
+  name: string
+  className: string
+  periodName: string
+  studentCount: number
+  isActive: boolean
+}
+
+interface Partial {
+  id: string
+  name: string
+  startDate: string
+  endDate: string
+  isActive: boolean
+}
+
+interface Student {
+  id: string
+  name: string
+  email: string
+  attendance?: {
+    id: string
+    status: 'present' | 'absent' | 'late'
+    date: string
+  }
+}
+
+export default function AttendancePage() {
+  const [sections, setSections] = useState<Section[]>([])
+  const [partials, setPartials] = useState<Partial[]>([])
+  const [students, setStudents] = useState<Student[]>([])
+  const [selectedSection, setSelectedSection] = useState<string>('')
+  const [selectedPartial, setSelectedPartial] = useState<string>('')
+  const [selectedDate, setSelectedDate] = useState<string>(new Date().toISOString().split('T')[0])
+  const [loading, setLoading] = useState(false)
+  const [saving, setSaving] = useState(false)
+
+  useEffect(() => {
+    fetchSections()
+    fetchPartials()
+  }, [])
+
+  useEffect(() => {
+    if (selectedSection && selectedPartial && selectedDate) {
+      fetchStudents()
+    }
+  }, [selectedSection, selectedPartial, selectedDate])
+
+  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 fetchPartials = async () => {
+    try {
+      const response = await fetch('/api/admin/partials')
+      if (response.ok) {
+        const data = await response.json()
+        setPartials(data.filter((p: Partial) => p.isActive))
+      }
+    } catch (error) {
+      toast.error('Error al cargar los parciales')
+    }
+  }
+
+  const fetchStudents = async () => {
+    if (!selectedSection || !selectedPartial || !selectedDate) return
+    
+    setLoading(true)
+    try {
+      const response = await fetch(
+        `/api/teacher/attendance?sectionId=${selectedSection}&partialId=${selectedPartial}&date=${selectedDate}`
+      )
+      if (response.ok) {
+        const data = await response.json()
+        setStudents(data)
+      }
+    } catch (error) {
+      toast.error('Error al cargar los estudiantes')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const updateAttendance = (studentId: string, status: 'present' | 'absent' | 'late') => {
+    setStudents(prev => prev.map(student => 
+      student.id === studentId 
+        ? { 
+            ...student, 
+            attendance: { 
+              id: student.attendance?.id || '', 
+              status, 
+              date: selectedDate 
+            } 
+          }
+        : student
+    ))
+  }
+
+  const saveAttendance = async () => {
+    if (!selectedSection || !selectedPartial || !selectedDate) {
+      toast.error('Selecciona sección, parcial y fecha')
+      return
+    }
+
+    setSaving(true)
+    try {
+      const attendanceData = students.map(student => ({
+        studentId: student.id,
+        sectionId: selectedSection,
+        partialId: selectedPartial,
+        date: selectedDate,
+        status: student.attendance?.status || 'absent'
+      }))
+
+      const response = await fetch('/api/teacher/attendance', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json'
+        },
+        body: JSON.stringify({ attendance: attendanceData })
+      })
+
+      if (response.ok) {
+        toast.success('Asistencia guardada correctamente')
+        fetchStudents() // Refresh data
+      } else {
+        toast.error('Error al guardar la asistencia')
+      }
+    } catch (error) {
+      toast.error('Error al guardar la asistencia')
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  const getStatusIcon = (status: string) => {
+    switch (status) {
+      case 'present':
+        return <CheckCircle className="h-4 w-4 text-green-600" />
+      case 'late':
+        return <Clock className="h-4 w-4 text-yellow-600" />
+      case 'absent':
+        return <XCircle className="h-4 w-4 text-red-600" />
+      default:
+        return <XCircle className="h-4 w-4 text-gray-400" />
+    }
+  }
+
+  const getStatusBadge = (status: string) => {
+    switch (status) {
+      case 'present':
+        return <Badge className="bg-green-100 text-green-800">Presente</Badge>
+      case 'late':
+        return <Badge className="bg-yellow-100 text-yellow-800">Tardanza</Badge>
+      case 'absent':
+        return <Badge className="bg-red-100 text-red-800">Ausente</Badge>
+      default:
+        return <Badge variant="secondary">Sin marcar</Badge>
+    }
+  }
+
+  return (
+    <div className="space-y-6">
+      <div>
+        <h1 className="text-2xl font-bold text-gray-900">Gestión de Asistencia</h1>
+        <p className="text-gray-600">Registra la asistencia de tus estudiantes</p>
+      </div>
+
+      {/* Filters */}
+      <Card>
+        <CardHeader>
+          <CardTitle className="flex items-center gap-2">
+            <Users className="h-5 w-5" />
+            Seleccionar Clase
+          </CardTitle>
+        </CardHeader>
+        <CardContent>
+          <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
+            <div>
+              <label className="block text-sm font-medium mb-2">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}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+            </div>
+
+            <div>
+              <label className="block text-sm font-medium mb-2">Parcial</label>
+              <Select value={selectedPartial} onValueChange={setSelectedPartial}>
+                <SelectTrigger>
+                  <SelectValue placeholder="Seleccionar parcial" />
+                </SelectTrigger>
+                <SelectContent>
+                  {partials.map((partial) => (
+                    <SelectItem key={partial.id} value={partial.id}>
+                      {partial.name}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+            </div>
+
+            <div>
+              <label className="block text-sm font-medium mb-2">Fecha</label>
+              <input
+                type="date"
+                value={selectedDate}
+                onChange={(e) => setSelectedDate(e.target.value)}
+                className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+              />
+            </div>
+
+            <div className="flex items-end">
+              <Button 
+                onClick={fetchStudents} 
+                disabled={!selectedSection || !selectedPartial || loading}
+                className="w-full"
+              >
+                {loading ? 'Cargando...' : 'Cargar Estudiantes'}
+              </Button>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+
+      {/* Students List */}
+      {students.length > 0 && (
+        <Card>
+          <CardHeader>
+            <div className="flex justify-between items-center">
+              <CardTitle>Lista de Estudiantes</CardTitle>
+              <Button onClick={saveAttendance} disabled={saving}>
+                {saving ? 'Guardando...' : 'Guardar Asistencia'}
+              </Button>
+            </div>
+          </CardHeader>
+          <CardContent>
+            <div className="space-y-4">
+              {students.map((student) => (
+                <div
+                  key={student.id}
+                  className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50"
+                >
+                  <div className="flex items-center space-x-3">
+                    {getStatusIcon(student.attendance?.status || 'absent')}
+                    <div>
+                      <h3 className="font-medium">{student.name}</h3>
+                      <p className="text-sm text-gray-600">{student.email}</p>
+                    </div>
+                  </div>
+                  
+                  <div className="flex items-center space-x-4">
+                    {getStatusBadge(student.attendance?.status || 'absent')}
+                    
+                    <div className="flex space-x-2">
+                      <Button
+                        size="sm"
+                        variant={student.attendance?.status === 'present' ? 'default' : 'outline'}
+                        onClick={() => updateAttendance(student.id, 'present')}
+                      >
+                        Presente
+                      </Button>
+                      <Button
+                        size="sm"
+                        variant={student.attendance?.status === 'late' ? 'default' : 'outline'}
+                        onClick={() => updateAttendance(student.id, 'late')}
+                      >
+                        Tardanza
+                      </Button>
+                      <Button
+                        size="sm"
+                        variant={student.attendance?.status === 'absent' ? 'default' : 'outline'}
+                        onClick={() => updateAttendance(student.id, 'absent')}
+                      >
+                        Ausente
+                      </Button>
+                    </div>
+                  </div>
+                </div>
+              ))}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {selectedSection && selectedPartial && students.length === 0 && !loading && (
+        <Card>
+          <CardContent className="text-center py-8">
+            <p className="text-gray-500">No hay estudiantes matriculados en esta sección.</p>
+          </CardContent>
+        </Card>
+      )}
+    </div>
+  )
+}

+ 79 - 0
src/app/teacher/layout.tsx

@@ -0,0 +1,79 @@
+import { redirect } from 'next/navigation'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import Link from 'next/link'
+import { LogOut, BookOpen, Users, ClipboardList } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+
+export default async function TeacherLayout({
+  children,
+}: {
+  children: React.ReactNode
+}) {
+  const session = await getServerSession(authOptions)
+
+  if (!session || session.user.role !== 'teacher') {
+    redirect('/auth/signin')
+  }
+
+  return (
+    <div className="min-h-screen bg-gray-50">
+      <nav className="bg-white shadow-sm border-b">
+        <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
+          <div className="flex justify-between h-16">
+            <div className="flex items-center">
+              <h1 className="text-xl font-semibold text-gray-900">
+                Panel del Profesor
+              </h1>
+            </div>
+            <div className="flex items-center space-x-4">
+              <span className="text-sm text-gray-700">
+                {session.user.name}
+              </span>
+              <form action="/api/auth/signout" method="post">
+                <Button type="submit" variant="ghost" size="sm">
+                  <LogOut className="h-4 w-4 mr-2" />
+                  Cerrar Sesión
+                </Button>
+              </form>
+            </div>
+          </div>
+        </div>
+      </nav>
+
+      <div className="flex">
+        <aside className="w-64 bg-white shadow-sm min-h-screen">
+          <nav className="mt-8">
+            <div className="px-4 space-y-2">
+              <Link
+                href="/teacher"
+                className="flex items-center px-4 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-100 hover:text-gray-900"
+              >
+                <BookOpen className="mr-3 h-5 w-5" />
+                Dashboard
+              </Link>
+              <Link
+                href="/teacher/attendance"
+                className="flex items-center px-4 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-100 hover:text-gray-900"
+              >
+                <ClipboardList className="mr-3 h-5 w-5" />
+                Asistencia
+              </Link>
+              <Link
+                href="/teacher/students"
+                className="flex items-center px-4 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-100 hover:text-gray-900"
+              >
+                <Users className="mr-3 h-5 w-5" />
+                Estudiantes
+              </Link>
+            </div>
+          </nav>
+        </aside>
+
+        <main className="flex-1 p-8">
+          {children}
+        </main>
+      </div>
+    </div>
+  )
+}

+ 227 - 0
src/app/teacher/page.tsx

@@ -0,0 +1,227 @@
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { redirect } from 'next/navigation'
+import { db } from '@/lib/db'
+import { teacherAssignments, sections, classes, periods, studentEnrollments, attendance, eq, and } from '@/lib/db/schema'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { BookOpen, Users, ClipboardCheck, Calendar } from 'lucide-react'
+
+interface TeacherStats {
+  totalSections: number
+  totalStudents: number
+  attendanceRecords: number
+  activePeriods: number
+}
+
+interface AssignedSection {
+  id: string
+  name: string
+  className: string
+  periodName: string
+  studentCount: number
+  maxStudents: number
+}
+
+async function getTeacherStats(teacherId: string): Promise<TeacherStats> {
+  // Get teacher's assigned sections
+  const assignedSections = await db
+    .select({ sectionId: teacherAssignments.sectionId })
+    .from(teacherAssignments)
+    .where(eq(teacherAssignments.teacherId, teacherId))
+
+  const sectionIds = assignedSections.map(a => a.sectionId)
+
+  if (sectionIds.length === 0) {
+    return {
+      totalSections: 0,
+      totalStudents: 0,
+      attendanceRecords: 0,
+      activePeriods: 0
+    }
+  }
+
+  // Count total students enrolled in teacher's sections
+  const studentCount = await db
+    .select()
+    .from(studentEnrollments)
+    .where(eq(studentEnrollments.sectionId, sectionIds[0] as string)) // This is a simplified query
+
+  // Count attendance records for teacher's sections
+  const attendanceCount = await db
+    .select()
+    .from(attendance)
+    .where(eq(attendance.sectionId, sectionIds[0] as string)) // This is a simplified query
+
+  // Count active periods
+  const activePeriods = await db
+    .select()
+    .from(periods)
+    .where(eq(periods.isActive, true))
+
+  return {
+    totalSections: sectionIds.length,
+    totalStudents: studentCount.length,
+    attendanceRecords: attendanceCount.length,
+    activePeriods: activePeriods.length
+  }
+}
+
+async function getAssignedSections(teacherId: string): Promise<AssignedSection[]> {
+  const result = await db
+    .select({
+      sectionId: sections.id,
+      sectionName: sections.name,
+      className: classes.name,
+      periodName: periods.name,
+      maxStudents: sections.maxStudents
+    })
+    .from(teacherAssignments)
+    .innerJoin(sections, eq(teacherAssignments.sectionId, sections.id))
+    .innerJoin(classes, eq(sections.classId, classes.id))
+    .innerJoin(periods, eq(sections.periodId, periods.id))
+    .where(eq(teacherAssignments.teacherId, teacherId))
+
+  // Get student count for each section
+  const sectionsWithStudents = await Promise.all(
+    result.map(async (section) => {
+      const studentCount = await db
+        .select()
+        .from(studentEnrollments)
+        .where(eq(studentEnrollments.sectionId, section.sectionId))
+
+      return {
+        id: section.sectionId,
+        name: section.sectionName,
+        className: section.className,
+        periodName: section.periodName,
+        studentCount: studentCount.length,
+        maxStudents: section.maxStudents || 0
+      }
+    })
+  )
+
+  return sectionsWithStudents
+}
+
+export default async function TeacherDashboard() {
+  const session = await getServerSession(authOptions)
+
+  if (!session || session.user.role !== 'teacher') {
+    redirect('/auth/signin')
+  }
+
+  const stats = await getTeacherStats(session.user.id)
+  const assignedSections = await getAssignedSections(session.user.id)
+
+  return (
+    <div className="space-y-6">
+      <div>
+        <h1 className="text-2xl font-bold text-gray-900">
+          Bienvenido, {session.user.name}
+        </h1>
+        <p className="text-gray-600">
+          Gestiona tus clases y estudiantes desde aquí
+        </p>
+      </div>
+
+      {/* Stats Cards */}
+      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
+        <Card>
+          <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+            <CardTitle className="text-sm font-medium">
+              Secciones Asignadas
+            </CardTitle>
+            <BookOpen className="h-4 w-4 text-muted-foreground" />
+          </CardHeader>
+          <CardContent>
+            <div className="text-2xl font-bold">{stats.totalSections}</div>
+          </CardContent>
+        </Card>
+
+        <Card>
+          <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+            <CardTitle className="text-sm font-medium">
+              Total Estudiantes
+            </CardTitle>
+            <Users className="h-4 w-4 text-muted-foreground" />
+          </CardHeader>
+          <CardContent>
+            <div className="text-2xl font-bold">{stats.totalStudents}</div>
+          </CardContent>
+        </Card>
+
+        <Card>
+          <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+            <CardTitle className="text-sm font-medium">
+              Registros de Asistencia
+            </CardTitle>
+            <ClipboardCheck className="h-4 w-4 text-muted-foreground" />
+          </CardHeader>
+          <CardContent>
+            <div className="text-2xl font-bold">{stats.attendanceRecords}</div>
+          </CardContent>
+        </Card>
+
+        <Card>
+          <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+            <CardTitle className="text-sm font-medium">
+              Períodos Activos
+            </CardTitle>
+            <Calendar className="h-4 w-4 text-muted-foreground" />
+          </CardHeader>
+          <CardContent>
+            <div className="text-2xl font-bold">{stats.activePeriods}</div>
+          </CardContent>
+        </Card>
+      </div>
+
+      {/* Assigned Sections */}
+      <Card>
+        <CardHeader>
+          <CardTitle>Mis Secciones</CardTitle>
+        </CardHeader>
+        <CardContent>
+          {assignedSections.length === 0 ? (
+            <p className="text-gray-500 text-center py-8">
+              No tienes secciones asignadas actualmente.
+            </p>
+          ) : (
+            <div className="space-y-4">
+              {assignedSections.map((section) => (
+                <div
+                  key={section.id}
+                  className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50"
+                >
+                  <div>
+                    <h3 className="font-medium">
+                      {section.className} - {section.name}
+                    </h3>
+                    <p className="text-sm text-gray-600">
+                      Período: {section.periodName}
+                    </p>
+                  </div>
+                  <div className="text-right">
+                    <p className="text-sm font-medium">
+                      {section.studentCount}/{section.maxStudents} estudiantes
+                    </p>
+                    <div className="w-24 bg-gray-200 rounded-full h-2 mt-1">
+                      <div
+                        className="bg-blue-600 h-2 rounded-full"
+                        style={{
+                          width: `${Math.min(
+                            (section.studentCount / section.maxStudents) * 100,
+                            100
+                          )}%`
+                        }}
+                      ></div>
+                    </div>
+                  </div>
+                </div>
+              ))}
+            </div>
+          )}
+        </CardContent>
+      </Card>
+    </div>
+  )
+}

+ 253 - 0
src/app/teacher/students/page.tsx

@@ -0,0 +1,253 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
+import { Users, Search, Mail, User, BookOpen } from 'lucide-react'
+import { toast } from 'sonner'
+
+interface Section {
+  id: string
+  name: string
+  className: string
+  periodName: string
+  studentCount: number
+  isActive: boolean
+}
+
+interface Student {
+  id: string
+  name: string
+  email: string
+  enrollmentDate: string
+  attendanceStats?: {
+    totalClasses: number
+    presentCount: number
+    lateCount: number
+    absentCount: number
+    attendancePercentage: number
+  }
+}
+
+export default function StudentsPage() {
+  const [sections, setSections] = useState<Section[]>([])
+  const [students, setStudents] = useState<Student[]>([])
+  const [filteredStudents, setFilteredStudents] = useState<Student[]>([])
+  const [selectedSection, setSelectedSection] = useState<string>('')
+  const [searchTerm, setSearchTerm] = useState('')
+  const [loading, setLoading] = useState(false)
+
+  useEffect(() => {
+    fetchSections()
+  }, [])
+
+  useEffect(() => {
+    if (selectedSection) {
+      fetchStudents()
+    } else {
+      setStudents([])
+      setFilteredStudents([])
+    }
+  }, [selectedSection])
+
+  useEffect(() => {
+    if (searchTerm) {
+      const filtered = students.filter(student => 
+        student.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+        student.email.toLowerCase().includes(searchTerm.toLowerCase())
+      )
+      setFilteredStudents(filtered)
+    } else {
+      setFilteredStudents(students)
+    }
+  }, [searchTerm, students])
+
+  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 fetchStudents = async () => {
+    if (!selectedSection) return
+    
+    setLoading(true)
+    try {
+      const response = await fetch(`/api/teacher/students?sectionId=${selectedSection}`)
+      if (response.ok) {
+        const data = await response.json()
+        setStudents(data)
+      } else {
+        toast.error('Error al cargar los estudiantes')
+      }
+    } catch (error) {
+      toast.error('Error al cargar los estudiantes')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const getAttendanceColor = (percentage: number) => {
+    if (percentage >= 80) return 'bg-green-100 text-green-800'
+    if (percentage >= 60) return 'bg-yellow-100 text-yellow-800'
+    return 'bg-red-100 text-red-800'
+  }
+
+  const selectedSectionInfo = sections.find(s => s.id === selectedSection)
+
+  return (
+    <div className="space-y-6">
+      <div>
+        <h1 className="text-2xl font-bold text-gray-900">Mis Estudiantes</h1>
+        <p className="text-gray-600">Gestiona y revisa la información de tus estudiantes</p>
+      </div>
+
+      {/* Section Selection */}
+      <Card>
+        <CardHeader>
+          <CardTitle className="flex items-center gap-2">
+            <BookOpen className="h-5 w-5" />
+            Seleccionar Sección
+          </CardTitle>
+        </CardHeader>
+        <CardContent>
+          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+            <div>
+              <label className="block text-sm font-medium mb-2">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>
+
+            {selectedSectionInfo && (
+              <div className="flex items-end">
+                <div className="text-sm text-gray-600">
+                  <p><strong>Clase:</strong> {selectedSectionInfo.className}</p>
+                  <p><strong>Período:</strong> {selectedSectionInfo.periodName}</p>
+                  <p><strong>Estudiantes:</strong> {selectedSectionInfo.studentCount}</p>
+                </div>
+              </div>
+            )}
+          </div>
+        </CardContent>
+      </Card>
+
+      {/* Search and Filters */}
+      {selectedSection && (
+        <Card>
+          <CardHeader>
+            <CardTitle className="flex items-center gap-2">
+              <Users className="h-5 w-5" />
+              Lista de Estudiantes
+            </CardTitle>
+          </CardHeader>
+          <CardContent>
+            <div className="mb-4">
+              <div className="relative">
+                <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
+                <Input
+                  placeholder="Buscar estudiante por nombre o email..."
+                  value={searchTerm}
+                  onChange={(e) => setSearchTerm(e.target.value)}
+                  className="pl-10"
+                />
+              </div>
+            </div>
+
+            {loading ? (
+              <div className="text-center py-8">
+                <p className="text-gray-500">Cargando estudiantes...</p>
+              </div>
+            ) : filteredStudents.length === 0 ? (
+              <div className="text-center py-8">
+                <p className="text-gray-500">
+                  {students.length === 0 
+                    ? 'No hay estudiantes matriculados en esta sección.'
+                    : 'No se encontraron estudiantes con ese criterio de búsqueda.'
+                  }
+                </p>
+              </div>
+            ) : (
+              <div className="space-y-4">
+                {filteredStudents.map((student) => (
+                  <div
+                    key={student.id}
+                    className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50"
+                  >
+                    <div className="flex items-center space-x-4">
+                      <div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
+                        <User className="h-5 w-5 text-blue-600" />
+                      </div>
+                      <div>
+                        <h3 className="font-medium text-gray-900">{student.name}</h3>
+                        <div className="flex items-center space-x-2 text-sm text-gray-600">
+                          <Mail className="h-4 w-4" />
+                          <span>{student.email}</span>
+                        </div>
+                        <p className="text-xs text-gray-500">
+                          Matriculado: {new Date(student.enrollmentDate).toLocaleDateString('es-ES')}
+                        </p>
+                      </div>
+                    </div>
+
+                    {student.attendanceStats && (
+                      <div className="text-right space-y-2">
+                        <Badge className={getAttendanceColor(student.attendanceStats.attendancePercentage)}>
+                          {student.attendanceStats.attendancePercentage.toFixed(1)}% Asistencia
+                        </Badge>
+                        <div className="text-xs text-gray-600 space-y-1">
+                          <div className="flex justify-between gap-4">
+                            <span>Presente:</span>
+                            <span className="font-medium text-green-600">
+                              {student.attendanceStats.presentCount}
+                            </span>
+                          </div>
+                          <div className="flex justify-between gap-4">
+                            <span>Tardanza:</span>
+                            <span className="font-medium text-yellow-600">
+                              {student.attendanceStats.lateCount}
+                            </span>
+                          </div>
+                          <div className="flex justify-between gap-4">
+                            <span>Ausente:</span>
+                            <span className="font-medium text-red-600">
+                              {student.attendanceStats.absentCount}
+                            </span>
+                          </div>
+                          <div className="flex justify-between gap-4 pt-1 border-t">
+                            <span>Total clases:</span>
+                            <span className="font-medium">
+                              {student.attendanceStats.totalClasses}
+                            </span>
+                          </div>
+                        </div>
+                      </div>
+                    )}
+                  </div>
+                ))}
+              </div>
+            )}
+          </CardContent>
+        </Card>
+      )}
+    </div>
+  )
+}

+ 36 - 0
src/components/ui/badge.tsx

@@ -0,0 +1,36 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+  "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+  {
+    variants: {
+      variant: {
+        default:
+          "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+        secondary:
+          "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+        destructive:
+          "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+        outline: "text-foreground",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  }
+)
+
+export interface BadgeProps
+  extends React.HTMLAttributes<HTMLDivElement>,
+    VariantProps<typeof badgeVariants> {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+  return (
+    <div className={cn(badgeVariants({ variant }), className)} {...props} />
+  )
+}
+
+export { Badge, badgeVariants }