diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 495f776..7a68392 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -69,6 +69,10 @@ model School { peerAssessments PeerAssessment[] emergencyContacts EmergencyContact[] schoolEvents SchoolEvent[] + studentTransports StudentTransport[] + healthRecords HealthRecord[] + badges Badge[] + studentBadges StudentBadge[] district SchoolDistrict? @relation(fields: [districtId], references: [id]) districtId String? } @@ -115,6 +119,7 @@ model User { resources Resource[] organizedSchoolEvents SchoolEvent[] eventRegistrations EventRegistration[] + awardedBadges StudentBadge[] } model SchoolYear { @@ -213,6 +218,9 @@ model Student { peerAssessmentsGiven PeerAssessment[] @relation("PeerAssessor") peerAssessmentsReceived PeerAssessment[] @relation("PeerAssessed") emergencyContacts EmergencyContact[] + transports StudentTransport[] + healthRecords HealthRecord[] + studentBadges StudentBadge[] } model Enrollment { @@ -1478,3 +1486,105 @@ model EventRegistration { @@unique([eventId, userId]) @@map("event_registrations") } + +// ─── Student Transportation ────────────────────────────────────── + +model StudentTransport { + id String @id @default(cuid()) + schoolId String + studentId String + transportType String // "bus", "tram", "walk", "car", "bike", "other" + routeNumber String? // bus/tram route number + stopName String? // nearest stop name + pickupTime String? // "07:45" + dropoffTime String? // "15:30" + driverName String? // for car/bus + driverPhone String? // for car/bus + distanceKm Float? // distance in km + notes String? + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + + @@index([schoolId, studentId]) + @@map("student_transport") +} + +// ─── Health Records ────────────────────────────────────────────── + +model HealthRecord { + id String @id @default(cuid()) + schoolId String + studentId String + bloodType String? // "A+", "A-", "B+", "B-", "O+", "O-", "AB+", "AB-" + allergies String? // JSON array of allergy names + medications String? // JSON array of { name, dosage, frequency } + conditions String? // JSON array of condition names + doctorName String? + doctorPhone String? + insuranceNumber String? + insuranceProvider String? + lastCheckup DateTime? + notes String? + isConfidential Boolean @default(true) + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + + @@unique([schoolId, studentId]) + @@map("health_records") +} + +// ─── Badges & Gamification ─────────────────────────────────────── + +model Badge { + id String @id @default(cuid()) + schoolId String + name String + description String? + icon String // Lucide icon name + color String @default("#10b981") + category String // "competency", "attendance", "behavior", "achievement", "milestone" + requirementType String // "mastery_level", "attendance_rate", "behavior_count", "progress_entries", "custom" + requirementValue Int? // threshold value + isAuto Boolean @default(true) // auto-awarded or manual + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + studentBadges StudentBadge[] + + @@index([schoolId, category]) + @@map("badges") +} + +model StudentBadge { + id String @id @default(cuid()) + schoolId String + studentId String + badgeId String + awardedAt DateTime @default(now()) + awardedBy String? // userId who awarded (null if auto) + notes String? + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + + school School @relation(fields: [schoolId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + badge Badge @relation(fields: [badgeId], references: [id]) + awardedByUser User? @relation(fields: [awardedBy], references: [id]) + + @@unique([studentId, badgeId]) + @@index([schoolId, studentId]) + @@map("student_badges") +} diff --git a/src/app/api/badge-check/route.ts b/src/app/api/badge-check/route.ts new file mode 100644 index 0000000..2d864b7 --- /dev/null +++ b/src/app/api/badge-check/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { seedDefaultBadges } from '@/lib/badge-check'; + +function isTeacherOrAdmin(role: string | undefined): boolean { + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + const body = await request.json(); + const { schoolId, studentId } = body; + + if (!schoolId || !studentId) { + return NextResponse.json({ error: 'Missing schoolId or studentId' }, { status: 400 }); + } + + // Seed default badges if needed + await seedDefaultBadges(schoolId); + + // Check and award + const { checkAndAwardBadges } = await import('@/lib/badge-check'); + const awarded = await checkAndAwardBadges(schoolId, studentId); + + return NextResponse.json({ awarded }); + } catch (error) { + console.error('Badge-check POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/badge-progress/route.ts b/src/app/api/badge-progress/route.ts new file mode 100644 index 0000000..80a0b3b --- /dev/null +++ b/src/app/api/badge-progress/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { getBadgeProgress } from '@/lib/badge-check'; + +function isTeacherOrAdmin(role: string | undefined): boolean { + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId'); + const studentId = searchParams.get('studentId'); + + if (!schoolId || !studentId) { + return NextResponse.json({ error: 'Missing schoolId or studentId' }, { status: 400 }); + } + + // Seed default badges if needed + const { seedDefaultBadges } = await import('@/lib/badge-check'); + await seedDefaultBadges(schoolId); + + const progress = await getBadgeProgress(schoolId, studentId); + + return NextResponse.json(progress); + } catch (error) { + console.error('Badge-progress GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/badge-seed/route.ts b/src/app/api/badge-seed/route.ts new file mode 100644 index 0000000..d2e3b90 --- /dev/null +++ b/src/app/api/badge-seed/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { getSession } from '@/lib/auth'; +import { seedDefaultBadges } from '@/lib/badge-check'; + +function isTeacherOrAdmin(role: string | undefined): boolean { + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + const body = await request.json(); + const { schoolId } = body; + + if (!schoolId) { + return NextResponse.json({ error: 'Missing schoolId' }, { status: 400 }); + } + + await seedDefaultBadges(schoolId); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Badge-seed POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/badges/[id]/route.ts b/src/app/api/badges/[id]/route.ts new file mode 100644 index 0000000..57356b5 --- /dev/null +++ b/src/app/api/badges/[id]/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +function isTeacherOrAdmin(role: string | undefined): boolean { + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + const { id } = await params; + const badge = await db.badge.findUnique({ + where: { id, deletedAt: null }, + include: { + _count: { select: { studentBadges: true } }, + }, + }); + + if (!badge) return NextResponse.json({ error: 'Badge not found' }, { status: 404 }); + return NextResponse.json(badge); + } catch (error) { + console.error('Badge GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + const { id } = await params; + const body = await request.json(); + + const existing = await db.badge.findUnique({ where: { id, deletedAt: null } }); + if (!existing) return NextResponse.json({ error: 'Badge not found' }, { status: 404 }); + + const updated = await db.badge.update({ + where: { id }, + data: { + name: body.name ?? existing.name, + description: body.description ?? existing.description, + icon: body.icon ?? existing.icon, + color: body.color ?? existing.color, + category: body.category ?? existing.category, + requirementType: body.requirementType ?? existing.requirementType, + requirementValue: body.requirementValue ?? existing.requirementValue, + isAuto: body.isAuto ?? existing.isAuto, + }, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('Badge PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + const { id } = await params; + + const existing = await db.badge.findUnique({ where: { id, deletedAt: null } }); + if (!existing) return NextResponse.json({ error: 'Badge not found' }, { status: 404 }); + + // Soft delete + await db.badge.update({ where: { id }, data: { deletedAt: new Date() } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Badge DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/badges/route.ts b/src/app/api/badges/route.ts new file mode 100644 index 0000000..c222e8a --- /dev/null +++ b/src/app/api/badges/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const categoryEnum = z.enum(['competency', 'attendance', 'behavior', 'achievement', 'milestone']); +const reqTypeEnum = z.enum(['mastery_level', 'attendance_rate', 'behavior_count', 'progress_entries', 'custom']); + +const createBadgeSchema = z.object({ + schoolId: z.string().min(1), + name: z.string().min(1).max(100), + description: z.string().max(500).optional().nullable(), + icon: z.string().min(1).max(50), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default('#10b981'), + category: categoryEnum, + requirementType: reqTypeEnum, + requirementValue: z.number().int().min(0).optional().nullable(), + isAuto: z.boolean().default(true), +}); + +function isTeacherOrAdmin(role: string | undefined): boolean { + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + const { searchParams } = new URL(request.url); + const schoolIdParam = searchParams.get('schoolId'); + const categoryParam = searchParams.get('category'); + + let schoolId: string | undefined; + if (session.user?.role === 'SCHOOL_ADMIN') { + schoolId = session.user.schoolId ?? undefined; + } else { + schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined; + } + + if (!schoolId) return NextResponse.json([]); + + const where: Record = { schoolId, deletedAt: null }; + if (categoryParam) where.category = categoryParam; + + const badges = await db.badge.findMany({ + where, + orderBy: [{ category: 'asc' }, { name: 'asc' }], + include: { + _count: { select: { studentBadges: true } }, + }, + }); + + return NextResponse.json(badges); + } catch (error) { + console.error('Badges GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + const body = await request.json(); + const parsed = createBadgeSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: 'Validation failed', details: parsed.error.issues }, { status: 400 }); + } + + const { schoolId } = parsed.data; + + const school = await db.school.findUnique({ where: { id: schoolId } }); + if (!school) return NextResponse.json({ error: 'School not found' }, { status: 404 }); + if (session.user?.role === 'SCHOOL_ADMIN' && session.user.schoolId && school.id !== session.user.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const badge = await db.badge.create({ + data: { + schoolId, + name: parsed.data.name, + description: parsed.data.description ?? null, + icon: parsed.data.icon, + color: parsed.data.color, + category: parsed.data.category, + requirementType: parsed.data.requirementType, + requirementValue: parsed.data.requirementValue ?? null, + isAuto: parsed.data.isAuto, + }, + }); + + return NextResponse.json(badge, { status: 201 }); + } catch (error) { + console.error('Badges POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/classes/[id]/seating/route.ts b/src/app/api/classes/[id]/seating/route.ts new file mode 100644 index 0000000..170740b --- /dev/null +++ b/src/app/api/classes/[id]/seating/route.ts @@ -0,0 +1,108 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const classGroup = await db.classGroup.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!classGroup) { + return NextResponse.json({ error: 'Class not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== classGroup.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Get enrolled students + const enrollments = await db.enrollment.findMany({ + where: { classGroupId: id, endDate: null }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + const students = enrollments.map((e) => e.student); + + // Parse existing seating order + let seatingOrder: Array<{ studentId: string; row: number; col: number }> = []; + if (classGroup.seatingOrder) { + try { + const parsed = JSON.parse(classGroup.seatingOrder); + if (Array.isArray(parsed)) { + // Check if it's the new format [{ studentId, row, col }] + if (parsed.length > 0 && parsed[0].studentId !== undefined && parsed[0].row !== undefined) { + seatingOrder = parsed; + } else { + // Legacy format: just array of student IDs, convert to grid + seatingOrder = parsed.map((studentId: string, i: number) => ({ + studentId, + row: Math.floor(i / 5), + col: i % 5, + })); + } + } + } catch { /* ignore parse errors */ } + } + + return NextResponse.json({ students, seatingOrder, classId: id }); + } catch (error) { + console.error('Seating GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const classGroup = await db.classGroup.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!classGroup) { + return NextResponse.json({ error: 'Class not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== classGroup.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { seatingOrder } = body; + + if (!Array.isArray(seatingOrder)) { + return NextResponse.json({ error: 'seatingOrder must be an array' }, { status: 400 }); + } + + await db.classGroup.update({ + where: { id }, + data: { seatingOrder: JSON.stringify(seatingOrder) }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Seating PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/health-records/[id]/route.ts b/src/app/api/health-records/[id]/route.ts new file mode 100644 index 0000000..8fb8df2 --- /dev/null +++ b/src/app/api/health-records/[id]/route.ts @@ -0,0 +1,141 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const record = await db.healthRecord.findFirst({ + where: { id, deletedAt: null }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + if (!record) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== record.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(record); + } catch (error) { + console.error('HealthRecord GET [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const existing = await db.healthRecord.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { + bloodType, + allergies, + medications, + conditions, + doctorName, + doctorPhone, + insuranceNumber, + insuranceProvider, + lastCheckup, + notes, + isConfidential, + } = body; + + const updated = await db.healthRecord.update({ + where: { id }, + data: { + ...(bloodType !== undefined && { bloodType: bloodType || null }), + ...(allergies !== undefined && { allergies: allergies ? JSON.stringify(allergies) : null }), + ...(medications !== undefined && { medications: medications ? JSON.stringify(medications) : null }), + ...(conditions !== undefined && { conditions: conditions ? JSON.stringify(conditions) : null }), + ...(doctorName !== undefined && { doctorName: doctorName || null }), + ...(doctorPhone !== undefined && { doctorPhone: doctorPhone || null }), + ...(insuranceNumber !== undefined && { insuranceNumber: insuranceNumber || null }), + ...(insuranceProvider !== undefined && { insuranceProvider: insuranceProvider || null }), + ...(lastCheckup !== undefined && { lastCheckup: lastCheckup ? new Date(lastCheckup) : null }), + ...(notes !== undefined && { notes }), + ...(isConfidential !== undefined && { isConfidential }), + }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('HealthRecord PUT [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const existing = await db.healthRecord.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Soft delete + await db.healthRecord.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('HealthRecord DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/health-records/route.ts b/src/app/api/health-records/route.ts new file mode 100644 index 0000000..0e9cc82 --- /dev/null +++ b/src/app/api/health-records/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId'); + const studentId = searchParams.get('studentId'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const where: Record = { + schoolId, + deletedAt: null, + }; + + if (studentId) where.studentId = studentId; + + const records = await db.healthRecord.findMany({ + where, + orderBy: [{ createdAt: 'desc' }], + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + return NextResponse.json(records); + } catch (error) { + console.error('HealthRecord GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.json(); + const { + schoolId, + studentId, + bloodType, + allergies, + medications, + conditions, + doctorName, + doctorPhone, + insuranceNumber, + insuranceProvider, + lastCheckup, + notes, + isConfidential, + isDemo, + } = body; + + if (!schoolId || !studentId) { + return NextResponse.json( + { error: 'schoolId and studentId are required' }, + { status: 400 } + ); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const record = await db.healthRecord.create({ + data: { + schoolId, + studentId, + bloodType: bloodType || null, + allergies: allergies ? JSON.stringify(allergies) : null, + medications: medications ? JSON.stringify(medications) : null, + conditions: conditions ? JSON.stringify(conditions) : null, + doctorName: doctorName || null, + doctorPhone: doctorPhone || null, + insuranceNumber: insuranceNumber || null, + insuranceProvider: insuranceProvider || null, + lastCheckup: lastCheckup ? new Date(lastCheckup) : null, + notes: notes || null, + isConfidential: isConfidential ?? true, + isDemo: isDemo ?? false, + }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + return NextResponse.json(record, { status: 201 }); + } catch (error) { + console.error('HealthRecord POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/student-badges/[id]/route.ts b/src/app/api/student-badges/[id]/route.ts new file mode 100644 index 0000000..e20252b --- /dev/null +++ b/src/app/api/student-badges/[id]/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +function isTeacherOrAdmin(role: string | undefined): boolean { + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + const { id } = await params; + const studentBadge = await db.studentBadge.findUnique({ + where: { id }, + include: { + badge: true, + student: { select: { id: true, firstName: true, lastName: true } }, + awardedByUser: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + if (!studentBadge) return NextResponse.json({ error: 'Student badge not found' }, { status: 404 }); + return NextResponse.json(studentBadge); + } catch (error) { + console.error('StudentBadge GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + const { id } = await params; + + const existing = await db.studentBadge.findUnique({ where: { id } }); + if (!existing) return NextResponse.json({ error: 'Student badge not found' }, { status: 404 }); + + await db.studentBadge.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('StudentBadge DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/student-badges/route.ts b/src/app/api/student-badges/route.ts new file mode 100644 index 0000000..49ef9a9 --- /dev/null +++ b/src/app/api/student-badges/route.ts @@ -0,0 +1,107 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const awardBadgeSchema = z.object({ + schoolId: z.string().min(1), + studentId: z.string().min(1), + badgeId: z.string().min(1), + notes: z.string().max(500).optional().nullable(), +}); + +function isTeacherOrAdmin(role: string | undefined): boolean { + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + const { searchParams } = new URL(request.url); + const schoolIdParam = searchParams.get('schoolId'); + const studentIdParam = searchParams.get('studentId'); + const badgeIdParam = searchParams.get('badgeId'); + + let schoolId: string | undefined; + if (session.user?.role === 'SCHOOL_ADMIN') { + schoolId = session.user.schoolId ?? undefined; + } else { + schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined; + } + + if (!schoolId) return NextResponse.json([]); + + const where: Record = { schoolId }; + if (studentIdParam) where.studentId = studentIdParam; + if (badgeIdParam) where.badgeId = badgeIdParam; + + const studentBadges = await db.studentBadge.findMany({ + where, + orderBy: { awardedAt: 'desc' }, + include: { + badge: true, + student: { select: { id: true, firstName: true, lastName: true } }, + awardedByUser: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(studentBadges); + } catch (error) { + console.error('StudentBadges GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + const body = await request.json(); + const parsed = awardBadgeSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: 'Validation failed', details: parsed.error.issues }, { status: 400 }); + } + + const { schoolId, studentId, badgeId } = parsed.data; + + // Verify badge exists + const badge = await db.badge.findUnique({ where: { id: badgeId, deletedAt: null } }); + if (!badge) return NextResponse.json({ error: 'Badge not found' }, { status: 404 }); + + // Verify student exists + const student = await db.student.findUnique({ where: { id: studentId, deletedAt: null } }); + if (!student) return NextResponse.json({ error: 'Student not found' }, { status: 404 }); + + // Check if already awarded + const existing = await db.studentBadge.findUnique({ + where: { studentId_badgeId: { studentId, badgeId } }, + }); + if (existing) { + return NextResponse.json({ error: 'Badge already awarded to this student' }, { status: 409 }); + } + + const studentBadge = await db.studentBadge.create({ + data: { + schoolId, + studentId, + badgeId, + awardedBy: session.userId, + notes: parsed.data.notes ?? null, + }, + include: { + badge: true, + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(studentBadge, { status: 201 }); + } catch (error) { + console.error('StudentBadges POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/student-transport/[id]/route.ts b/src/app/api/student-transport/[id]/route.ts new file mode 100644 index 0000000..6cb9bf8 --- /dev/null +++ b/src/app/api/student-transport/[id]/route.ts @@ -0,0 +1,137 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const transport = await db.studentTransport.findFirst({ + where: { id, deletedAt: null }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + if (!transport) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== transport.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(transport); + } catch (error) { + console.error('StudentTransport GET [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const existing = await db.studentTransport.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { + transportType, + routeNumber, + stopName, + pickupTime, + dropoffTime, + driverName, + driverPhone, + distanceKm, + notes, + } = body; + + const updated = await db.studentTransport.update({ + where: { id }, + data: { + ...(transportType !== undefined && { transportType }), + ...(routeNumber !== undefined && { routeNumber: routeNumber || null }), + ...(stopName !== undefined && { stopName: stopName || null }), + ...(pickupTime !== undefined && { pickupTime: pickupTime || null }), + ...(dropoffTime !== undefined && { dropoffTime: dropoffTime || null }), + ...(driverName !== undefined && { driverName: driverName || null }), + ...(driverPhone !== undefined && { driverPhone: driverPhone || null }), + ...(distanceKm !== undefined && { distanceKm: distanceKm != null ? distanceKm : null }), + ...(notes !== undefined && { notes }), + }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('StudentTransport PUT [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const existing = await db.studentTransport.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Soft delete + await db.studentTransport.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('StudentTransport DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/student-transport/route.ts b/src/app/api/student-transport/route.ts new file mode 100644 index 0000000..04f9273 --- /dev/null +++ b/src/app/api/student-transport/route.ts @@ -0,0 +1,109 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId'); + const studentId = searchParams.get('studentId'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const where: Record = { + schoolId, + deletedAt: null, + }; + + if (studentId) where.studentId = studentId; + + const transports = await db.studentTransport.findMany({ + where, + orderBy: [{ createdAt: 'desc' }], + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + return NextResponse.json(transports); + } catch (error) { + console.error('StudentTransport GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.json(); + const { + schoolId, + studentId, + transportType, + routeNumber, + stopName, + pickupTime, + dropoffTime, + driverName, + driverPhone, + distanceKm, + notes, + isDemo, + } = body; + + if (!schoolId || !studentId || !transportType) { + return NextResponse.json( + { error: 'schoolId, studentId, and transportType are required' }, + { status: 400 } + ); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const transport = await db.studentTransport.create({ + data: { + schoolId, + studentId, + transportType, + routeNumber: routeNumber || null, + stopName: stopName || null, + pickupTime: pickupTime || null, + dropoffTime: dropoffTime || null, + driverName: driverName || null, + driverPhone: driverPhone || null, + distanceKm: distanceKm != null ? distanceKm : null, + notes: notes || null, + isDemo: isDemo ?? false, + }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + return NextResponse.json(transport, { status: 201 }); + } catch (error) { + console.error('StudentTransport POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 2cbd38a..8788b9f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2315,4 +2315,198 @@ min-height: 44px; min-width: 44px; } + + /* ─── Badge Circle Styling ─────────────────────────────────────── */ + .badge-circle { + width: 56px; + height: 56px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease, box-shadow 0.2s ease; + position: relative; + cursor: pointer; + } + .badge-circle:hover { + transform: scale(1.12); + } + .badge-earned { + background: linear-gradient(135deg, oklch(0.65 0.17 155), oklch(0.55 0.14 170)); + box-shadow: 0 2px 12px oklch(0.55 0.17 155 / 0.3); + } + .badge-earned:hover { + box-shadow: 0 4px 20px oklch(0.55 0.17 155 / 0.5); + } + .badge-unearned { + background: oklch(0.92 0 0); + border: 2px dashed oklch(0.7 0 0); + opacity: 0.5; + } + .dark .badge-unearned { + background: oklch(0.25 0 0); + border-color: oklch(0.45 0 0); + } + .badge-progress { + position: absolute; + bottom: -2px; + left: 50%; + transform: translateX(-50%); + width: 36px; + height: 4px; + border-radius: 2px; + background: oklch(0.85 0 0); + overflow: hidden; + } + .dark .badge-progress { + background: oklch(0.35 0 0); + } + .badge-progress-fill { + height: 100%; + border-radius: 2px; + background: linear-gradient(90deg, oklch(0.55 0.17 155), oklch(0.65 0.12 175)); + transition: width 0.5s ease; + } + + /* ─── Celebration Animation ────────────────────────────────────── */ + @keyframes celebration-sparkle { + 0% { opacity: 0; transform: scale(0.5) rotate(0deg); } + 50% { opacity: 1; transform: scale(1.2) rotate(180deg); } + 100% { opacity: 0; transform: scale(0.5) rotate(360deg); } + } + @keyframes celebration-bounce { + 0%, 100% { transform: translateY(0); } + 30% { transform: translateY(-12px); } + 50% { transform: translateY(-6px); } + 70% { transform: translateY(-8px); } + } + .celebration-sparkle { + animation: celebration-sparkle 0.8s ease-in-out; + } + .celebration-bounce { + animation: celebration-bounce 0.6s ease-in-out; + } + .celebration-ring { + position: absolute; + inset: -6px; + border-radius: 50%; + border: 2px solid oklch(0.65 0.17 155 / 0.6); + animation: celebration-ring-pulse 1s ease-out; + } + @keyframes celebration-ring-pulse { + 0% { transform: scale(0.8); opacity: 1; } + 100% { transform: scale(1.5); opacity: 0; } + } + + /* ─── QR Code Card Styling ─────────────────────────────────────── */ + .qr-card { + border-radius: 1rem; + padding: 1.5rem; + text-align: center; + background: white; + box-shadow: 0 4px 16px oklch(0 0 0 / 0.08); + position: relative; + overflow: hidden; + } + .dark .qr-card { + background: oklch(0.2 0 0); + box-shadow: 0 4px 16px oklch(0 0 0 / 0.3); + } + .qr-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, oklch(0.55 0.17 155), oklch(0.65 0.12 175), oklch(0.55 0.17 155)); + } + .qr-card img { + border-radius: 0.5rem; + max-width: 100%; + } + + /* ─── Attendance QR Display ────────────────────────────────────── */ + .attendance-qr-display { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 2rem; + background: linear-gradient(135deg, oklch(0.97 0.02 155), oklch(0.95 0.02 175)); + border-radius: 1rem; + border: 2px solid oklch(0.55 0.17 155 / 0.2); + } + .dark .attendance-qr-display { + background: linear-gradient(135deg, oklch(0.2 0.01 155), oklch(0.18 0.01 175)); + border-color: oklch(0.55 0.17 155 / 0.3); + } + .attendance-qr-display .qr-timer { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: oklch(0.5 0 0); + } + .dark .attendance-qr-display .qr-timer { + color: oklch(0.65 0 0); + } + .attendance-qr-display .qr-timer-bar { + width: 100%; + height: 4px; + border-radius: 2px; + background: oklch(0.9 0 0); + overflow: hidden; + } + .dark .attendance-qr-display .qr-timer-bar { + background: oklch(0.3 0 0); + } + .attendance-qr-display .qr-timer-bar-fill { + height: 100%; + background: linear-gradient(90deg, oklch(0.55 0.17 155), oklch(0.65 0.12 175)); + transition: width 1s linear; + } + + /* ─── Student Detail Quick Stats ───────────────────────────────── */ + .quick-stats-bar { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + padding: 0.75rem; + border-radius: 0.75rem; + background: oklch(0.97 0.02 155); + border: 1px solid oklch(0.55 0.17 155 / 0.1); + } + .dark .quick-stats-bar { + background: oklch(0.2 0.01 155); + border-color: oklch(0.55 0.17 155 / 0.2); + } + .quick-stat-item { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border-radius: 0.5rem; + background: white; + border: 1px solid oklch(0.9 0 0); + font-size: 0.8125rem; + white-space: nowrap; + } + .dark .quick-stat-item { + background: oklch(0.25 0 0); + border-color: oklch(0.35 0 0); + } + + /* ─── Section Header with Icon ─────────────────────────────────── */ + .section-header-icon { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0; + cursor: pointer; + user-select: none; + } + .section-header-icon:hover { + opacity: 0.8; + } } diff --git a/src/components/attendance-view.tsx b/src/components/attendance-view.tsx index 2cf50f9..3c7070b 100644 --- a/src/components/attendance-view.tsx +++ b/src/components/attendance-view.tsx @@ -27,6 +27,9 @@ import { Download, AlertTriangle, Shield, + QrCode, + FileDown, + RefreshCw, } from 'lucide-react'; import { LineChart, @@ -97,6 +100,7 @@ import { type Subject, type AttendanceAnalyticsData, } from '@/lib/api'; +import { generateQRCodeSync, downloadQRCode, type QRCodeData } from '@/lib/qrcode'; /* ── Status helpers ────────────────────────────────────────────────── */ @@ -1072,6 +1076,43 @@ export default function AttendanceView() { const [newPeriod, setNewPeriod] = useState(''); const [creating, setCreating] = useState(false); + // QR Attendance mode + const [qrAttendanceMode, setQrAttendanceMode] = useState(false); + const [qrAttendanceDataUrl, setQrAttendanceDataUrl] = useState(''); + const [qrRefreshTimer, setQrRefreshTimer] = useState(300); // 5 minutes = 300 seconds + const [qrTimerInterval, setQrTimerInterval] = useState(null); + + // QR Attendance: generate and refresh QR code + const generateAttendanceQR = useCallback(() => { + if (!currentClassId) return; + const sessionId = `attendance-${currentClassId}-${Date.now()}`; + const qrData: QRCodeData = { type: 'attendance', id: currentClassId, sessionId, timestamp: Date.now() }; + const dataUrl = generateQRCodeSync(qrData, { size: 300 }); + setQrAttendanceDataUrl(dataUrl); + setQrRefreshTimer(300); + }, [currentClassId]); + + // QR timer effect + useEffect(() => { + if (!qrAttendanceMode) { + if (qrTimerInterval) clearInterval(qrTimerInterval); + setQrTimerInterval(null); + return; + } + generateAttendanceQR(); + const interval = setInterval(() => { + setQrRefreshTimer((prev) => { + if (prev <= 1) { + generateAttendanceQR(); + return 300; + } + return prev - 1; + }); + }, 1000); + setQrTimerInterval(interval); + return () => clearInterval(interval); + }, [qrAttendanceMode, generateAttendanceQR]); + // Load classes useEffect(() => { fetchClasses() @@ -1476,6 +1517,10 @@ export default function AttendanceView() { {t('attendance.calendar')} + + + {t('qr.attendance')} + {/* Sessions List Tab */} @@ -1650,6 +1695,109 @@ export default function AttendanceView() { + + {/* QR Attendance Tab */} + + + +
+ +
+ +
+ {t('qr.attendance')} +
+ +
+
+ + {qrAttendanceMode ? ( +
+
+ + {t('qr.session_active')} +
+ {qrAttendanceDataUrl ? ( + {t('qr.attendance')} + ) : ( +
+ +
+ )} +

{t('qr.scan_to_checkin')}

+
+ + {t('qr.refreshes_in', { seconds: qrRefreshTimer })} +
+
+
+
+
+ + +
+ {/* Real-time check-in status */} + {sessions.length > 0 && ( +
+

{t('attendance.status')}

+
+ {sessions[0]?.records.map((record) => ( +
+
+
+ {record.student.firstName[0]}{record.student.lastName[0]} +
+ {record.student.firstName} {record.student.lastName} +
+ +
+ ))} +
+
+ )} +
+ ) : ( +
+
+ +
+

{t('qr.attendance')}

+

{t('qr.scan_to_checkin')}

+ +
+ )} + + + )}
diff --git a/src/components/classes-view.tsx b/src/components/classes-view.tsx index 8de6d65..abe5cbf 100644 --- a/src/components/classes-view.tsx +++ b/src/components/classes-view.tsx @@ -9,6 +9,7 @@ import { GripVertical, Armchair, ArrowRight, GraduationCap, Library, Backpack, Sprout, Leaf, TreePine, Trees, + QrCode, FileDown, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -29,6 +30,7 @@ import { type LearningProgressEntry, type ClassGroup, type Student, type Subject, type CompetencyTemplate, type ClassCompetencyAssignment, } from '@/lib/api'; +import { generateQRCodeSync, downloadQRCode, type QRCodeData } from '@/lib/qrcode'; import { toast } from 'sonner'; const schoolTypeAccent: Record = { @@ -192,6 +194,10 @@ export default function ClassesView() { const [dragIdx, setDragIdx] = useState(null); const [reorderSaving, setReorderSaving] = useState(false); + // QR Code dialog + const [classQrOpen, setClassQrOpen] = useState(false); + const [classQrDataUrl, setClassQrDataUrl] = useState(''); + // Parse a CSV file (client-side). Supports quoted fields with escaped quotes. function parseCsv(text: string): string[][] { const rows: string[][] = []; @@ -787,6 +793,15 @@ export default function ClassesView() { {t('classes.assign_template')} + @@ -1423,6 +1438,40 @@ export default function ClassesView() { + + {/* Class QR Code Dialog */} + + + + + + {t('qr.class')} + + {selectedClass?.name} + +
+ {classQrDataUrl ? ( + {t('qr.title')} + ) : ( +
+ +
+ )} +

{selectedClass?.name}

+

{t('label.grade')} {selectedClass?.gradeLevel} · {selectedClass?.schoolYear?.label}

+
+
+ +
+
+
); } diff --git a/src/components/settings-view.tsx b/src/components/settings-view.tsx index ac7782a..2ff6d1f 100644 --- a/src/components/settings-view.tsx +++ b/src/components/settings-view.tsx @@ -62,6 +62,11 @@ import { AlertCircle, Star, Printer, + Trophy, + Award, + Target, + CalendarCheck, + Flame, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -145,6 +150,15 @@ import { fetchEmailLogs, type EmailTemplate, type EmailLog, + fetchBadges, + createBadge, + updateBadge, + deleteBadge, + fetchStudentBadges, + awardBadgeToStudent, + type BadgeData, + type StudentBadgeData, + seedBadges, } from '@/lib/api'; import { RateLimitStatus } from '@/components/offline-indicator'; @@ -409,6 +423,278 @@ function NotificationSoundSetting() { ); } +// ─── Badge Management Tab Component ───────────────────────────────────── +function BadgeManagementTab() { + const currentUser = useAppStore((s) => s.currentUser); + const [badges, setBadges] = useState([]); + const [studentBadges, setStudentBadges] = useState([]); + const [students, setStudentsList] = useState>([]); + const [loading, setLoading] = useState(true); + const [createBadgeOpen, setCreateBadgeOpen] = useState(false); + const [awardBadgeOpen, setAwardBadgeOpen] = useState(false); + const [newBadgeForm, setNewBadgeForm] = useState({ + name: '', description: '', icon: 'Award', color: '#10b981', + category: 'achievement', requirementType: 'custom', requirementValue: 5, isAuto: true, + }); + const [awardForm, setAwardForm] = useState({ studentId: '', badgeId: '', notes: '' }); + + const categoryOptions = [ + { value: 'competency', label: t('badges.competency') }, + { value: 'attendance', label: t('badges.attendance') }, + { value: 'behavior', label: t('badges.behavior') }, + { value: 'achievement', label: t('badges.achievement') }, + { value: 'milestone', label: t('badges.milestone') }, + ]; + const reqTypeOptions = [ + { value: 'mastery_level', label: 'Mastery Level' }, + { value: 'attendance_rate', label: 'Attendance Rate' }, + { value: 'behavior_count', label: 'Behavior Count' }, + { value: 'progress_entries', label: 'Progress Entries' }, + { value: 'custom', label: 'Custom' }, + ]; + const iconOptions = ['Award', 'Star', 'Trophy', 'Target', 'CalendarCheck', 'TrendingUp', 'BookOpen', 'Pencil', 'ClipboardCheck', 'Leaf', 'Heart', 'Zap', 'Rocket', 'Flame']; + + const loadBadges = useCallback(async () => { + if (!currentUser?.schoolId) return; + setLoading(true); + try { + // Seed default badges first + await seedBadges(currentUser.schoolId); + const data = await fetchBadges(currentUser.schoolId); + setBadges(data); + const sbData = await fetchStudentBadges(currentUser.schoolId); + setStudentBadges(sbData); + const stuData = await fetchStudents(currentUser.schoolId); + setStudentsList(stuData.map(s => ({ id: s.id, firstName: s.firstName, lastName: s.lastName }))); + } catch { /* ignore */ } + setLoading(false); + }, [currentUser?.schoolId]); + + useEffect(() => { loadBadges(); }, [loadBadges]); + + const handleCreateBadge = async () => { + if (!currentUser?.schoolId || !newBadgeForm.name) return; + try { + await createBadge({ schoolId: currentUser.schoolId, ...newBadgeForm }); + toast.success(t('badges.create')); + setCreateBadgeOpen(false); + setNewBadgeForm({ name: '', description: '', icon: 'Award', color: '#10b981', category: 'achievement', requirementType: 'custom', requirementValue: 5, isAuto: true }); + loadBadges(); + } catch { toast.error(t('error.generic')); } + }; + + const handleAwardBadge = async () => { + if (!currentUser?.schoolId || !awardForm.studentId || !awardForm.badgeId) return; + try { + await awardBadgeToStudent({ schoolId: currentUser.schoolId, studentId: awardForm.studentId, badgeId: awardForm.badgeId, notes: awardForm.notes }); + toast.success(t('badges.award')); + setAwardBadgeOpen(false); + setAwardForm({ studentId: '', badgeId: '', notes: '' }); + loadBadges(); + } catch { toast.error(t('error.generic')); } + }; + + const handleDeleteBadge = async (id: string) => { + try { + await deleteBadge(id); + toast.success(t('badges.delete')); + loadBadges(); + } catch { toast.error(t('error.generic')); } + }; + + if (loading) { + return
; + } + + return ( +
+ {/* Badge Statistics */} + + +
+ +
+ +
+ {t('badges.statistics')} +
+
+ + +
+
+
+ +
+
+ +

{badges.length}

+

{t('badges.title')}

+
+
+ +

{studentBadges.length}

+

{t('badges.earned')}

+
+
+ +

{badges.filter(b => b.isAuto).length}

+

{t('badges.auto')}

+
+
+ + {/* Badge List */} +
+ {badges.map((badge) => { + const awardedCount = badge._count?.studentBadges ?? 0; + return ( +
+
+
+ +
+
+

{badge.name}

+

{badge.description}

+
+ {t(`badges.${badge.category}`)} + {badge.isAuto && {t('badges.auto')}} + {awardedCount} {t('badges.earned')} +
+
+
+ +
+ ); + })} +
+
+
+ + {/* Create Badge Dialog */} + + + + + + {t('badges.create')} + + {t('badges.create')} + +
+
+ + setNewBadgeForm(f => ({ ...f, name: e.target.value }))} placeholder={t('badges.name' in {} ? 'badges.name' : 'label.name')} /> +
+
+ + setNewBadgeForm(f => ({ ...f, description: e.target.value }))} /> +
+
+
+ + +
+
+ + setNewBadgeForm(f => ({ ...f, color: e.target.value }))} className="h-10" /> +
+
+
+
+ + +
+
+ + +
+
+
+
+ + setNewBadgeForm(f => ({ ...f, requirementValue: parseInt(e.target.value) || 0 }))} /> +
+
+ setNewBadgeForm(f => ({ ...f, isAuto: e.target.checked }))} className="rounded" /> + +
+
+
+ + + + +
+
+ + {/* Award Badge Dialog */} + + + + + + {t('badges.award_manually')} + + {t('badges.award')} + +
+
+ + +
+
+ + +
+
+ + setAwardForm(f => ({ ...f, notes: e.target.value }))} /> +
+
+ + + + +
+
+
+ ); +} + export default function SettingsView() { const currentUser = useAppStore((s) => s.currentUser); const [activeTab, setActiveTab] = useState('school'); @@ -1569,6 +1855,10 @@ export default function SettingsView() { {t('rate_limit.status_title')} + + + {t('badges.manage')} + {/* ── School Info Tab ─────────────────────────────────── */} @@ -4273,6 +4563,11 @@ export default function SettingsView() { + + {/* ── Badge Management Tab ─────────────────────────────────── */} + + + ); } diff --git a/src/components/student-detail-view.tsx b/src/components/student-detail-view.tsx index 2d09aed..c6ac452 100644 --- a/src/components/student-detail-view.tsx +++ b/src/components/student-detail-view.tsx @@ -19,6 +19,9 @@ import { LucideIcon, Download, FileSpreadsheet, FileDown, Heart, Users as UsersIcon, Eye, Plus, Trash2, Clock, CheckCircle2, XCircle, SlidersHorizontal, Lightbulb, Phone, PhoneCall, MapPin, ShieldAlert, UserCheck, AlertCircle, Globe, + Bus, Car, Bike, Footprints, Train, Truck, Droplets, Pill, Stethoscope, + EyeOff, Shield, Activity, HeartPulse, Syringe, + QrCode, CalendarCheck, Leaf, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -36,7 +39,8 @@ import { } from '@/components/ui/tooltip'; import { useAppStore, type CurrentUser } from '@/lib/store'; import { t } from '@/lib/i18n'; -import { fetchStudentDetail, getReportPdfUrl, type StudentDetailData, fetchParentLinks, type ParentStudentLinkData, apiGet, apiPost, apiPut, apiDelete } from '@/lib/api'; +import { fetchStudentDetail, getReportPdfUrl, type StudentDetailData, fetchParentLinks, type ParentStudentLinkData, apiGet, apiPost, apiPut, apiDelete, fetchBadgeProgress, type BadgeProgressData, fetchStudentBadges, type StudentBadgeData, type BadgeData } from '@/lib/api'; +import { generateQRCodeSync, downloadQRCode, type QRCodeData } from '@/lib/qrcode'; import { toast } from 'sonner'; import TeacherNotesSection from './teacher-notes-section'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'; @@ -332,6 +336,13 @@ const masteryBadge = (level: number) => { return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300'; }; +// Icon map for badge icons +const iconMap: Record = { + CalendarCheck, Award, TrendingUp, Star, BookOpen, Pencil, ClipboardCheck, + UsersIcon, Target, Leaf, Trophy, Zap, Rocket, Heart, GraduationCap, + CheckCircle2, Shield, Activity, Flower2, Flag, Lightbulb, +}; + const gradeColor = (value: number) => { if (value <= 2) return 'text-emerald-600 dark:text-emerald-400'; if (value <= 4) return 'text-amber-600 dark:text-amber-400'; @@ -463,6 +474,51 @@ export default function StudentDetailView() { const [ecEditId, setEcEditId] = useState(null); const [ecForm, setEcForm] = useState({ name: '', relationship: 'mother', phone: '', phoneAlt: '', email: '', address: '', isPrimary: false, priority: 1, notes: '' }); + // Transportation state + interface TransportData { + id: string; schoolId: string; studentId: string; transportType: string; + routeNumber: string | null; stopName: string | null; pickupTime: string | null; + dropoffTime: string | null; driverName: string | null; driverPhone: string | null; + distanceKm: number | null; notes: string | null; isDemo: boolean; + createdAt: string; updatedAt: string; deletedAt: string | null; + student: { id: string; firstName: string; lastName: string }; + } + const [transports, setTransports] = useState([]); + const [transportDialogOpen, setTransportDialogOpen] = useState(false); + const [transportEditId, setTransportEditId] = useState(null); + const [transportForm, setTransportForm] = useState({ + transportType: 'bus', routeNumber: '', stopName: '', pickupTime: '', + dropoffTime: '', driverName: '', driverPhone: '', distanceKm: '', notes: '', + }); + + // Health Records state + interface HealthRecordData { + id: string; schoolId: string; studentId: string; bloodType: string | null; + allergies: string | null; medications: string | null; conditions: string | null; + doctorName: string | null; doctorPhone: string | null; insuranceNumber: string | null; + insuranceProvider: string | null; lastCheckup: string | null; notes: string | null; + isConfidential: boolean; isDemo: boolean; + createdAt: string; updatedAt: string; deletedAt: string | null; + student: { id: string; firstName: string; lastName: string }; + } + const [healthRecord, setHealthRecord] = useState(null); + const [healthDialogOpen, setHealthDialogOpen] = useState(false); + const [healthEditId, setHealthEditId] = useState(null); + const [healthForm, setHealthForm] = useState({ + bloodType: '', allergies: '' as string, medications: '' as string, + conditions: '' as string, doctorName: '', doctorPhone: '', + insuranceNumber: '', insuranceProvider: '', lastCheckup: '', + notes: '', isConfidential: true, + }); + + // QR Code & Badge state + const [qrDialogOpen, setQrDialogOpen] = useState(false); + const [qrCodeDataUrl, setQrCodeDataUrl] = useState(''); + const [badgeProgress, setBadgeProgress] = useState([]); + const [earnedBadges, setEarnedBadges] = useState([]); + const [badgeDetailDialogOpen, setBadgeDetailDialogOpen] = useState(false); + const [selectedBadgeDetail, setSelectedBadgeDetail] = useState(null); + // Load parent links for parent users useEffect(() => { if (isParent && currentUser?.id) { @@ -540,6 +596,46 @@ export default function StudentDetailView() { loadEC(); }, [currentStudentId, currentUser?.schoolId]); + // Load transportation and health records + useEffect(() => { + if (!currentStudentId || !currentUser?.schoolId) return; + const schoolId = currentUser.schoolId; + async function loadTransport() { + try { + const data = await apiGet(`/api/student-transport?schoolId=${schoolId}&studentId=${currentStudentId}`); + setTransports(data); + } catch { /* ignore */ } + } + async function loadHealth() { + try { + const data = await apiGet(`/api/health-records?schoolId=${schoolId}&studentId=${currentStudentId}`); + setHealthRecord(data.length > 0 ? data[0] : null); + } catch { /* ignore */ } + } + loadTransport(); + loadHealth(); + }, [currentStudentId, currentUser?.schoolId]); + + // Load badge progress and earned badges + useEffect(() => { + if (!currentStudentId || !currentUser?.schoolId) return; + const schoolId = currentUser.schoolId; + async function loadBadges() { + try { + const progress = await fetchBadgeProgress(schoolId, currentStudentId); + setBadgeProgress(progress); + } catch { /* ignore */ } + } + async function loadEarnedBadges() { + try { + const data = await fetchStudentBadges(schoolId, currentStudentId); + setEarnedBadges(data); + } catch { /* ignore */ } + } + loadBadges(); + loadEarnedBadges(); + }, [currentStudentId, currentUser?.schoolId]); + if (loading) { return (
@@ -631,6 +727,21 @@ export default function StudentDetailView() { {t('student.export_pdf')} +
@@ -736,6 +847,175 @@ export default function StudentDetailView() {
+ {/* Quick Stats Bar + Badge Collection */} +
+
+ + {stats.totalProgressEntries} {t('student_detail.total_entries')} +
+
+ + {stats.averageMastery.toFixed(1)} {t('student_detail.avg_mastery')} +
+
+ + {student.externalId || student.id.slice(0, 8)} +
+
+ + + {badgeProgress.filter(b => b.earned).length}/{badgeProgress.length} {t('badges.title')} + +
+
+ + {/* Badge Collection Section */} + {badgeProgress.length > 0 && ( + + + +
+ +
+ +
+ {t('badges.collection')} +
+ + {t('badges.earned_count', { earned: badgeProgress.filter(b => b.earned).length, total: badgeProgress.length })} + +
+
+ +
+ {badgeProgress.map((bp) => { + const BadgeIcon = (iconMap as Record)[bp.icon] || Award; + return ( + + + +
{ setSelectedBadgeDetail(bp); setBadgeDetailDialogOpen(true); }} + > + + {bp.earned &&
} + {!bp.earned && bp.progress > 0 && ( +
+
+
+ )} +
+ + + {bp.name} + {bp.earned ? ` — ${t('badges.earned')}` : ` — ${bp.progress}% ${t('badges.progress')}`} + + + + ); + })} +
+ {/* Recent badge awards */} + {earnedBadges.length > 0 && ( +
+

{t('badges.recent')}

+
+ {earnedBadges.slice(0, 5).map((eb) => { + const BadgeIcon = (iconMap as Record)[eb.badge.icon] || Award; + return ( +
+ + {eb.badge.name} + {new Date(eb.awardedAt).toLocaleDateString()} +
+ ); + })} +
+
+ )} + + + + )} + + {/* Badge Detail Dialog */} + + + + + {selectedBadgeDetail && (() => { + const BadgeIcon = (iconMap as Record)[selectedBadgeDetail.icon] || Award; + return ; + })()} + {selectedBadgeDetail?.name} + + + {selectedBadgeDetail?.earned ? t('badges.earned') : t('badges.unearned')} + + + {selectedBadgeDetail && ( +
+
+
+ {(() => { const BadgeIcon = (iconMap as Record)[selectedBadgeDetail.icon] || Award; return ; })()} +
+
+ {!selectedBadgeDetail.earned && ( +
+
+ {t('badges.progress')} + {selectedBadgeDetail.current}/{selectedBadgeDetail.target} +
+ +
+ )} +
+ {t('badges.category')}: {t(`badges.${selectedBadgeDetail.category}`)} +
+
+ )} +
+
+ + {/* QR Code Dialog */} + + + + + + {t('qr.student')} + + {studentName} + +
+ {qrCodeDataUrl ? ( + {t('qr.title')} + ) : ( +
+ +
+ )} +

{studentName}

+

{student.externalId || student.id.slice(0, 8)}

+
+
+ +
+
+
+ {/* Competence Flower (mini radar) */} @@ -2024,6 +2304,561 @@ export default function StudentDetailView() { + {/* ─── Transportation Section ─────────────────────────────────── */} + + +
+ +
+ +
+ {t('transport.title')} + + {transports.length} + +
+ +
+
+ + {transports.length === 0 ? ( +
+ +

{t('transport.no_transport')}

+
+ ) : ( +
+ {transports.map((tr, i) => { + const typeIcon: Record = { + bus: , + tram: , + walk: , + car: , + bike: , + other: , + }; + const typeColor: Record = { + bus: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', + tram: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300', + walk: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300', + car: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300', + bike: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300', + other: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300', + }; + return ( + +
+
+
+ {typeIcon[tr.transportType] || typeIcon.other}{t(`transport.${tr.transportType}`)} + {tr.routeNumber && {t('transport.route')}: {tr.routeNumber}} + {tr.distanceKm != null && {t('transport.distance_km', { count: tr.distanceKm })}} +
+
+ {tr.stopName && {tr.stopName}} + {tr.pickupTime && {t('transport.pickup')}: {tr.pickupTime}} + {tr.dropoffTime && {t('transport.dropoff')}: {tr.dropoffTime}} +
+ {(tr.driverName || tr.driverPhone) && ( +
+ {tr.driverName && {tr.driverName}} + {tr.driverPhone && ( + + {tr.driverPhone} + + + )} +
+ )} + {tr.notes &&

{tr.notes}

} +
+
+ + +
+
+
+ ); + })} +
+ )} +
+
+ + {/* ─── Health Records Section ─────────────────────────────────── */} + + +
+ +
+ +
+ {t('health.title')} + {healthRecord?.isConfidential && ( + + {t('health.confidential')} + + )} + {!healthRecord && 0} +
+ +
+
+ + {!healthRecord ? ( +
+ +

{t('health.no_record')}

+
+ ) : ( +
+
+ {/* Blood Type */} + {healthRecord.bloodType && ( +
+
+ + {t('health.blood_type')} +
+ {healthRecord.bloodType} +
+ )} + + {/* Last Checkup */} + {healthRecord.lastCheckup && ( +
+
+ + {t('health.last_checkup')} +
+ {new Date(healthRecord.lastCheckup).toLocaleDateString()} +
+ )} +
+ + {/* Allergies */} + {healthRecord.allergies && (() => { + const allergies: string[] = JSON.parse(healthRecord.allergies); + if (allergies.length === 0) return null; + return ( +
+
+ + {t('health.allergies')} +
+
+ {allergies.map((a, i) => ( + {a} + ))} +
+
+ ); + })()} + + {/* Medications */} + {healthRecord.medications && (() => { + const medications: Array<{ name: string; dosage: string; frequency: string }> = JSON.parse(healthRecord.medications); + if (medications.length === 0) return null; + return ( +
+
+ + {t('health.medications')} +
+
+ {medications.map((m, i) => ( +
+ {m.name} + {m.dosage && {t('health.dosage')}: {m.dosage}} + {m.frequency && {t('health.frequency')}: {m.frequency}} +
+ ))} +
+
+ ); + })()} + + {/* Conditions */} + {healthRecord.conditions && (() => { + const conditions: string[] = JSON.parse(healthRecord.conditions); + if (conditions.length === 0) return null; + return ( +
+
+ + {t('health.conditions')} +
+
+ {conditions.map((c, i) => ( + {c} + ))} +
+
+ ); + })()} + + {/* Doctor & Insurance */} +
+ {(healthRecord.doctorName || healthRecord.doctorPhone) && ( +
+
+ + {t('health.doctor')} +
+ {healthRecord.doctorName &&

{healthRecord.doctorName}

} + {healthRecord.doctorPhone && ( + + {healthRecord.doctorPhone} + + + )} +
+ )} + {(healthRecord.insuranceNumber || healthRecord.insuranceProvider) && ( +
+
+ + {t('health.insurance')} +
+ {healthRecord.insuranceProvider &&

{healthRecord.insuranceProvider}

} + {healthRecord.insuranceNumber &&

{healthRecord.insuranceNumber}

} +
+ )} +
+ + {healthRecord.notes && ( +

{healthRecord.notes}

+ )} +
+ )} +
+
+ + {/* ─── Transportation Dialog ──────────────────────────────────── */} + + + + {transportEditId ? t('transport.edit') : t('transport.add')} + {t('transport.title')} + +
+
+ + +
+
+
+ + setTransportForm((f) => ({ ...f, routeNumber: e.target.value }))} className="mt-1 rounded-lg" placeholder="42" /> +
+
+ + setTransportForm((f) => ({ ...f, stopName: e.target.value }))} className="mt-1 rounded-lg" placeholder={t('transport.stop_name')} /> +
+
+
+
+ + setTransportForm((f) => ({ ...f, pickupTime: e.target.value }))} className="mt-1 rounded-lg" /> +
+
+ + setTransportForm((f) => ({ ...f, dropoffTime: e.target.value }))} className="mt-1 rounded-lg" /> +
+
+
+
+ + setTransportForm((f) => ({ ...f, driverName: e.target.value }))} className="mt-1 rounded-lg" placeholder={t('transport.driver_name')} /> +
+
+ + setTransportForm((f) => ({ ...f, driverPhone: e.target.value }))} className="mt-1 rounded-lg" placeholder="+49 123 456789" /> +
+
+
+ + setTransportForm((f) => ({ ...f, distanceKm: e.target.value }))} className="mt-1 rounded-lg" placeholder="2.5" /> +
+
+ +