From a94f900ae0873c2c6d137008b172f0df9001222a Mon Sep 17 00:00:00 2001 From: Mustafa Cetin Date: Wed, 29 Jul 2026 11:16:28 +0000 Subject: [PATCH] Round 15: Timetable, Resource Library, Advanced Analytics, Accessibility --- prisma/schema.prisma | 68 + src/app/api/analytics/route.ts | 261 +++ src/app/api/resources/[id]/route.ts | 169 ++ src/app/api/resources/route.ts | 164 ++ src/app/api/timetable/[id]/route.ts | 136 ++ src/app/api/timetable/route.ts | 196 ++ src/app/globals.css | 228 ++ src/components/analytics-view.tsx | 2449 +++++++++++++--------- src/components/app-layout.tsx | 8 + src/components/resource-library-view.tsx | 811 +++++++ src/components/timetable-view.tsx | 668 ++++++ src/lib/api.ts | 61 + src/lib/i18n.ts | 278 +++ src/lib/store.ts | 4 +- worklog.md | 158 +- 15 files changed, 4580 insertions(+), 1079 deletions(-) create mode 100644 src/app/api/resources/[id]/route.ts create mode 100644 src/app/api/resources/route.ts create mode 100644 src/app/api/timetable/[id]/route.ts create mode 100644 src/app/api/timetable/route.ts create mode 100644 src/components/resource-library-view.tsx create mode 100644 src/components/timetable-view.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 44a41b9..f51fd76 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,6 +49,8 @@ model School { selfAssessments SelfAssessment[] learningGoals LearningGoal[] portfolioEntries PortfolioEntry[] + timetableSlots TimetableSlot[] + resources Resource[] district SchoolDistrict? @relation(fields: [districtId], references: [id]) districtId String? } @@ -91,6 +93,8 @@ model User { assignedInterventions BehaviorIntervention[] @relation("InterventionAssignee") homeworks Homework[] announcements Announcement[] + timetableSlots TimetableSlot[] + resources Resource[] } model SchoolYear { @@ -140,6 +144,8 @@ model ClassGroup { homeworks Homework[] announcements Announcement[] selfAssessments SelfAssessment[] + timetableSlots TimetableSlot[] + resources Resource[] } model ClassGroupTeacher { @@ -221,6 +227,8 @@ model Subject { calendarEvents CalendarEvent[] curriculumStandards CurriculumStandard[] homeworks Homework[] + timetableSlots TimetableSlot[] + resources Resource[] } model CompetencyTemplate { @@ -1238,3 +1246,63 @@ model PortfolioEntry { @@index([schoolId, studentId]) @@map("portfolio_entries") } + +// ─── Timetable ────────────────────────────────────────────────────── + +model TimetableSlot { + id String @id @default(cuid()) + schoolId String + classGroupId String + dayOfWeek Int // 0=Monday, 1=Tuesday, ..., 4=Friday + period Int // 1-8 (lesson period) + subjectId String? + teacherId String? + roomId String? // optional room + startTime String // "08:00" + endTime String // "08:45" + isBreak Boolean @default(false) + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + classGroup ClassGroup @relation(fields: [classGroupId], references: [id]) + subject Subject? @relation(fields: [subjectId], references: [id]) + teacher User? @relation(fields: [teacherId], references: [id]) + + @@unique([classGroupId, dayOfWeek, period]) + @@index([schoolId, classGroupId]) + @@map("timetable_slots") +} + +// ─── Digital Resource Library ────────────────────────────────────── + +model Resource { + id String @id @default(cuid()) + schoolId String + authorId String + title String + description String? + resourceType String // "document", "worksheet", "presentation", "video_link", "image", "link", "audio" + url String? // external URL or file path + content String? // text content + subjectId String? + classGroupId String? + gradeLevel Int? + tags String? // JSON array of tags + isPublic Boolean @default(true) + downloadCount Int @default(0) + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + author User @relation(fields: [authorId], references: [id]) + subject Subject? @relation(fields: [subjectId], references: [id]) + classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) + + @@index([schoolId, resourceType]) + @@map("resources") +} diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 712bfce..3b5e932 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -419,6 +419,256 @@ export async function GET(request: Request) { ) / 100 : 0; + // ── Advanced: School Overview counts ── + const classGroupWhere: Record = {}; + if (schoolId) classGroupWhere.schoolId = schoolId; + if (schoolYearId) classGroupWhere.schoolYearId = schoolYearId; + if (classGroupId) classGroupWhere.id = classGroupId; + + const [totalClasses, totalTeachers, totalCompetencies] = await Promise.all([ + db.classGroup.count({ where: classGroupWhere }).catch(() => 0), + db.classGroupTeacher.findMany({ + where: Object.keys(classGroupWhere).length > 0 + ? { classGroup: classGroupWhere } + : {}, + select: { userId: true }, + }).then((rows) => new Set(rows.map((r) => r.userId)).size).catch(() => 0), + db.competency.count().catch(() => 0), + ]); + + // ── Advanced: Subject Mastery Avg ── + const subjectAgg = new Map(); + for (const e of entries) { + const cat = e.competency?.category; + if (!cat) continue; + const sid = cat.id; + const cur = subjectAgg.get(sid) ?? { subjectName: cat.name, total: 0, count: 0 }; + cur.total += e.masteryLevelValue; + cur.count += 1; + subjectAgg.set(sid, cur); + } + const subjectMasteryAvg = Array.from(subjectAgg.entries()) + .map(([categoryId, v]) => ({ + categoryId, + categoryName: v.subjectName, + avgMastery: v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0, + entryCount: v.count, + })) + .sort((a, b) => b.avgMastery - a.avgMastery); + + // ── Advanced: Teacher Performance ── + const teacherAssessments = await db.assessment.findMany({ + where: { + ...(classGroupId ? { classGroupId } : {}), + ...(schoolYearId ? { classGroup: { schoolYearId } } : {}), + ...(schoolId ? { classGroup: { schoolId } } : {}), + }, + select: { + teacherId: true, + teacher: { select: { firstName: true, lastName: true } }, + }, + }).catch(() => []); + + const teacherProgressEntries = await db.learningProgressEntry.findMany({ + where, + select: { + classGroupId: true, + classGroup: { + select: { + teachers: { select: { userId: true, user: { select: { firstName: true, lastName: true } } } }, + }, + }, + }, + }).catch(() => []); + + // Build teacher stats + const teacherStatsMap = new Map(); + for (const a of teacherAssessments) { + const name = `${a.teacher?.firstName ?? ''} ${a.teacher?.lastName ?? ''}`.trim(); + const cur = teacherStatsMap.get(a.teacherId) ?? { name: name || a.teacherId, assessmentCount: 0, progressCount: 0 }; + cur.assessmentCount += 1; + teacherStatsMap.set(a.teacherId, cur); + } + for (const p of teacherProgressEntries) { + for (const t of p.classGroup?.teachers ?? []) { + const name = `${t.user?.firstName ?? ''} ${t.user?.lastName ?? ''}`.trim(); + const cur = teacherStatsMap.get(t.userId) ?? { name: name || t.userId, assessmentCount: 0, progressCount: 0 }; + cur.progressCount += 1; + teacherStatsMap.set(t.userId, cur); + } + } + + // Compute student improvement rate per teacher + // Batch-fetch all class teachers for students in scope + const classGroupIds = new Set(enrollments.map((enr) => enr.classGroup?.id).filter(Boolean) as string[]); + const allClassTeachers = classGroupIds.size > 0 + ? await db.classGroupTeacher.findMany({ + where: { classGroupId: { in: Array.from(classGroupIds) } }, + select: { classGroupId: true, userId: true }, + }).catch(() => []) + : []; + const classTeacherMap = new Map(); + for (const ct of allClassTeachers) { + const arr = classTeacherMap.get(ct.classGroupId) ?? []; + arr.push(ct.userId); + classTeacherMap.set(ct.classGroupId, arr); + } + + const teacherImprovementMap = new Map(); + for (const [studentId, eArr] of studentEntries) { + if (eArr.length < 2) continue; + const sorted = eArr.sort((a, b) => a.date.getTime() - b.date.getTime()); + const firstHalf = sorted.slice(0, Math.floor(sorted.length / 2)); + const secondHalf = sorted.slice(Math.floor(sorted.length / 2)); + const firstAvg = firstHalf.reduce((s, e) => s + e.masteryLevelValue, 0) / firstHalf.length; + const secondAvg = secondHalf.reduce((s, e) => s + e.masteryLevelValue, 0) / secondHalf.length; + const improved = secondAvg > firstAvg; + + // Find the student's class teacher(s) for attribution + const studentEnr = enrollments.find((enr) => enr.studentId === studentId); + const teacherIds = studentEnr?.classGroup?.id ? (classTeacherMap.get(studentEnr.classGroup.id) ?? []) : []; + for (const tid of teacherIds) { + const cur = teacherImprovementMap.get(tid) ?? { improved: 0, total: 0 }; + cur.total += 1; + if (improved) cur.improved += 1; + teacherImprovementMap.set(tid, cur); + } + } + + // Notebook usage per teacher + const teacherNotebookCounts = await db.notebook.groupBy({ + by: ['ownerId'], + where: { + ownerType: 'TEACHER', + ...(schoolId ? { schoolId } : {}), + deletedAt: null, + }, + _count: { id: true }, + }).catch(() => []); + + const teacherPerformance = Array.from(teacherStatsMap.entries()) + .map(([teacherId, v]) => { + const imp = teacherImprovementMap.get(teacherId); + const notebook = teacherNotebookCounts.find((n) => n.ownerId === teacherId); + return { + teacherId, + teacherName: v.name, + assessmentCount: v.assessmentCount, + progressCount: v.progressCount, + improvementRate: imp && imp.total > 0 ? Math.round((imp.improved / imp.total) * 1000) / 10 : 0, + notebookCount: notebook?._count.id ?? 0, + }; + }) + .sort((a, b) => b.assessmentCount - a.assessmentCount); + + // ── Advanced: Self-Assessment vs Teacher Gap Analysis ── + const selfAssessmentData = await db.selfAssessment.findMany({ + where: { + ...(schoolId ? { schoolId } : {}), + ...(classGroupId ? { classGroupId } : {}), + deletedAt: null, + }, + select: { + studentId: true, + competencyId: true, + selfLevel: true, + createdAt: true, + student: { select: { firstName: true, lastName: true } }, + }, + }).catch(() => []); + + // For each self-assessment, find the latest teacher progress entry for the same student+competency + const gapAnalysis: Array<{ + studentId: string; + studentName: string; + competencyId: string; + selfLevel: number; + teacherLevel: number; + gap: number; + }> = []; + for (const sa of selfAssessmentData) { + const teacherEntries = studentEntries.get(sa.studentId) + ?.filter((e) => e.masteryLevelValue > 0) + ?? []; + if (teacherEntries.length === 0) continue; + const latestTeacher = teacherEntries.sort((a, b) => b.date.getTime() - a.date.getTime())[0]; + const gap = sa.selfLevel - latestTeacher.masteryLevelValue; + gapAnalysis.push({ + studentId: sa.studentId, + studentName: `${sa.student?.firstName ?? ''} ${sa.student?.lastName ?? ''}`.trim() || sa.studentId, + competencyId: sa.competencyId, + selfLevel: sa.selfLevel, + teacherLevel: Math.round(latestTeacher.masteryLevelValue * 100) / 100, + gap: Math.round(gap * 100) / 100, + }); + } + + // Aggregate gap analysis for chart + const selfVsTeacherAgg = gapAnalysis.length > 0 ? { + avgSelfLevel: Math.round((gapAnalysis.reduce((s, g) => s + g.selfLevel, 0) / gapAnalysis.length) * 100) / 100, + avgTeacherLevel: Math.round((gapAnalysis.reduce((s, g) => s + g.teacherLevel, 0) / gapAnalysis.length) * 100) / 100, + avgGap: Math.round((gapAnalysis.reduce((s, g) => s + g.gap, 0) / gapAnalysis.length) * 100) / 100, + totalComparisons: gapAnalysis.length, + } : null; + + // ── Advanced: Competency Coverage ── + const assessedCompetencyIds = new Set(entries.map((e) => e.competencyId)); + const competencyCoveragePct = totalCompetencies > 0 + ? Math.round((assessedCompetencyIds.size / totalCompetencies) * 1000) / 10 + : 0; + + // ── Advanced: Students Excelling (improvement trajectory) ── + const excellingStudents: Array<{ + studentId: string; + studentName: string; + className: string; + latestMastery: number; + improvement: number; + }> = []; + for (const [studentId, eArr] of studentEntries) { + if (eArr.length < 4) continue; + const sorted = eArr.sort((a, b) => a.date.getTime() - b.date.getTime()); + const first2 = sorted.slice(0, 2); + const last2 = sorted.slice(-2); + const firstAvg = first2.reduce((s, e) => s + e.masteryLevelValue, 0) / 2; + const lastAvg = last2.reduce((s, e) => s + e.masteryLevelValue, 0) / 2; + const improvement = Math.round((lastAvg - firstAvg) * 100) / 100; + if (improvement > 0.5) { + const meta = studentMeta.get(studentId); + if (meta) { + excellingStudents.push({ + studentId, + studentName: meta.studentName, + className: meta.className, + latestMastery: Math.round(lastAvg * 100) / 100, + improvement, + }); + } + } + } + excellingStudents.sort((a, b) => b.improvement - a.improvement); + + // ── Advanced: Class Radar Data ── + // For each class, compute multi-dimensional scores + const classRadarData = Array.from(classAgg.entries()).map(([classId, v]) => { + const classStudents = Array.from(v.students); + const classAttendance = classStudents.reduce((sum, sid) => { + const att = attendanceMap.get(sid); + return sum + (att && att.total > 0 ? (att.present / att.total) * 100 : 0); + }, 0) / (classStudents.length || 1); + const engagement = Math.min(100, (v.count / Math.max(1, v.students.size)) * 20); + const progress = v.count > 0 ? (v.total / v.count / 4) * 100 : 0; + return { + classId, + className: v.className, + mastery: Math.round((v.count > 0 ? (v.total / v.count / 4) * 100 : 0) * 10) / 10, + attendance: Math.round(classAttendance * 10) / 10, + engagement: Math.round(engagement * 10) / 10, + progress: Math.round(progress * 10) / 10, + behavior: Math.round(((v.count > 0 ? (v.total / v.count / 4) * 100 : 0) * 0.6 + classAttendance * 0.4) * 10) / 10, + }; + }); + return NextResponse.json({ masteryTrend, classComparison, @@ -431,6 +681,17 @@ export async function GET(request: Request) { totalEntries, totalStudents, overallAvgMastery, + // New fields + totalClasses, + totalTeachers, + totalCompetencies, + competencyCoveragePct, + subjectMasteryAvg, + teacherPerformance, + selfVsTeacherAgg, + gapAnalysis: gapAnalysis.slice(0, 50), + excellingStudents: excellingStudents.slice(0, 10), + classRadarData, }); } catch (error) { console.error('Analytics GET error:', error); diff --git a/src/app/api/resources/[id]/route.ts b/src/app/api/resources/[id]/route.ts new file mode 100644 index 0000000..7ce56b6 --- /dev/null +++ b/src/app/api/resources/[id]/route.ts @@ -0,0 +1,169 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const resourceTypeEnum = z.enum([ + 'document', 'worksheet', 'presentation', 'video_link', 'image', 'link', 'audio', +]); + +const updateResourceSchema = z.object({ + title: z.string().min(1).max(300).optional(), + description: z.string().max(5000).optional().nullable(), + resourceType: resourceTypeEnum.optional(), + url: z.string().optional().nullable(), + content: z.string().optional().nullable(), + subjectId: z.string().optional().nullable(), + classGroupId: z.string().optional().nullable(), + gradeLevel: z.number().int().min(1).max(13).optional().nullable(), + tags: z.array(z.string()).optional().nullable(), + isPublic: z.boolean().optional(), + incrementDownload: z.boolean().optional(), +}); + +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 }); + } + + const { id } = await params; + const resource = await db.resource.findUnique({ + where: { id, deletedAt: null }, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + }, + }); + + if (!resource) { + return NextResponse.json({ error: 'Resource not found' }, { status: 404 }); + } + + // Students can only see public resources + if (session.user?.role === 'STUDENT' && !resource.isPublic) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(resource); + } catch (error) { + console.error('Resources 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 }); + } + if (!isTeacherOrAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json(); + const parsed = updateResourceSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const existing = await db.resource.findUnique({ where: { id, deletedAt: null } }); + if (!existing) { + return NextResponse.json({ error: 'Resource not found' }, { status: 404 }); + } + + // Only the author or admins can edit + if (session.user?.role === 'TEACHER' && existing.authorId !== session.userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Handle download increment separately + if (parsed.data.incrementDownload) { + await db.resource.update({ + where: { id }, + data: { downloadCount: { increment: 1 } }, + }); + return NextResponse.json({ success: true }); + } + + const updateData: Record = {}; + if (parsed.data.title !== undefined) updateData.title = parsed.data.title; + if (parsed.data.description !== undefined) updateData.description = parsed.data.description; + if (parsed.data.resourceType !== undefined) updateData.resourceType = parsed.data.resourceType; + if (parsed.data.url !== undefined) updateData.url = parsed.data.url; + if (parsed.data.content !== undefined) updateData.content = parsed.data.content; + if (parsed.data.subjectId !== undefined) updateData.subjectId = parsed.data.subjectId || null; + if (parsed.data.classGroupId !== undefined) updateData.classGroupId = parsed.data.classGroupId || null; + if (parsed.data.gradeLevel !== undefined) updateData.gradeLevel = parsed.data.gradeLevel; + if (parsed.data.tags !== undefined) updateData.tags = parsed.data.tags ? JSON.stringify(parsed.data.tags) : null; + if (parsed.data.isPublic !== undefined) updateData.isPublic = parsed.data.isPublic; + + const resource = await db.resource.update({ + where: { id }, + data: updateData, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + }, + }); + + return NextResponse.json(resource); + } catch (error) { + console.error('Resources 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 }); + } + if (!isTeacherOrAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const existing = await db.resource.findUnique({ where: { id, deletedAt: null } }); + if (!existing) { + return NextResponse.json({ error: 'Resource not found' }, { status: 404 }); + } + + // Only the author or admins can delete + if (session.user?.role === 'TEACHER' && existing.authorId !== session.userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + await db.resource.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Resources DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/resources/route.ts b/src/app/api/resources/route.ts new file mode 100644 index 0000000..0ed78b4 --- /dev/null +++ b/src/app/api/resources/route.ts @@ -0,0 +1,164 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const resourceTypeEnum = z.enum([ + 'document', 'worksheet', 'presentation', 'video_link', 'image', 'link', 'audio', +]); + +const createResourceSchema = z.object({ + schoolId: z.string().min(1), + title: z.string().min(1).max(300), + description: z.string().max(5000).optional().nullable(), + resourceType: resourceTypeEnum, + url: z.string().optional().nullable(), + content: z.string().optional().nullable(), + subjectId: z.string().optional().nullable(), + classGroupId: z.string().optional().nullable(), + gradeLevel: z.number().int().min(1).max(13).optional().nullable(), + tags: z.array(z.string()).optional().nullable(), + isPublic: z.boolean().optional().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 }); + } + + const { searchParams } = new URL(request.url); + const schoolIdParam = searchParams.get('schoolId'); + const resourceType = searchParams.get('resourceType'); + const subjectId = searchParams.get('subjectId'); + const classGroupId = searchParams.get('classGroupId'); + const search = searchParams.get('search'); + const isPublic = searchParams.get('isPublic'); + + 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 }; + + // Students can only see public resources + if (session.user?.role === 'STUDENT') { + where.isPublic = true; + } + + if (resourceType && resourceType !== 'all') where.resourceType = resourceType; + if (subjectId && subjectId !== 'all') where.subjectId = subjectId; + if (classGroupId && classGroupId !== 'all') where.classGroupId = classGroupId; + if (isPublic === 'true') where.isPublic = true; + if (isPublic === 'false') where.isPublic = false; + + if (search) { + where.OR = [ + { title: { contains: search } }, + { description: { contains: search } }, + ]; + } + + const resources = await db.resource.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + }, + }); + + return NextResponse.json(resources); + } catch (error) { + console.error('Resources 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 = createResourceSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const { schoolId, subjectId, classGroupId, tags, ...rest } = parsed.data; + + // Verify school + 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 }); + } + + // Verify subject if provided + if (subjectId) { + const subject = await db.subject.findUnique({ where: { id: subjectId } }); + if (!subject) { + return NextResponse.json({ error: 'Subject not found' }, { status: 404 }); + } + } + + // Verify classGroup if provided + if (classGroupId) { + const classGroup = await db.classGroup.findUnique({ where: { id: classGroupId } }); + if (!classGroup || classGroup.schoolId !== schoolId) { + return NextResponse.json({ error: 'Class group not found in this school' }, { status: 404 }); + } + } + + const resource = await db.resource.create({ + data: { + schoolId, + authorId: session.userId, + title: rest.title, + description: rest.description ?? null, + resourceType: rest.resourceType, + url: rest.url ?? null, + content: rest.content ?? null, + subjectId: subjectId || null, + classGroupId: classGroupId || null, + gradeLevel: rest.gradeLevel ?? null, + tags: tags ? JSON.stringify(tags) : null, + isPublic: rest.isPublic, + }, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + }, + }); + + return NextResponse.json(resource, { status: 201 }); + } catch (error) { + console.error('Resources POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/timetable/[id]/route.ts b/src/app/api/timetable/[id]/route.ts new file mode 100644 index 0000000..7d1b12d --- /dev/null +++ b/src/app/api/timetable/[id]/route.ts @@ -0,0 +1,136 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const updateSlotSchema = z.object({ + subjectId: z.string().optional().nullable(), + teacherId: z.string().optional().nullable(), + roomId: z.string().optional().nullable(), + startTime: z.string().optional(), + endTime: z.string().optional(), + dayOfWeek: z.number().int().min(0).max(4).optional(), + period: z.number().int().min(1).max(8).optional(), + isBreak: z.boolean().optional(), +}); + +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 }); + } + + const { id } = await params; + const slot = await db.timetableSlot.findUnique({ + where: { id, deletedAt: null }, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + subject: { select: { id: true, name: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + if (!slot) { + return NextResponse.json({ error: 'Timetable slot not found' }, { status: 404 }); + } + + return NextResponse.json(slot); + } catch (error) { + console.error('Timetable 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 }); + } + if (!isTeacherOrAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json(); + const parsed = updateSlotSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const existing = await db.timetableSlot.findUnique({ where: { id, deletedAt: null } }); + if (!existing) { + return NextResponse.json({ error: 'Timetable slot not found' }, { status: 404 }); + } + + const updateData: Record = {}; + if (parsed.data.subjectId !== undefined) updateData.subjectId = parsed.data.subjectId || null; + if (parsed.data.teacherId !== undefined) updateData.teacherId = parsed.data.teacherId || null; + if (parsed.data.roomId !== undefined) updateData.roomId = parsed.data.roomId || null; + if (parsed.data.startTime !== undefined) updateData.startTime = parsed.data.startTime; + if (parsed.data.endTime !== undefined) updateData.endTime = parsed.data.endTime; + if (parsed.data.dayOfWeek !== undefined) updateData.dayOfWeek = parsed.data.dayOfWeek; + if (parsed.data.period !== undefined) updateData.period = parsed.data.period; + if (parsed.data.isBreak !== undefined) updateData.isBreak = parsed.data.isBreak; + + const slot = await db.timetableSlot.update({ + where: { id }, + data: updateData, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + subject: { select: { id: true, name: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(slot); + } catch (error) { + console.error('Timetable 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 }); + } + if (!isTeacherOrAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const existing = await db.timetableSlot.findUnique({ where: { id, deletedAt: null } }); + if (!existing) { + return NextResponse.json({ error: 'Timetable slot not found' }, { status: 404 }); + } + + await db.timetableSlot.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Timetable DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/timetable/route.ts b/src/app/api/timetable/route.ts new file mode 100644 index 0000000..20e7551 --- /dev/null +++ b/src/app/api/timetable/route.ts @@ -0,0 +1,196 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const createSlotSchema = z.object({ + schoolId: z.string().min(1), + classGroupId: z.string().min(1), + dayOfWeek: z.number().int().min(0).max(4), + period: z.number().int().min(1).max(8), + subjectId: z.string().optional().nullable(), + teacherId: z.string().optional().nullable(), + roomId: z.string().optional().nullable(), + startTime: z.string().min(1), + endTime: z.string().min(1), + isBreak: z.boolean().optional().default(false), +}); + +const batchCreateSchema = z.object({ + schoolId: z.string().min(1), + classGroupId: z.string().min(1), + slots: z.array(createSlotSchema.omit({ schoolId: true, classGroupId: 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 }); + } + + const { searchParams } = new URL(request.url); + const schoolIdParam = searchParams.get('schoolId'); + const classGroupId = searchParams.get('classGroupId'); + + 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 (classGroupId && classGroupId !== 'all') where.classGroupId = classGroupId; + + const slots = await db.timetableSlot.findMany({ + where, + orderBy: [{ dayOfWeek: 'asc' }, { period: 'asc' }], + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + subject: { select: { id: true, name: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(slots); + } catch (error) { + console.error('Timetable 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(); + + // Check if this is a batch create request + if (Array.isArray(body.slots)) { + const parsed = batchCreateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const { schoolId, classGroupId, slots } = parsed.data; + + // Verify school + 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 }); + } + + // Verify classGroup + const classGroup = await db.classGroup.findUnique({ where: { id: classGroupId } }); + if (!classGroup || classGroup.schoolId !== schoolId) { + return NextResponse.json({ error: 'Class group not found in this school' }, { status: 404 }); + } + + const created = await db.timetableSlot.createMany({ + data: slots.map((slot) => ({ + schoolId, + classGroupId, + dayOfWeek: slot.dayOfWeek, + period: slot.period, + subjectId: slot.subjectId || null, + teacherId: slot.teacherId || null, + roomId: slot.roomId || null, + startTime: slot.startTime, + endTime: slot.endTime, + isBreak: slot.isBreak, + })), + skipDuplicates: true, + }); + + return NextResponse.json({ count: created.count }, { status: 201 }); + } + + // Single slot create + const parsed = createSlotSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const { schoolId, classGroupId, subjectId, teacherId, ...rest } = parsed.data; + + // Verify school + 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 }); + } + + // Verify classGroup + const classGroup = await db.classGroup.findUnique({ where: { id: classGroupId } }); + if (!classGroup || classGroup.schoolId !== schoolId) { + return NextResponse.json({ error: 'Class group not found in this school' }, { status: 404 }); + } + + // Verify subject if provided + if (subjectId) { + const subject = await db.subject.findUnique({ where: { id: subjectId } }); + if (!subject) { + return NextResponse.json({ error: 'Subject not found' }, { status: 404 }); + } + } + + // Verify teacher if provided + if (teacherId) { + const teacher = await db.user.findUnique({ where: { id: teacherId, deletedAt: null } }); + if (!teacher) { + return NextResponse.json({ error: 'Teacher not found' }, { status: 404 }); + } + } + + const slot = await db.timetableSlot.create({ + data: { + schoolId, + classGroupId, + subjectId: subjectId || null, + teacherId: teacherId || null, + roomId: rest.roomId || null, + dayOfWeek: rest.dayOfWeek, + period: rest.period, + startTime: rest.startTime, + endTime: rest.endTime, + isBreak: rest.isBreak, + }, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + subject: { select: { id: true, name: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(slot, { status: 201 }); + } catch (error) { + console.error('Timetable 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 7b472dc..527524b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1748,6 +1748,234 @@ background: linear-gradient(to right, transparent, oklch(0.65 0.17 155 / 0.3), transparent); } + /* ─── Advanced Analytics Styles ──────────────────────────────────── */ + + /* Chart container with rounded corners and shadow */ + .chart-container { + background: oklch(1 0 0); + border-radius: 1rem; + box-shadow: 0 1px 3px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.04); + border: 1px solid oklch(0.93 0.005 155); + overflow: hidden; + } + .dark .chart-container { + background: oklch(0.18 0.015 260); + box-shadow: 0 1px 3px oklch(0 0 0 / 0.3), 0 0 0 1px oklch(1 0 0 / 6%); + border-color: oklch(1 0 0 / 8%); + } + + /* Stat card with gradient */ + .stat-card { + position: relative; + border-radius: 1rem; + padding: 1.25rem; + overflow: hidden; + transition: transform 0.2s ease-out, box-shadow 0.2s ease-out; + } + .stat-card::before { + content: ''; + position: absolute; + inset: 0; + opacity: 0.06; + background: linear-gradient(135deg, oklch(0.55 0.17 155), oklch(0.55 0.14 175)); + pointer-events: none; + } + .stat-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px -6px oklch(0.55 0.17 155 / 0.15); + } + .dark .stat-card:hover { + box-shadow: 0 8px 24px -6px oklch(0.65 0.17 155 / 0.25); + } + + /* Heatmap cell styling */ + .heatmap-cell { + border-radius: 3px; + transition: all 0.15s ease; + min-width: 14px; + min-height: 14px; + } + .heatmap-cell:hover { + transform: scale(1.3); + box-shadow: 0 0 0 2px oklch(0.55 0.17 155 / 0.3); + z-index: 1; + } + + /* Analytics dashboard grid layout */ + .analytics-dashboard { + display: grid; + gap: 1.5rem; + grid-template-columns: 1fr; + } + @media (min-width: 768px) { + .analytics-dashboard { + grid-template-columns: repeat(2, 1fr); + } + } + .analytics-dashboard-full { + grid-column: 1 / -1; + } + + /* Chart loading skeleton */ + .loading-skeleton-chart { + position: relative; + overflow: hidden; + border-radius: 1rem; + background: oklch(0.94 0.005 155 / 0.5); + min-height: 200px; + } + .dark .loading-skeleton-chart { + background: oklch(0.22 0.015 260 / 0.5); + } + .loading-skeleton-chart::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent 0%, + oklch(0.97 0.01 155 / 0.4) 50%, + transparent 100% + ); + animation: skeleton-slide 1.5s ease-in-out infinite; + } + .dark .loading-skeleton-chart::after { + background: linear-gradient( + 90deg, + transparent 0%, + oklch(0.30 0.01 260 / 0.3) 50%, + transparent 100% + ); + } + + /* Predictive badge — risk / excelling */ + .predictive-badge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + line-height: 1.25rem; + } + .predictive-badge-risk { + background: oklch(0.55 0.22 25 / 0.1); + color: oklch(0.55 0.22 25); + border: 1px solid oklch(0.55 0.22 25 / 0.2); + } + .dark .predictive-badge-risk { + background: oklch(0.55 0.22 25 / 0.15); + color: oklch(0.70 0.19 22); + border-color: oklch(0.55 0.22 25 / 0.3); + } + .predictive-badge-excel { + background: oklch(0.55 0.17 155 / 0.1); + color: oklch(0.55 0.17 155); + border: 1px solid oklch(0.55 0.17 155 / 0.2); + } + .dark .predictive-badge-excel { + background: oklch(0.55 0.17 155 / 0.15); + color: oklch(0.70 0.17 155); + border-color: oklch(0.65 0.17 155 / 0.3); + } + .predictive-badge-attention { + background: oklch(0.72 0.19 85 / 0.1); + color: oklch(0.60 0.18 50); + border: 1px solid oklch(0.72 0.19 85 / 0.2); + } + .dark .predictive-badge-attention { + background: oklch(0.72 0.19 85 / 0.15); + color: oklch(0.77 0.19 85); + border-color: oklch(0.72 0.19 85 / 0.3); + } + + /* Analytics section tab navigation */ + .analytics-tab { + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-size: 0.8125rem; + font-weight: 500; + color: oklch(0.55 0.02 260); + transition: all 0.2s ease; + cursor: pointer; + min-height: 44px; + display: flex; + align-items: center; + gap: 0.375rem; + border: 1px solid transparent; + } + .analytics-tab:hover { + background: oklch(0.55 0.17 155 / 0.06); + color: oklch(0.55 0.17 155); + } + .analytics-tab.active { + background: oklch(0.55 0.17 155 / 0.1); + color: oklch(0.55 0.17 155); + border-color: oklch(0.55 0.17 155 / 0.2); + font-weight: 600; + } + .dark .analytics-tab:hover { + background: oklch(0.55 0.17 155 / 0.1); + color: oklch(0.65 0.17 155); + } + .dark .analytics-tab.active { + background: oklch(0.55 0.17 155 / 0.15); + color: oklch(0.65 0.17 155); + border-color: oklch(0.65 0.17 155 / 0.3); + } + + /* Export button group */ + .export-btn-group { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + } + .export-btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-size: 0.8125rem; + font-weight: 500; + min-height: 44px; + border: 1px solid oklch(0.93 0.005 155); + background: oklch(1 0 0); + color: oklch(0.35 0.02 260); + transition: all 0.2s ease; + cursor: pointer; + } + .export-btn:hover { + background: oklch(0.55 0.17 155 / 0.06); + border-color: oklch(0.55 0.17 155 / 0.2); + color: oklch(0.55 0.17 155); + } + .dark .export-btn { + background: oklch(0.18 0.015 260); + border-color: oklch(1 0 0 / 8%); + color: oklch(0.70 0.02 260); + } + .dark .export-btn:hover { + background: oklch(0.55 0.17 155 / 0.1); + border-color: oklch(0.65 0.17 155 / 0.3); + color: oklch(0.65 0.17 155); + } + + /* Chart description for screen readers */ + .chart-sr-desc { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + /* ─── Offline / PWA Styles ──────────────────────────────────────── */ /* Offline indicator bar */ diff --git a/src/components/analytics-view.tsx b/src/components/analytics-view.tsx index c089e14..ae21939 100644 --- a/src/components/analytics-view.tsx +++ b/src/components/analytics-view.tsx @@ -1,7 +1,7 @@ 'use client'; -import React, { useEffect, useMemo, useState } from 'react'; -import { motion } from 'framer-motion'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { motion, useReducedMotion } from 'framer-motion'; import { LineChart, Line, @@ -18,6 +18,16 @@ import { Legend, ComposedChart, ReferenceLine, + RadarChart, + PolarGrid, + PolarAngleAxis, + PolarRadiusAxis, + Radar, + ScatterChart, + Scatter, + ZAxis, + Area, + AreaChart, } from 'recharts'; import { TrendingUp, @@ -49,6 +59,25 @@ import { Flame as FlameIcon, AlertCircle, PartyPopper, + School, + Download, + Printer, + FileSpreadsheet, + Image as ImageIcon, + BookOpen, + UserCheck, + GraduationCap, + Target, + Brain, + Radar as RadarIcon, + Grid3x3, + BarChartHorizontal, + ArrowUpRight, + Heart, + Search, + ChevronDown, + Keyboard, + Accessibility, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -77,6 +106,13 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; import { useAppStore } from '@/lib/store'; import { t } from '@/lib/i18n'; import { @@ -93,12 +129,16 @@ import { import { toast } from 'sonner'; const MASTERY_COLORS: Record = { - 1: '#ef4444', // rose - 2: '#f59e0b', // amber - 3: '#10b981', // emerald - 4: '#14b8a6', // teal + 1: '#ef4444', + 2: '#f59e0b', + 3: '#10b981', + 4: '#14b8a6', }; +const CHART_COLORS = ['#10b981', '#14b8a6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#f97316']; + +const RADAR_COLORS = ['#10b981', '#14b8a6', '#f59e0b', '#8b5cf6', '#ef4444', '#ec4899']; + const HEATMAP_LEVELS = [ { max: 0, bg: 'bg-gray-100 dark:bg-gray-800/60', label: '0' }, { max: 1, bg: 'bg-emerald-200/60 dark:bg-emerald-900/30', label: '1' }, @@ -200,7 +240,112 @@ const SIGNAL_META: Record< key: 'analytics.signal_declining', chip: 'bg-orange-50 text-orange-700 dark:bg-orange-900/20 dark:text-orange-300', }, +}; + +type AnalyticsTab = 'school' | 'performance' | 'class' | 'teacher' | 'predictive' | 'export'; + +const TAB_META: { key: AnalyticsTab; icon: React.ReactNode; labelKey: string }[] = [ + { key: 'school', icon: , labelKey: 'analytics.section_school' }, + { key: 'performance', icon: , labelKey: 'analytics.section_performance' }, + { key: 'class', icon: , labelKey: 'analytics.section_class' }, + { key: 'teacher', icon: , labelKey: 'analytics.section_teacher' }, + { key: 'predictive', icon: , labelKey: 'analytics.section_predictive' }, + { key: 'export', icon: , labelKey: 'analytics.section_export' }, +]; + +// ── Count-up hook ── +function useCountUp(target: number, duration = 800) { + const [value, setValue] = useState(0); + const prefersReduced = useReducedMotion(); + useEffect(() => { + if (prefersReduced || target === 0) { + setValue(target); + return; + } + let start = 0; + const startTime = performance.now(); + function step(now: number) { + const elapsed = now - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + setValue(Math.round(eased * target * 100) / 100); + if (progress < 1) requestAnimationFrame(step); + } + requestAnimationFrame(step); + }, [target, duration, prefersReduced]); + return value; +} + +// ── Stat Card Component ── +function StatCard({ icon, label, value, suffix, color }: { + icon: React.ReactNode; + label: string; + value: number; + suffix?: string; + color: 'emerald' | 'teal' | 'amber' | 'violet' | 'rose'; +}) { + const displayValue = useCountUp(value); + const colorMap = { + emerald: 'from-emerald-50/80 to-emerald-100/40 dark:from-emerald-900/15 dark:to-emerald-800/10 border-emerald-200/30 dark:border-emerald-900/20 text-emerald-700 dark:text-emerald-300', + teal: 'from-teal-50/80 to-teal-100/40 dark:from-teal-900/15 dark:to-teal-800/10 border-teal-200/30 dark:border-teal-900/20 text-teal-700 dark:text-teal-300', + amber: 'from-amber-50/80 to-amber-100/40 dark:from-amber-900/15 dark:to-amber-800/10 border-amber-200/30 dark:border-amber-900/20 text-amber-700 dark:text-amber-300', + violet: 'from-violet-50/80 to-violet-100/40 dark:from-violet-900/15 dark:to-violet-800/10 border-violet-200/30 dark:border-violet-900/20 text-violet-700 dark:text-violet-300', + rose: 'from-rose-50/80 to-rose-100/40 dark:from-rose-900/15 dark:to-rose-800/10 border-rose-200/30 dark:border-rose-900/20 text-rose-700 dark:text-rose-300', }; + const iconColorMap = { + emerald: 'text-emerald-500', + teal: 'text-teal-500', + amber: 'text-amber-500', + violet: 'text-violet-500', + rose: 'text-rose-500', + }; + return ( + +
{icon}
+

+ {Number.isInteger(value) ? displayValue : displayValue.toFixed(2)} + {suffix && {suffix}} +

+

{label}

+
+ ); +} + +// ── Empty State Component ── +function EmptyState({ icon, title, description }: { icon: React.ReactNode; title: string; description: string }) { + return ( +
+
+ {icon} +
+

{title}

+

{description}

+
+ ); +} + +// ── Chart Skeleton ── +function ChartSkeleton() { + return ( +