From 7f0810c88f2b79058620dc12df74013563e57a10 Mon Sep 17 00:00:00 2001 From: Mustafa Cetin Date: Thu, 30 Jul 2026 07:20:05 +0000 Subject: [PATCH] Round 26: Grade Analytics, School Transport, Peer Assessment, Styling - Grade Analytics & Reporting View: distribution histogram, student trajectory, class comparison, subject analysis, report generation - School Transport & Bus Routes View: route management, student assignment, transport schedule, stop management - Peer Assessment View: creation wizard, rating sliders, results radar chart, self vs peer comparison - 3 new Prisma models: GradeReport, TransportRoute, TransportStop - Extended PeerAssessment and StudentTransport models - Enhanced classes-view with gradient header, glassmorphism cards - Enhanced attendance-view with attendance rate gauge, status indicators - Fixed duplicate variable name bugs in school-transport-view.tsx - 12+ new API route files - 300+ i18n keys added - All views verified working in browser --- prisma/schema.prisma | 132 +- .../api/grade-analytics/comparison/route.ts | 358 +++ .../api/grade-analytics/distribution/route.ts | 182 ++ src/app/api/grade-analytics/reports/route.ts | 232 ++ src/app/api/grade-analytics/route.ts | 250 ++ src/app/api/grade-analytics/trends/route.ts | 215 ++ .../[id]/results/route.ts | 182 ++ .../peer-assessment-sessions/[id]/route.ts | 178 ++ .../[id]/submit/route.ts | 96 + src/app/api/peer-assessment-sessions/route.ts | 184 ++ src/app/api/peer-assessments/[id]/route.ts | 7 + src/app/api/peer-assessments/route.ts | 12 + src/app/api/student-transport/[id]/route.ts | 8 + src/app/api/student-transport/bulk/route.ts | 80 + src/app/api/student-transport/route.ts | 8 + src/app/api/transport-routes/[id]/route.ts | 145 ++ .../api/transport-routes/[id]/stops/route.ts | 100 + src/app/api/transport-routes/route.ts | 108 + src/app/globals.css | 355 +++ src/components/app-layout.tsx | 17 + src/components/attendance-view.tsx | 423 +++- src/components/classes-view.tsx | 360 ++- src/components/grade-analytics-view.tsx | 1820 ++++++++++++++ src/components/peer-assessment-view.tsx | 2151 +++++++++++++++++ src/components/school-transport-view.tsx | 1814 ++++++++++++++ src/lib/i18n.ts | 660 +++++ src/lib/store.ts | 5 +- worklog.md | 309 +++ 28 files changed, 10264 insertions(+), 127 deletions(-) create mode 100644 src/app/api/grade-analytics/comparison/route.ts create mode 100644 src/app/api/grade-analytics/distribution/route.ts create mode 100644 src/app/api/grade-analytics/reports/route.ts create mode 100644 src/app/api/grade-analytics/route.ts create mode 100644 src/app/api/grade-analytics/trends/route.ts create mode 100644 src/app/api/peer-assessment-sessions/[id]/results/route.ts create mode 100644 src/app/api/peer-assessment-sessions/[id]/route.ts create mode 100644 src/app/api/peer-assessment-sessions/[id]/submit/route.ts create mode 100644 src/app/api/peer-assessment-sessions/route.ts create mode 100644 src/app/api/student-transport/bulk/route.ts create mode 100644 src/app/api/transport-routes/[id]/route.ts create mode 100644 src/app/api/transport-routes/[id]/stops/route.ts create mode 100644 src/app/api/transport-routes/route.ts create mode 100644 src/components/grade-analytics-view.tsx create mode 100644 src/components/peer-assessment-view.tsx create mode 100644 src/components/school-transport-view.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2e5c771..1ba5e0e 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,10 +68,12 @@ model School { resources Resource[] emailTemplates EmailTemplate[] emailLogs EmailLog[] - peerAssessments PeerAssessment[] - emergencyContacts EmergencyContact[] + peerAssessments PeerAssessment[] + peerAssessmentSessions PeerAssessmentSession[] + emergencyContacts EmergencyContact[] schoolEvents SchoolEvent[] studentTransports StudentTransport[] + transportRoutes TransportRoute[] healthRecords HealthRecord[] badges Badge[] studentBadges StudentBadge[] @@ -93,6 +95,7 @@ model School { counselingAppointments CounselingAppointment[] disciplinaryCommittees DisciplinaryCommittee[] communicationRooms CommunicationRoom[] + gradeReports GradeReport[] aiTestGenerations AITestGeneration[] gradingReviews TeacherGradingReview[] gradingAnnotations GradingAnnotation[] @@ -195,7 +198,9 @@ model User { responsibleClasses ClassGroup[] @relation("ResponsibleTeacher") examPlans ExamPlan[] seatingCharts SeatingChart[] + peerAssessmentSessions PeerAssessmentSession[] @relation("PeerAssessmentTeacher") bookCheckouts BookCheckout[] + gradeReports GradeReport[] @relation("GradeReportGenerator") bookReservations BookReservation[] dataImportJobs DataImportJob[] dataExportJobs DataExportJob[] @@ -256,6 +261,7 @@ model ClassGroup { timetableSlots TimetableSlot[] resources Resource[] peerAssessments PeerAssessment[] + peerAssessmentSessions PeerAssessmentSession[] @relation("PeerAssessmentClassGroup") schoolEvents SchoolEvent[] aiTestGenerations AITestGeneration[] examPlans ExamPlan[] @@ -1579,24 +1585,57 @@ model EmailLog { // ─── Peer Assessment ────────────────────────────────────────────── -model PeerAssessment { +model PeerAssessmentSession { id String @id @default(cuid()) schoolId String - assessorId String // student who is assessing - assessedId String // student being assessed - competencyId String? + teacherId String? // teacher who created the session classGroupId String? - assessmentType String // "competency", "project", "presentation", "teamwork", "behavior" - level Int? // 1-6 rating - comment String? - rubricId String? // optional rubric reference - isAnonymous Boolean @default(false) + title String + description String? + assessmentType String @default("competency") // competency, project, presentation, teamwork + criteria String? // JSON: [{ name, description, maxScore }] + anonymityMode String @default("anonymous") // anonymous, semi-anonymous, open + status String @default("active") // active, completed, closed + deadline DateTime? + assignMode String @default("manual") // random, manual, group-based + assignedPairs String? // JSON: [{ assessorId, assessedId }] isDemo Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? school School @relation(fields: [schoolId], references: [id]) + teacher User? @relation("PeerAssessmentTeacher", fields: [teacherId], references: [id]) + classGroup ClassGroup? @relation("PeerAssessmentClassGroup", fields: [classGroupId], references: [id]) + peerAssessments PeerAssessment[] + + @@index([schoolId, status]) + @@index([schoolId, teacherId]) + @@map("peer_assessment_sessions") +} + +model PeerAssessment { + id String @id @default(cuid()) + schoolId String + sessionId String? // link to session + assessorId String // student who is assessing + assessedId String // student being assessed + competencyId String? + classGroupId String? + assessmentType String // "competency", "project", "presentation", "teamwork", "behavior" + criteria String? // JSON: [{ name, score, maxScore }] — individual criterion scores + level Int? // 1-6 rating (overall) + comment String? + rubricId String? // optional rubric reference + isAnonymous Boolean @default(false) + status String @default("pending") // pending, submitted + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + session PeerAssessmentSession? @relation(fields: [sessionId], references: [id]) assessor Student @relation("PeerAssessor", fields: [assessorId], references: [id]) assessed Student @relation("PeerAssessed", fields: [assessedId], references: [id]) competency Competency? @relation(fields: [competencyId], references: [id]) @@ -1605,6 +1644,7 @@ model PeerAssessment { @@index([schoolId, assessedId]) @@index([schoolId, assessorId]) + @@index([sessionId]) @@map("peer_assessments") } @@ -1721,19 +1761,62 @@ model StudentTransport { driverName String? // for car/bus driverPhone String? // for car/bus distanceKm Float? // distance in km + routeId String? // link to TransportRoute 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]) + school School @relation(fields: [schoolId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + route TransportRoute? @relation(fields: [routeId], references: [id]) @@index([schoolId, studentId]) @@map("student_transport") } +model TransportRoute { + id String @id @default(cuid()) + schoolId String + routeNumber String + routeName String + transportType String // bus, tram, train + driverName String? + driverPhone String? + capacity Int @default(40) + isActive Boolean @default(true) + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + stops TransportStop[] + assignments StudentTransport[] + + @@index([schoolId]) + @@map("transport_route") +} + +model TransportStop { + id String @id @default(cuid()) + routeId String + stopName String + stopOrder Int + pickupTime String? // "07:45" + dropoffTime String? // "15:30" + address String? + latitude Float? + longitude Float? + createdAt DateTime @default(now()) + + route TransportRoute @relation(fields: [routeId], references: [id], onDelete: Cascade) + + @@index([routeId]) + @@map("transport_stop") +} + // ─── Health Records ────────────────────────────────────────────── model HealthRecord { @@ -3110,3 +3193,26 @@ model SubstitutionAssignment { @@index([date]) @@map("substitution_assignments") } + +// ─── Grade Analytics & Reporting ────────────────────────────────── + +model GradeReport { + id String @id @default(cuid()) + schoolId String + generatedBy String + title String + type String @default("custom") // quarterly, semester, annual, custom + dateRange String // JSON: { from, to } + classIds String? // JSON: array of class IDs + subjectIds String? // JSON: array of subject IDs + metrics String? // JSON: array of selected metrics + status String @default("pending") // pending, completed, failed + fileData String? // Base64 encoded or JSON report data + createdAt DateTime @default(now()) + + school School @relation(fields: [schoolId], references: [id]) + generator User @relation("GradeReportGenerator", fields: [generatedBy], references: [id]) + + @@index([schoolId]) + @@index([generatedBy]) +} diff --git a/src/app/api/grade-analytics/comparison/route.ts b/src/app/api/grade-analytics/comparison/route.ts new file mode 100644 index 0000000..5499f29 --- /dev/null +++ b/src/app/api/grade-analytics/comparison/route.ts @@ -0,0 +1,358 @@ +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') ?? session.user?.schoolId ?? undefined; + const schoolYearId = searchParams.get('schoolYearId'); + const compareType = searchParams.get('compareType') ?? 'class'; // class, subject, teacher + + const role = session.user?.role; + const isAdmin = role === 'SUPER_ADMIN' || role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL'; + + // Only admins can do teacher comparison + if (compareType === 'teacher' && !isAdmin) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); + } + + // Build where clause + const gradeWhere: Record = {}; + if (schoolYearId) gradeWhere.schoolYearId = schoolYearId; + if (schoolId) { + gradeWhere.classGroup = { schoolId }; + } + + const grades = await db.computedGrade.findMany({ + where: gradeWhere, + select: { + computedValue: true, + overriddenValue: true, + subjectId: true, + classGroupId: true, + studentId: true, + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + }, + }); + + if (compareType === 'class') { + // Class comparison + const classMap: Record = {}; + + grades.forEach((g) => { + const val = g.overriddenValue ?? g.computedValue; + if (!classMap[g.classGroupId]) { + classMap[g.classGroupId] = { + name: g.classGroup.name, + gradeLevel: g.classGroup.gradeLevel, + grades: [], + average: 0, + median: 0, + stdDev: 0, + gradeCount: 0, + }; + } + classMap[g.classGroupId].grades.push(val); + }); + + // Calculate statistics + Object.values(classMap).forEach((c) => { + c.grades.sort((a, b) => a - b); + c.gradeCount = c.grades.length; + c.average = c.grades.length > 0 ? Math.round((c.grades.reduce((s, v) => s + v, 0) / c.grades.length) * 100) / 100 : 0; + + // Median + const mid = Math.floor(c.grades.length / 2); + c.median = c.grades.length > 0 + ? c.grades.length % 2 !== 0 + ? c.grades[mid] + : Math.round(((c.grades[mid - 1] + c.grades[mid]) / 2) * 100) / 100 + : 0; + + // Standard deviation + const variance = c.grades.length > 0 + ? c.grades.reduce((s, v) => s + Math.pow(v - c.average, 2), 0) / c.grades.length + : 0; + c.stdDev = Math.round(Math.sqrt(variance) * 100) / 100; + }); + + // Class ranking (anonymized) + const classRanking = Object.entries(classMap) + .map(([id, data]) => ({ id, ...data })) + .sort((a, b) => a.average - b.average) + .map((c, idx) => ({ + ...c, + rank: idx + 1, + anonymizedName: `Class ${String.fromCharCode(65 + idx)}`, // A, B, C, ... + })); + + // Subject comparison across classes + const subjectByClass: Record> = {}; + grades.forEach((g) => { + const val = g.overriddenValue ?? g.computedValue; + if (!subjectByClass[g.subjectId]) { + subjectByClass[g.subjectId] = {}; + } + if (!subjectByClass[g.subjectId][g.classGroupId]) { + subjectByClass[g.subjectId][g.classGroupId] = { + total: 0, + count: 0, + name: g.classGroup.name, + }; + } + subjectByClass[g.subjectId][g.classGroupId].total += val; + subjectByClass[g.subjectId][g.classGroupId].count += 1; + }); + + // Get subject names + const subjects = await db.subject.findMany({ + where: schoolId ? { schoolId } : {}, + select: { id: true, name: true }, + }); + const subjectNameMap: Record = {}; + subjects.forEach((s) => { subjectNameMap[s.id] = s.name; }); + + const subjectComparison = Object.entries(subjectByClass).map(([subjectId, classData]) => ({ + subjectId, + subjectName: subjectNameMap[subjectId] || subjectId, + classes: Object.entries(classData).map(([classId, data]) => ({ + classId, + className: data.name, + average: data.count > 0 ? Math.round((data.total / data.count) * 100) / 100 : 0, + count: data.count, + })), + })); + + return NextResponse.json({ + type: 'class', + classRanking, + subjectComparison, + total: grades.length, + }); + } + + if (compareType === 'subject') { + // Subject comparison + const subjectMap: Record = {}; + + grades.forEach((g) => { + const val = g.overriddenValue ?? g.computedValue; + if (!subjectMap[g.subjectId]) { + subjectMap[g.subjectId] = { + name: g.subject.name, + grades: [], + average: 0, + variance: 0, + gradeCount: 0, + }; + } + subjectMap[g.subjectId].grades.push(val); + }); + + Object.values(subjectMap).forEach((s) => { + s.gradeCount = s.grades.length; + s.average = s.grades.length > 0 ? Math.round((s.grades.reduce((sum, v) => sum + v, 0) / s.grades.length) * 100) / 100 : 0; + const variance = s.grades.length > 0 + ? s.grades.reduce((sum, v) => sum + Math.pow(v - s.average, 2), 0) / s.grades.length + : 0; + s.variance = Math.round(variance * 100) / 100; + }); + + // Cross-subject correlation + const subjectIds = Object.keys(subjectMap); + const studentSubjectGrades: Record> = {}; + grades.forEach((g) => { + const val = g.overriddenValue ?? g.computedValue; + if (!studentSubjectGrades[g.studentId]) { + studentSubjectGrades[g.studentId] = {}; + } + if (!studentSubjectGrades[g.studentId][g.subjectId]) { + studentSubjectGrades[g.studentId][g.subjectId] = []; + } + studentSubjectGrades[g.studentId][g.subjectId].push(val); + }); + + // Calculate correlation matrix + const correlationMatrix: Array<{ + subject1: string; + subject2: string; + name1: string; + name2: string; + correlation: number; + }> = []; + + for (let i = 0; i < subjectIds.length; i++) { + for (let j = i; j < subjectIds.length; j++) { + const s1 = subjectIds[i]; + const s2 = subjectIds[j]; + const pairs: Array<{ x: number; y: number }> = []; + + Object.entries(studentSubjectGrades).forEach(([, subjects]) => { + const g1 = subjects[s1]; + const g2 = subjects[s2]; + if (g1 && g2 && g1.length > 0 && g2.length > 0) { + pairs.push({ + x: g1.reduce((a, b) => a + b, 0) / g1.length, + y: g2.reduce((a, b) => a + b, 0) / g2.length, + }); + } + }); + + if (pairs.length >= 3) { + const n = pairs.length; + const sumX = pairs.reduce((s, p) => s + p.x, 0); + const sumY = pairs.reduce((s, p) => s + p.y, 0); + const sumXY = pairs.reduce((s, p) => s + p.x * p.y, 0); + const sumX2 = pairs.reduce((s, p) => s + p.x * p.x, 0); + const sumY2 = pairs.reduce((s, p) => s + p.y * p.y, 0); + + const numerator = n * sumXY - sumX * sumY; + const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY)); + const correlation = denominator !== 0 ? Math.round((numerator / denominator) * 100) / 100 : 0; + + correlationMatrix.push({ + subject1: s1, + subject2: s2, + name1: subjectMap[s1].name, + name2: subjectMap[s2].name, + correlation, + }); + } + } + } + + // Subject difficulty ranking + const subjectRanking = Object.entries(subjectMap) + .map(([id, data]) => ({ + id, + name: data.name, + average: data.average, + variance: data.variance, + gradeCount: data.gradeCount, + difficulty: data.average <= 2 ? 'easy' : data.average <= 3 ? 'moderate' : data.average <= 4 ? 'challenging' : 'hard', + })) + .sort((a, b) => b.average - a.average); + + // Subject recommendations + const recommendations = subjectRanking.map((s) => { + const recs: string[] = []; + if (s.average >= 4.5) { + recs.push('Consider additional support materials and tutoring'); + recs.push('Review curriculum difficulty and pacing'); + } else if (s.average >= 3.5) { + recs.push('Monitor student progress closely'); + recs.push('Consider differentiated instruction'); + } else { + recs.push('Performance is on track'); + recs.push('Continue current teaching approach'); + } + if (s.variance > 1.5) { + recs.push('High variance suggests need for differentiated instruction'); + } + return { subjectId: s.id, subjectName: s.name, recommendations: recs }; + }); + + return NextResponse.json({ + type: 'subject', + subjectRanking, + correlationMatrix, + recommendations, + total: grades.length, + }); + } + + if (compareType === 'teacher') { + // Teacher comparison (admin only) + if (!isAdmin) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); + } + + const teacherClasses = await db.classGroupTeacher.findMany({ + where: schoolId ? { classGroup: { schoolId } } : {}, + select: { + userId: true, + role: true, + user: { select: { id: true, firstName: true, lastName: true } }, + classGroup: { select: { id: true, name: true } }, + }, + }); + + const teacherMap: Record = {}; + + teacherClasses.forEach((tc) => { + if (!teacherMap[tc.userId]) { + teacherMap[tc.userId] = { + name: `${tc.user.firstName} ${tc.user.lastName}`, + classIds: [], + grades: [], + average: 0, + classCount: 0, + gradeCount: 0, + }; + } + if (!teacherMap[tc.userId].classIds.includes(tc.classGroup.id)) { + teacherMap[tc.userId].classIds.push(tc.classGroup.id); + } + }); + + // Assign grades to teachers based on class + grades.forEach((g) => { + Object.entries(teacherMap).forEach(([, teacher]) => { + if (teacher.classIds.includes(g.classGroupId)) { + teacher.grades.push(g.overriddenValue ?? g.computedValue); + } + }); + }); + + // Calculate teacher stats + Object.values(teacherMap).forEach((t) => { + t.gradeCount = t.grades.length; + t.classCount = t.classIds.length; + t.average = t.grades.length > 0 ? Math.round((t.grades.reduce((s, v) => s + v, 0) / t.grades.length) * 100) / 100 : 0; + }); + + const teacherRanking = Object.entries(teacherMap) + .map(([id, data]) => ({ id, ...data })) + .sort((a, b) => a.average - b.average); + + return NextResponse.json({ + type: 'teacher', + teacherRanking, + total: grades.length, + }); + } + + return NextResponse.json({ error: 'Invalid compareType parameter' }, { status: 400 }); + } catch (error) { + console.error('Grade comparison error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/grade-analytics/distribution/route.ts b/src/app/api/grade-analytics/distribution/route.ts new file mode 100644 index 0000000..6f8c4e0 --- /dev/null +++ b/src/app/api/grade-analytics/distribution/route.ts @@ -0,0 +1,182 @@ +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') ?? session.user?.schoolId ?? undefined; + const classGroupId = searchParams.get('classGroupId'); + const subjectId = searchParams.get('subjectId'); + const schoolYearId = searchParams.get('schoolYearId'); + const groupBy = searchParams.get('groupBy') ?? 'overall'; // overall, subject, class + + const role = session.user?.role; + const isStudent = role === 'STUDENT'; + const isParent = role === 'PARENT'; + + // Build where clause + const gradeWhere: Record = {}; + if (classGroupId) gradeWhere.classGroupId = classGroupId; + if (subjectId) gradeWhere.subjectId = subjectId; + if (schoolYearId) gradeWhere.schoolYearId = schoolYearId; + + if (schoolId) { + gradeWhere.classGroup = { schoolId }; + } + + // Role-based scoping + if (isStudent && session.user?.id) { + const student = await db.student.findFirst({ + where: { userId: session.user.id, deletedAt: null }, + }); + if (student) gradeWhere.studentId = student.id; + } + + if (isParent && session.user?.id) { + const parentLinks = await db.parentStudentLink.findMany({ + where: { parentId: session.user.id }, + select: { studentId: true }, + }); + const studentIds = parentLinks.map((l) => l.studentId); + gradeWhere.studentId = { in: studentIds }; + } + + // Get all grades with subject and class info + const grades = await db.computedGrade.findMany({ + where: gradeWhere, + select: { + computedValue: true, + overriddenValue: true, + subjectId: true, + classGroupId: true, + studentId: true, + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + }, + }); + + // Grade colors for German 1-6 scale + const gradeColors: Record = { + 1: '#10b981', // emerald + 2: '#22c55e', // green + 3: '#eab308', // yellow + 4: '#f59e0b', // amber + 5: '#f97316', // orange + 6: '#ef4444', // red + }; + + if (groupBy === 'overall') { + // Overall distribution + const distribution: Record = {}; + for (let i = 1; i <= 6; i++) { + distribution[i] = { count: 0, percentage: 0, color: gradeColors[i] }; + } + grades.forEach((g) => { + const val = g.overriddenValue ?? g.computedValue; + const rounded = Math.round(val); + if (rounded >= 1 && rounded <= 6) { + distribution[rounded].count++; + } + }); + const total = grades.length || 1; + Object.values(distribution).forEach((d) => { + d.percentage = Math.round((d.count / total) * 10000) / 100; + }); + + // Bell curve data (normal distribution overlay) + const mean = grades.reduce((s, g) => s + (g.overriddenValue ?? g.computedValue), 0) / (grades.length || 1); + const variance = grades.reduce((s, g) => { + const val = g.overriddenValue ?? g.computedValue; + return s + Math.pow(val - mean, 2); + }, 0) / (grades.length || 1); + const stdDev = Math.sqrt(variance); + + const bellCurveData = []; + for (let x = 0.5; x <= 6.5; x += 0.1) { + const exponent = -Math.pow(x - mean, 2) / (2 * (stdDev * stdDev || 1)); + const y = (1 / (stdDev * Math.sqrt(2 * Math.PI) || 1)) * Math.exp(exponent); + bellCurveData.push({ x: Math.round(x * 10) / 10, y: Math.round(y * 1000) / 1000 }); + } + + return NextResponse.json({ + type: 'overall', + distribution: Object.entries(distribution).map(([grade, data]) => ({ + grade: parseInt(grade), + ...data, + label: grade === '1' ? 'Sehr gut' : grade === '2' ? 'Gut' : grade === '3' ? 'Befriedigend' : grade === '4' ? 'Ausreichend' : grade === '5' ? 'Mangelhaft' : 'Ungenügend', + })), + bellCurve: bellCurveData, + mean: Math.round(mean * 100) / 100, + stdDev: Math.round(stdDev * 100) / 100, + total: grades.length, + }); + } + + if (groupBy === 'subject') { + // Distribution by subject + const subjectMap: Record }> = {}; + grades.forEach((g) => { + const val = g.overriddenValue ?? g.computedValue; + const rounded = Math.round(val); + if (!subjectMap[g.subjectId]) { + subjectMap[g.subjectId] = { name: g.subject.name, distribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 } }; + } + if (rounded >= 1 && rounded <= 6) { + subjectMap[g.subjectId].distribution[rounded]++; + } + }); + + const subjectDistribution = Object.entries(subjectMap).map(([id, data]) => ({ + id, + name: data.name, + distribution: data.distribution, + })); + + return NextResponse.json({ + type: 'subject', + subjects: subjectDistribution, + gradeColors, + total: grades.length, + }); + } + + if (groupBy === 'class') { + // Distribution by class + const classMap: Record }> = {}; + grades.forEach((g) => { + const val = g.overriddenValue ?? g.computedValue; + const rounded = Math.round(val); + if (!classMap[g.classGroupId]) { + classMap[g.classGroupId] = { name: g.classGroup.name, distribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 } }; + } + if (rounded >= 1 && rounded <= 6) { + classMap[g.classGroupId].distribution[rounded]++; + } + }); + + const classDistribution = Object.entries(classMap).map(([id, data]) => ({ + id, + name: data.name, + distribution: data.distribution, + })); + + return NextResponse.json({ + type: 'class', + classes: classDistribution, + gradeColors, + total: grades.length, + }); + } + + return NextResponse.json({ error: 'Invalid groupBy parameter' }, { status: 400 }); + } catch (error) { + console.error('Grade distribution error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/grade-analytics/reports/route.ts b/src/app/api/grade-analytics/reports/route.ts new file mode 100644 index 0000000..c608ba1 --- /dev/null +++ b/src/app/api/grade-analytics/reports/route.ts @@ -0,0 +1,232 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// GET: List grade reports +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') ?? session.user?.schoolId ?? undefined; + const type = searchParams.get('type'); + + const role = session.user?.role; + const isAdmin = role === 'SUPER_ADMIN' || role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL'; + const isTeacher = role === 'TEACHER'; + + const where: Record = {}; + if (schoolId) where.schoolId = schoolId; + if (type) where.type = type; + + // Non-admin users can only see their own reports + if (!isAdmin && !isTeacher) { + where.generatedBy = session.userId; + } + + const reports = await db.gradeReport.findMany({ + where, + include: { + generator: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json({ + reports: reports.map((r) => ({ + id: r.id, + title: r.title, + type: r.type, + dateRange: r.dateRange, + classIds: r.classIds, + subjectIds: r.subjectIds, + metrics: r.metrics, + status: r.status, + generatedBy: r.generator ? `${r.generator.firstName} ${r.generator.lastName}` : 'Unknown', + createdAt: r.createdAt, + hasFileData: !!r.fileData, + })), + }); + } catch (error) { + console.error('Grade reports list error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// POST: Generate a new grade report +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const role = session.user?.role; + const isAdmin = role === 'SUPER_ADMIN' || role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL'; + const isTeacher = role === 'TEACHER'; + + if (!isAdmin && !isTeacher) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); + } + + const body = await request.json(); + const { + title, + type, + dateRange, + classIds, + subjectIds, + metrics, + } = body; + + if (!title || !type || !dateRange) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + const schoolId = body.schoolId ?? session.user?.schoolId; + if (!schoolId) { + return NextResponse.json({ error: 'School ID required' }, { status: 400 }); + } + + // Build the grade data for the report + const gradeWhere: Record = { + classGroup: { schoolId }, + }; + + const parsedDateRange = typeof dateRange === 'string' ? JSON.parse(dateRange) : dateRange; + if (parsedDateRange.from || parsedDateRange.to) { + if (parsedDateRange.from) { + gradeWhere.computedAt = { gte: new Date(parsedDateRange.from) }; + } + if (parsedDateRange.to) { + gradeWhere.computedAt = { ...(gradeWhere.computedAt as object || {}), lte: new Date(parsedDateRange.to) }; + } + } + + const parsedClassIds = typeof classIds === 'string' ? JSON.parse(classIds) : classIds; + const parsedSubjectIds = typeof subjectIds === 'string' ? JSON.parse(subjectIds) : subjectIds; + const parsedMetrics = typeof metrics === 'string' ? JSON.parse(metrics) : metrics; + + if (parsedClassIds && Array.isArray(parsedClassIds) && parsedClassIds.length > 0) { + gradeWhere.classGroupId = { in: parsedClassIds }; + } + if (parsedSubjectIds && Array.isArray(parsedSubjectIds) && parsedSubjectIds.length > 0) { + gradeWhere.subjectId = { in: parsedSubjectIds }; + } + + // Fetch grades for report + const grades = await db.computedGrade.findMany({ + where: gradeWhere, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + }, + orderBy: { computedAt: 'asc' }, + }); + + // Generate report data + const reportData: Record = { + generatedAt: new Date().toISOString(), + generatedBy: `${session.user?.firstName} ${session.user?.lastName}`, + title, + type, + dateRange: parsedDateRange, + totalGrades: grades.length, + }; + + // Include selected metrics + if (!parsedMetrics || parsedMetrics.includes('distribution')) { + const distribution: Record = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 }; + grades.forEach((g) => { + const val = g.overriddenValue ?? g.computedValue; + const rounded = Math.round(val); + if (rounded >= 1 && rounded <= 6) distribution[rounded]++; + }); + reportData.distribution = distribution; + } + + if (!parsedMetrics || parsedMetrics.includes('averages')) { + const avg = grades.length > 0 + ? Math.round((grades.reduce((s, g) => s + (g.overriddenValue ?? g.computedValue), 0) / grades.length) * 100) / 100 + : 0; + reportData.averageGrade = avg; + } + + if (!parsedMetrics || parsedMetrics.includes('classComparison')) { + const classAvg: Record = {}; + grades.forEach((g) => { + const val = g.overriddenValue ?? g.computedValue; + if (!classAvg[g.classGroupId]) { + classAvg[g.classGroupId] = { name: g.classGroup.name, average: 0, count: 0 }; + } + classAvg[g.classGroupId].average += val; + classAvg[g.classGroupId].count += 1; + }); + Object.values(classAvg).forEach((c) => { + c.average = c.count > 0 ? Math.round((c.average / c.count) * 100) / 100 : 0; + }); + reportData.classComparison = classAvg; + } + + if (!parsedMetrics || parsedMetrics.includes('subjectAnalysis')) { + const subjectAvg: Record = {}; + grades.forEach((g) => { + const val = g.overriddenValue ?? g.computedValue; + if (!subjectAvg[g.subjectId]) { + subjectAvg[g.subjectId] = { name: g.subject.name, average: 0, count: 0 }; + } + subjectAvg[g.subjectId].average += val; + subjectAvg[g.subjectId].count += 1; + }); + Object.values(subjectAvg).forEach((s) => { + s.average = s.count > 0 ? Math.round((s.average / s.count) * 100) / 100 : 0; + }); + reportData.subjectAnalysis = subjectAvg; + } + + if (!parsedMetrics || parsedMetrics.includes('studentGrades')) { + reportData.studentGrades = grades.map((g) => ({ + student: `${g.student.firstName} ${g.student.lastName}`, + subject: g.subject.name, + class: g.classGroup.name, + grade: g.overriddenValue ?? g.computedValue, + isFinalized: g.isFinalized, + period: g.period, + })); + } + + // Create the report record + const report = await db.gradeReport.create({ + data: { + schoolId, + generatedBy: session.userId, + title, + type, + dateRange: typeof dateRange === 'string' ? dateRange : JSON.stringify(dateRange), + classIds: classIds ? (typeof classIds === 'string' ? classIds : JSON.stringify(classIds)) : null, + subjectIds: subjectIds ? (typeof subjectIds === 'string' ? subjectIds : JSON.stringify(subjectIds)) : null, + metrics: metrics ? (typeof metrics === 'string' ? metrics : JSON.stringify(metrics)) : null, + status: 'completed', + fileData: JSON.stringify(reportData), + }, + }); + + return NextResponse.json({ + id: report.id, + title: report.title, + type: report.type, + status: report.status, + createdAt: report.createdAt, + reportData, + }); + } catch (error) { + console.error('Grade report generation error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/grade-analytics/route.ts b/src/app/api/grade-analytics/route.ts new file mode 100644 index 0000000..b2b6538 --- /dev/null +++ b/src/app/api/grade-analytics/route.ts @@ -0,0 +1,250 @@ +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') ?? session.user?.schoolId ?? undefined; + const classGroupId = searchParams.get('classGroupId'); + const subjectId = searchParams.get('subjectId'); + const schoolYearId = searchParams.get('schoolYearId'); + + const role = session.user?.role; + const isStudent = role === 'STUDENT'; + const isParent = role === 'PARENT'; + + // Build where clause for ComputedGrade + const gradeWhere: Record = {}; + if (classGroupId) gradeWhere.classGroupId = classGroupId; + if (subjectId) gradeWhere.subjectId = subjectId; + if (schoolYearId) gradeWhere.schoolYearId = schoolYearId; + + // Scope by school + if (schoolId) { + const classWhere: Record = { schoolId }; + gradeWhere.classGroup = { schoolId }; + if (Object.keys(classWhere).length > 0) { + gradeWhere.classGroup = classWhere; + } + } + + // Role-based scoping + if (isStudent && session.user?.id) { + const student = await db.student.findFirst({ + where: { userId: session.user.id, deletedAt: null }, + }); + if (student) { + gradeWhere.studentId = student.id; + } + } + + if (isParent && session.user?.id) { + const parentLinks = await db.parentStudentLink.findMany({ + where: { parentId: session.user.id }, + select: { studentId: true }, + }); + const studentIds = parentLinks.map((l) => l.studentId); + gradeWhere.studentId = { in: studentIds }; + } + + // Get total grades count + const totalGrades = await db.computedGrade.count({ where: gradeWhere }); + + // Get average grade + const gradeAgg = await db.computedGrade.aggregate({ + where: gradeWhere, + _avg: { computedValue: true }, + _min: { computedValue: true }, + _max: { computedValue: true }, + }); + + // Get grade distribution (1-6 scale) + const allGrades = await db.computedGrade.findMany({ + where: gradeWhere, + select: { + computedValue: true, + overriddenValue: true, + subjectId: true, + classGroupId: true, + studentId: true, + period: true, + isFinalized: true, + }, + }); + + // Grade distribution + const distribution: Record = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 }; + allGrades.forEach((g) => { + const val = g.overriddenValue ?? g.computedValue; + const rounded = Math.round(val); + if (rounded >= 1 && rounded <= 6) { + distribution[rounded]++; + } + }); + + // Finalized vs draft count + const finalizedCount = allGrades.filter((g) => g.isFinalized).length; + const draftCount = totalGrades - finalizedCount; + + // Top performers (lowest grade = best on German scale) + const studentGradeMap: Record = {}; + for (const g of allGrades) { + const val = g.overriddenValue ?? g.computedValue; + if (!studentGradeMap[g.studentId]) { + studentGradeMap[g.studentId] = { total: 0, count: 0, name: '' }; + } + studentGradeMap[g.studentId].total += val; + studentGradeMap[g.studentId].count += 1; + } + + // Get student names + const studentIds = Object.keys(studentGradeMap); + const students = await db.student.findMany({ + where: { id: { in: studentIds }, deletedAt: null }, + select: { id: true, firstName: true, lastName: true }, + }); + students.forEach((s) => { + if (studentGradeMap[s.id]) { + studentGradeMap[s.id].name = `${s.firstName} ${s.lastName}`; + } + }); + + // Calculate averages and sort + const studentAverages = studentIds + .map((id) => ({ + id, + name: studentGradeMap[id].name, + average: studentGradeMap[id].count > 0 ? studentGradeMap[id].total / studentGradeMap[id].count : 0, + gradeCount: studentGradeMap[id].count, + })) + .sort((a, b) => a.average - b.average); // Lower = better on German scale + + const topPerformers = studentAverages.slice(0, 5); + const bottomPerformers = studentAverages.slice(-5).reverse(); + + // Risk students (average >= 4.5, trending towards 5-6) + const riskStudents = studentAverages.filter((s) => s.average >= 4.5 && s.gradeCount >= 2); + + // Get classes for filter + const classes = await db.classGroup.findMany({ + where: schoolId ? { schoolId } : {}, + select: { id: true, name: true, gradeLevel: true }, + orderBy: { gradeLevel: 'asc' }, + }); + + // Get subjects for filter + const subjects = await db.subject.findMany({ + where: schoolId ? { schoolId } : {}, + select: { id: true, name: true }, + orderBy: { name: 'asc' }, + }); + + // Subject difficulty ranking + const subjectGradeMap: Record = {}; + for (const g of allGrades) { + const val = g.overriddenValue ?? g.computedValue; + if (!subjectGradeMap[g.subjectId]) { + subjectGradeMap[g.subjectId] = { total: 0, count: 0, name: '' }; + } + subjectGradeMap[g.subjectId].total += val; + subjectGradeMap[g.subjectId].count += 1; + } + + subjects.forEach((s) => { + if (subjectGradeMap[s.id]) { + subjectGradeMap[s.id].name = s.name; + } + }); + + const subjectDifficulty = Object.entries(subjectGradeMap) + .map(([id, data]) => ({ + id, + name: data.name, + average: data.count > 0 ? data.total / data.count : 0, + gradeCount: data.count, + })) + .sort((a, b) => b.average - a.average); // Higher average = harder + + // Class average comparison + const classGradeMap: Record = {}; + for (const g of allGrades) { + const val = g.overriddenValue ?? g.computedValue; + if (!classGradeMap[g.classGroupId]) { + classGradeMap[g.classGroupId] = { total: 0, count: 0, name: '' }; + } + classGradeMap[g.classGroupId].total += val; + classGradeMap[g.classGroupId].count += 1; + } + + classes.forEach((c) => { + if (classGradeMap[c.id]) { + classGradeMap[c.id].name = c.name; + } + }); + + const classAverages = Object.entries(classGradeMap) + .map(([id, data]) => ({ + id, + name: data.name, + average: data.count > 0 ? data.total / data.count : 0, + gradeCount: data.count, + })) + .sort((a, b) => a.average - b.average); + + // Teacher comparison (admin only) + let teacherComparison: Array<{ id: string; name: string; average: number; gradeCount: number }> = []; + if (role === 'SUPER_ADMIN' || role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') { + const teacherIds = await db.classGroupTeacher.findMany({ + where: schoolId ? { classGroup: { schoolId } } : {}, + select: { userId: true, user: { select: { id: true, firstName: true, lastName: true } } }, + distinct: ['userId'], + }); + + for (const t of teacherIds) { + const teacherClasses = await db.classGroupTeacher.findMany({ + where: { userId: t.userId }, + select: { classGroupId: true }, + }); + const tClassIds = teacherClasses.map((tc) => tc.classGroupId); + const tGrades = allGrades.filter((g) => tClassIds.includes(g.classGroupId)); + if (tGrades.length > 0) { + const avg = tGrades.reduce((sum, g) => sum + (g.overriddenValue ?? g.computedValue), 0) / tGrades.length; + teacherComparison.push({ + id: t.user.id, + name: `${t.user.firstName} ${t.user.lastName}`, + average: Math.round(avg * 100) / 100, + gradeCount: tGrades.length, + }); + } + } + teacherComparison.sort((a, b) => a.average - b.average); + } + + return NextResponse.json({ + totalGrades, + averageGrade: gradeAgg._avg.computedValue ? Math.round(gradeAgg._avg.computedValue * 100) / 100 : 0, + minGrade: gradeAgg._min.computedValue ?? 0, + maxGrade: gradeAgg._max.computedValue ?? 0, + finalizedCount, + draftCount, + distribution, + topPerformers, + bottomPerformers, + riskStudents, + classes, + subjects, + subjectDifficulty, + classAverages, + teacherComparison, + }); + } catch (error) { + console.error('Grade analytics error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/grade-analytics/trends/route.ts b/src/app/api/grade-analytics/trends/route.ts new file mode 100644 index 0000000..d85acc7 --- /dev/null +++ b/src/app/api/grade-analytics/trends/route.ts @@ -0,0 +1,215 @@ +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') ?? session.user?.schoolId ?? undefined; + const classGroupId = searchParams.get('classGroupId'); + const subjectId = searchParams.get('subjectId'); + const schoolYearId = searchParams.get('schoolYearId'); + const studentId = searchParams.get('studentId'); + + const role = session.user?.role; + const isStudent = role === 'STUDENT'; + const isParent = role === 'PARENT'; + + // Build where clause for assessments + const assessmentWhere: Record = {}; + if (classGroupId) assessmentWhere.classGroupId = classGroupId; + if (subjectId) assessmentWhere.subjectId = subjectId; + + if (schoolId) { + assessmentWhere.classGroup = { schoolId }; + } + + // Get assessments with results over time + const assessments = await db.assessment.findMany({ + where: assessmentWhere, + select: { + id: true, + title: true, + date: true, + type: true, + subjectId: true, + classGroupId: true, + maxScore: true, + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + assessmentResults: { + select: { + studentId: true, + score: true, + masteryLevelValue: true, + student: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + ...(studentId ? { where: { studentId } } : {}), + }, + }, + orderBy: { date: 'asc' }, + }); + + // Role-based scoping for student/parent + let filteredAssessments = assessments; + if (isStudent && session.user?.id) { + const student = await db.student.findFirst({ + where: { userId: session.user.id, deletedAt: null }, + }); + if (student) { + filteredAssessments = assessments.map((a) => ({ + ...a, + assessmentResults: a.assessmentResults.filter((r) => r.studentId === student.id), + })); + } + } + + if (isParent && session.user?.id) { + const parentLinks = await db.parentStudentLink.findMany({ + where: { parentId: session.user.id }, + select: { studentId: true }, + }); + const childIds = parentLinks.map((l) => l.studentId); + filteredAssessments = assessments.map((a) => ({ + ...a, + assessmentResults: a.assessmentResults.filter((r) => childIds.includes(r.studentId)), + })); + } + + // Build trend data by month + const monthMap: Record = {}; + + filteredAssessments.forEach((a) => { + const monthKey = a.date.toISOString().substring(0, 7); // YYYY-MM + if (!monthMap[monthKey]) { + monthMap[monthKey] = { date: monthKey, average: 0, count: 0, grade1: 0, grade2: 0, grade3: 0, grade4: 0, grade5: 0, grade6: 0 }; + } + a.assessmentResults.forEach((r) => { + if (r.score !== null && a.maxScore) { + // Convert score to German grade (1-6 scale) + const percentage = r.score / a.maxScore; + let grade: number; + if (percentage >= 0.92) grade = 1; + else if (percentage >= 0.81) grade = 2; + else if (percentage >= 0.67) grade = 3; + else if (percentage >= 0.50) grade = 4; + else if (percentage >= 0.30) grade = 5; + else grade = 6; + + monthMap[monthKey].count++; + monthMap[monthKey].average += grade; + if (grade === 1) monthMap[monthKey].grade1++; + else if (grade === 2) monthMap[monthKey].grade2++; + else if (grade === 3) monthMap[monthKey].grade3++; + else if (grade === 4) monthMap[monthKey].grade4++; + else if (grade === 5) monthMap[monthKey].grade5++; + else monthMap[monthKey].grade6++; + } + }); + }); + + // Calculate averages + Object.values(monthMap).forEach((m) => { + m.average = m.count > 0 ? Math.round((m.average / m.count) * 100) / 100 : 0; + }); + + const trendData = Object.values(monthMap).sort((a, b) => a.date.localeCompare(b.date)); + + // Student trajectory data + const studentTrajectories: Record }> = {}; + filteredAssessments.forEach((a) => { + const monthKey = a.date.toISOString().substring(0, 7); + a.assessmentResults.forEach((r) => { + if (r.score !== null && a.maxScore) { + const percentage = r.score / a.maxScore; + let grade: number; + if (percentage >= 0.92) grade = 1; + else if (percentage >= 0.81) grade = 2; + else if (percentage >= 0.67) grade = 3; + else if (percentage >= 0.50) grade = 4; + else if (percentage >= 0.30) grade = 5; + else grade = 6; + + if (!studentTrajectories[r.studentId]) { + studentTrajectories[r.studentId] = { + name: `${r.student.firstName} ${r.student.lastName}`, + data: [], + }; + } + // Check if we already have data for this month + const existing = studentTrajectories[r.studentId].data.find((d) => d.date === monthKey); + if (!existing) { + studentTrajectories[r.studentId].data.push({ date: monthKey, grade }); + } + } + }); + }); + + // Sort trajectory data by date + Object.values(studentTrajectories).forEach((t) => { + t.data.sort((a, b) => a.date.localeCompare(b.date)); + }); + + // Improvement/regression detection + const studentImprovements: Array<{ + studentId: string; + name: string; + trend: 'improving' | 'stable' | 'regressing'; + firstGrade: number; + lastGrade: number; + change: number; + }> = []; + + Object.entries(studentTrajectories).forEach(([id, data]) => { + if (data.data.length >= 2) { + const firstGrade = data.data[0].grade; + const lastGrade = data.data[data.data.length - 1].grade; + const change = firstGrade - lastGrade; // Negative = regressing (grade went up), Positive = improving + let trend: 'improving' | 'stable' | 'regressing' = 'stable'; + if (change >= 0.5) trend = 'improving'; + else if (change <= -0.5) trend = 'regressing'; + + studentImprovements.push({ + studentId: id, + name: data.name, + trend, + firstGrade, + lastGrade, + change: Math.round(change * 100) / 100, + }); + } + }); + + // Percentile calculation + const allStudentAvgs = Object.entries(studentTrajectories).map(([id, data]) => { + const avg = data.data.length > 0 ? data.data.reduce((s, d) => s + d.grade, 0) / data.data.length : 0; + return { id, name: data.name, average: avg }; + }).sort((a, b) => a.average - b.average); + + const percentileData = allStudentAvgs.map((s, idx) => ({ + ...s, + percentile: Math.round((idx / Math.max(allStudentAvgs.length - 1, 1)) * 100), + })); + + return NextResponse.json({ + trendData, + studentTrajectories: Object.entries(studentTrajectories).map(([id, data]) => ({ + id, + ...data, + })), + studentImprovements, + percentileData, + totalAssessments: filteredAssessments.length, + }); + } catch (error) { + console.error('Grade trends error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/peer-assessment-sessions/[id]/results/route.ts b/src/app/api/peer-assessment-sessions/[id]/results/route.ts new file mode 100644 index 0000000..ba82a73 --- /dev/null +++ b/src/app/api/peer-assessment-sessions/[id]/results/route.ts @@ -0,0 +1,182 @@ +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; + + // Verify session exists + const assessmentSession = await db.peerAssessmentSession.findFirst({ + where: { id, deletedAt: null }, + include: { + teacher: { + select: { id: true, firstName: true, lastName: true }, + }, + classGroup: { + select: { id: true, name: true }, + }, + }, + }); + + if (!assessmentSession) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== assessmentSession.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Get all submitted assessments for this session + const assessments = await db.peerAssessment.findMany({ + where: { sessionId: id, status: 'submitted', deletedAt: null }, + include: { + assessor: { + select: { id: true, firstName: true, lastName: true }, + }, + assessed: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + // Parse criteria from session + const sessionCriteria = assessmentSession.criteria + ? JSON.parse(assessmentSession.criteria) + : []; + + // Aggregate results by assessed student + const byStudent: Record; + comments: string[]; + averageRating: number; + assessmentCount: number; + }> = {}; + + for (const a of assessments) { + const sid = a.assessedId; + if (!byStudent[sid]) { + byStudent[sid] = { + student: a.assessed, + ratings: [], + criteriaScores: {}, + comments: [], + averageRating: 0, + assessmentCount: 0, + }; + // Initialize criteria scores + for (const c of sessionCriteria) { + byStudent[sid].criteriaScores[c.name] = { total: 0, count: 0, scores: [] }; + } + } + + if (a.level) { + byStudent[sid].ratings.push(a.level); + } + + // Parse individual criteria scores + if (a.criteria) { + try { + const critScores = JSON.parse(a.criteria); + for (const cs of critScores) { + if (byStudent[sid].criteriaScores[cs.name]) { + byStudent[sid].criteriaScores[cs.name].total += cs.score; + byStudent[sid].criteriaScores[cs.name].count += 1; + byStudent[sid].criteriaScores[cs.name].scores.push(cs.score); + } + } + } catch { + // ignore parse errors + } + } + + if (a.comment) { + byStudent[sid].comments.push(a.comment); + } + + byStudent[sid].assessmentCount += 1; + } + + // Calculate averages + const studentResults = Object.values(byStudent).map((s) => ({ + ...s, + averageRating: s.ratings.length > 0 + ? Math.round((s.ratings.reduce((a, b) => a + b, 0) / s.ratings.length) * 100) / 100 + : 0, + criteriaScores: Object.fromEntries( + Object.entries(s.criteriaScores).map(([name, data]) => [ + name, + { + ...data, + average: data.count > 0 ? Math.round((data.total / data.count) * 100) / 100 : 0, + }, + ]) + ), + })); + + // Overall stats + const allRatings = assessments.filter((a) => a.level).map((a) => a.level as number); + const overallAverage = allRatings.length > 0 + ? Math.round((allRatings.reduce((a, b) => a + b, 0) / allRatings.length) * 100) / 100 + : 0; + + // Outlier detection: ratings more than 2 standard deviations from mean + const ratingStdDev = allRatings.length > 1 + ? Math.sqrt( + allRatings.reduce((sum, r) => sum + Math.pow(r - overallAverage, 2), 0) / + allRatings.length + ) + : 0; + + const outliers = assessments.filter((a) => { + if (!a.level) return false; + return Math.abs(a.level - overallAverage) > 2 * ratingStdDev; + }); + + // Competency averages for radar chart + const competencyAverages: Record = {}; + for (const c of sessionCriteria) { + const scores = studentResults + .map((s) => s.criteriaScores[c.name]?.average || 0) + .filter((v) => v > 0); + competencyAverages[c.name] = scores.length > 0 + ? Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 100) / 100 + : 0; + } + + return NextResponse.json({ + session: assessmentSession, + studentResults, + overallStats: { + totalAssessments: assessments.length, + overallAverage, + ratingStdDev: Math.round(ratingStdDev * 100) / 100, + uniqueAssessors: new Set(assessments.map((a) => a.assessorId)).size, + uniqueAssessed: new Set(assessments.map((a) => a.assessedId)).size, + }, + competencyAverages, + outliers: outliers.map((o) => ({ + id: o.id, + assessor: assessmentSession.anonymityMode === 'anonymous' && o.isAnonymous + ? { id: 'hidden', firstName: 'Anonym', lastName: '' } + : o.assessor, + assessed: o.assessed, + level: o.level, + comment: o.comment, + })), + }); + } catch (error) { + console.error('PeerAssessmentSession results error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/peer-assessment-sessions/[id]/route.ts b/src/app/api/peer-assessment-sessions/[id]/route.ts new file mode 100644 index 0000000..2009536 --- /dev/null +++ b/src/app/api/peer-assessment-sessions/[id]/route.ts @@ -0,0 +1,178 @@ +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 assessmentSession = await db.peerAssessmentSession.findFirst({ + where: { id, deletedAt: null }, + include: { + teacher: { + select: { id: true, firstName: true, lastName: true }, + }, + classGroup: { + select: { id: true, name: true }, + }, + peerAssessments: { + where: { deletedAt: null }, + include: { + assessor: { + select: { id: true, firstName: true, lastName: true }, + }, + assessed: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }, + }, + }); + + if (!assessmentSession) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== assessmentSession.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // For students: hide assessor identity in anonymous mode + if (session.user?.role === 'STUDENT' && assessmentSession.anonymityMode === 'anonymous') { + assessmentSession.peerAssessments = assessmentSession.peerAssessments.map((pa) => ({ + ...pa, + assessor: pa.isAnonymous + ? { id: 'hidden', firstName: 'Anonym', lastName: '' } + : pa.assessor, + })); + } + + return NextResponse.json(assessmentSession); + } catch (error) { + console.error('PeerAssessmentSession 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.peerAssessmentSession.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if ( + session.user?.role !== 'SUPER_ADMIN' && + session.user?.role !== 'SCHOOL_ADMIN' && + session.user?.role !== 'VICE_PRINCIPAL' && + existing.teacherId !== session.user.id + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { + title, + description, + assessmentType, + criteria, + anonymityMode, + status, + deadline, + assignMode, + assignedPairs, + } = body; + + const updated = await db.peerAssessmentSession.update({ + where: { id }, + data: { + ...(title !== undefined && { title }), + ...(description !== undefined && { description }), + ...(assessmentType !== undefined && { assessmentType }), + ...(criteria !== undefined && { criteria: JSON.stringify(criteria) }), + ...(anonymityMode !== undefined && { anonymityMode }), + ...(status !== undefined && { status }), + ...(deadline !== undefined && { deadline: deadline ? new Date(deadline) : null }), + ...(assignMode !== undefined && { assignMode }), + ...(assignedPairs !== undefined && { assignedPairs: JSON.stringify(assignedPairs) }), + }, + include: { + teacher: { + select: { id: true, firstName: true, lastName: true }, + }, + classGroup: { + select: { id: true, name: true }, + }, + }, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('PeerAssessmentSession 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.peerAssessmentSession.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if ( + session.user?.role !== 'SUPER_ADMIN' && + session.user?.role !== 'SCHOOL_ADMIN' && + session.user?.role !== 'VICE_PRINCIPAL' && + existing.teacherId !== session.user.id + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Soft delete session and all related assessments + await db.peerAssessment.updateMany({ + where: { sessionId: id }, + data: { deletedAt: new Date() }, + }); + await db.peerAssessmentSession.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('PeerAssessmentSession DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/peer-assessment-sessions/[id]/submit/route.ts b/src/app/api/peer-assessment-sessions/[id]/submit/route.ts new file mode 100644 index 0000000..e841a95 --- /dev/null +++ b/src/app/api/peer-assessment-sessions/[id]/submit/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function POST( + 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 body = await request.json(); + const { assessmentId, criteria, level, comment } = body; + + if (!assessmentId) { + return NextResponse.json({ error: 'assessmentId is required' }, { status: 400 }); + } + + // Verify the session exists and is active + const assessmentSession = await db.peerAssessmentSession.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!assessmentSession) { + return NextResponse.json({ error: 'Session not found' }, { status: 404 }); + } + + if (assessmentSession.status === 'closed') { + return NextResponse.json({ error: 'Session is closed' }, { status: 400 }); + } + + // Check deadline + if (assessmentSession.deadline && new Date() > assessmentSession.deadline) { + return NextResponse.json({ error: 'Deadline has passed' }, { status: 400 }); + } + + // Verify the assessment belongs to this session + const assessment = await db.peerAssessment.findFirst({ + where: { id: assessmentId, sessionId: id, deletedAt: null }, + }); + + if (!assessment) { + return NextResponse.json({ error: 'Assessment not found in this session' }, { status: 404 }); + } + + // For students: verify they are the assessor + if (session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ + where: { userId: session.user.id, deletedAt: null }, + }); + if (!student || student.id !== assessment.assessorId) { + return NextResponse.json({ error: 'You can only submit your own assessments' }, { status: 403 }); + } + } + + // Update the assessment + const updated = await db.peerAssessment.update({ + where: { id: assessmentId }, + data: { + criteria: criteria ? JSON.stringify(criteria) : assessment.criteria, + level: level ?? assessment.level, + comment: comment ?? assessment.comment, + status: 'submitted', + }, + include: { + assessor: { + select: { id: true, firstName: true, lastName: true }, + }, + assessed: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + // Check if all assessments in session are submitted + const pendingCount = await db.peerAssessment.count({ + where: { sessionId: id, status: 'pending', deletedAt: null }, + }); + + if (pendingCount === 0) { + await db.peerAssessmentSession.update({ + where: { id }, + data: { status: 'completed' }, + }); + } + + return NextResponse.json(updated); + } catch (error) { + console.error('PeerAssessmentSession submit error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/peer-assessment-sessions/route.ts b/src/app/api/peer-assessment-sessions/route.ts new file mode 100644 index 0000000..6c16af9 --- /dev/null +++ b/src/app/api/peer-assessment-sessions/route.ts @@ -0,0 +1,184 @@ +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 status = searchParams.get('status'); + const teacherId = searchParams.get('teacherId'); + const classGroupId = searchParams.get('classGroupId'); + const assessmentType = searchParams.get('assessmentType'); + + 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 (status) where.status = status; + if (teacherId) where.teacherId = teacherId; + if (classGroupId) where.classGroupId = classGroupId; + if (assessmentType) where.assessmentType = assessmentType; + + // For students, only show sessions where they are involved + if (session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ + where: { userId: session.user.id, deletedAt: null }, + }); + if (student) { + where.assignedPairs = { contains: student.id }; + } + } + + const sessions = await db.peerAssessmentSession.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { + teacher: { + select: { id: true, firstName: true, lastName: true }, + }, + classGroup: { + select: { id: true, name: true }, + }, + _count: { + select: { peerAssessments: true }, + }, + }, + }); + + // Enrich with completion stats + const enriched = await Promise.all( + sessions.map(async (s) => { + const total = await db.peerAssessment.count({ + where: { sessionId: s.id, deletedAt: null }, + }); + const submitted = await db.peerAssessment.count({ + where: { sessionId: s.id, status: 'submitted', deletedAt: null }, + }); + return { + ...s, + stats: { total, submitted, pending: total - submitted }, + }; + }) + ); + + return NextResponse.json(enriched); + } catch (error) { + console.error('PeerAssessmentSession 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 }); + } + + // Only teachers and admins can create sessions + if ( + session.user?.role !== 'TEACHER' && + session.user?.role !== 'SCHOOL_ADMIN' && + session.user?.role !== 'SUPER_ADMIN' && + session.user?.role !== 'VICE_PRINCIPAL' + ) { + return NextResponse.json({ error: 'Only teachers and admins can create assessments' }, { status: 403 }); + } + + const body = await request.json(); + const { + schoolId, + teacherId, + classGroupId, + title, + description, + assessmentType, + criteria, + anonymityMode, + deadline, + assignMode, + assignedPairs, + isDemo, + } = body; + + if (!schoolId || !title || !assessmentType) { + return NextResponse.json( + { error: 'schoolId, title, and assessmentType are required' }, + { status: 400 } + ); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const assessmentSession = await db.peerAssessmentSession.create({ + data: { + schoolId, + teacherId: teacherId || session.user.id, + classGroupId: classGroupId || null, + title, + description: description || null, + assessmentType, + criteria: criteria ? JSON.stringify(criteria) : null, + anonymityMode: anonymityMode || 'anonymous', + status: 'active', + deadline: deadline ? new Date(deadline) : null, + assignMode: assignMode || 'manual', + assignedPairs: assignedPairs ? JSON.stringify(assignedPairs) : null, + isDemo: isDemo ?? false, + }, + include: { + teacher: { + select: { id: true, firstName: true, lastName: true }, + }, + classGroup: { + select: { id: true, name: true }, + }, + }, + }); + + // If assignedPairs provided, create individual PeerAssessment records + if (assignedPairs && Array.isArray(assignedPairs) && assignedPairs.length > 0) { + const criteriaForRatings = criteria ? JSON.parse(JSON.stringify(criteria)) : null; + const assessmentRecords = assignedPairs.map( + (pair: { assessorId: string; assessedId: string }) => ({ + schoolId, + sessionId: assessmentSession.id, + assessorId: pair.assessorId, + assessedId: pair.assessedId, + classGroupId: classGroupId || null, + assessmentType, + criteria: criteriaForRatings + ? JSON.stringify(criteriaForRatings.map((c: { name: string; maxScore: number }) => ({ name: c.name, score: 0, maxScore: c.maxScore }))) + : null, + isAnonymous: anonymityMode === 'anonymous' || anonymityMode === 'semi-anonymous', + status: 'pending', + isDemo: isDemo ?? false, + }) + ); + + await db.peerAssessment.createMany({ data: assessmentRecords }); + } + + return NextResponse.json(assessmentSession, { status: 201 }); + } catch (error) { + console.error('PeerAssessmentSession POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/peer-assessments/[id]/route.ts b/src/app/api/peer-assessments/[id]/route.ts index 6d48a60..417ba28 100755 --- a/src/app/api/peer-assessments/[id]/route.ts +++ b/src/app/api/peer-assessments/[id]/route.ts @@ -31,6 +31,9 @@ export async function GET( rubric: { select: { id: true, title: true }, }, + session: { + select: { id: true, title: true, anonymityMode: true }, + }, }, }); @@ -81,6 +84,8 @@ export async function PUT( classGroupId, rubricId, isAnonymous, + criteria, + status, } = body; if (level !== undefined && level !== null && (level < 1 || level > 6)) { @@ -97,6 +102,8 @@ export async function PUT( ...(classGroupId !== undefined && { classGroupId: classGroupId || null }), ...(rubricId !== undefined && { rubricId: rubricId || null }), ...(isAnonymous !== undefined && { isAnonymous }), + ...(criteria !== undefined && { criteria: JSON.stringify(criteria) }), + ...(status !== undefined && { status }), }, include: { assessor: { diff --git a/src/app/api/peer-assessments/route.ts b/src/app/api/peer-assessments/route.ts index 64ccd00..efeaff8 100755 --- a/src/app/api/peer-assessments/route.ts +++ b/src/app/api/peer-assessments/route.ts @@ -16,6 +16,8 @@ export async function GET(request: Request) { const competencyId = searchParams.get('competencyId'); const classGroupId = searchParams.get('classGroupId'); const assessmentType = searchParams.get('assessmentType'); + const sessionId = searchParams.get('sessionId'); + const status = searchParams.get('status'); if (!schoolId) { return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); @@ -35,6 +37,8 @@ export async function GET(request: Request) { if (competencyId) where.competencyId = competencyId; if (classGroupId) where.classGroupId = classGroupId; if (assessmentType) where.assessmentType = assessmentType; + if (sessionId) where.sessionId = sessionId; + if (status) where.status = status; const assessments = await db.peerAssessment.findMany({ where, @@ -55,6 +59,9 @@ export async function GET(request: Request) { rubric: { select: { id: true, title: true }, }, + session: { + select: { id: true, title: true, anonymityMode: true }, + }, }, }); @@ -80,10 +87,12 @@ export async function POST(request: Request) { competencyId, classGroupId, assessmentType, + criteria, level, comment, rubricId, isAnonymous, + sessionId, isDemo, } = body; @@ -113,10 +122,13 @@ export async function POST(request: Request) { competencyId: competencyId || null, classGroupId: classGroupId || null, assessmentType, + criteria: criteria ? JSON.stringify(criteria) : null, level: level ?? null, comment: comment || null, rubricId: rubricId || null, isAnonymous: isAnonymous ?? false, + sessionId: sessionId || null, + status: 'pending', isDemo: isDemo ?? false, }, include: { diff --git a/src/app/api/student-transport/[id]/route.ts b/src/app/api/student-transport/[id]/route.ts index 6cb9bf8..b3b37e8 100755 --- a/src/app/api/student-transport/[id]/route.ts +++ b/src/app/api/student-transport/[id]/route.ts @@ -19,6 +19,9 @@ export async function GET( student: { select: { id: true, firstName: true, lastName: true }, }, + route: { + select: { id: true, routeNumber: true, routeName: true, transportType: true }, + }, }, }); @@ -70,6 +73,7 @@ export async function PUT( driverName, driverPhone, distanceKm, + routeId, notes, } = body; @@ -84,12 +88,16 @@ export async function PUT( ...(driverName !== undefined && { driverName: driverName || null }), ...(driverPhone !== undefined && { driverPhone: driverPhone || null }), ...(distanceKm !== undefined && { distanceKm: distanceKm != null ? distanceKm : null }), + ...(routeId !== undefined && { routeId: routeId || null }), ...(notes !== undefined && { notes }), }, include: { student: { select: { id: true, firstName: true, lastName: true }, }, + route: { + select: { id: true, routeNumber: true, routeName: true, transportType: true }, + }, }, }); diff --git a/src/app/api/student-transport/bulk/route.ts b/src/app/api/student-transport/bulk/route.ts new file mode 100644 index 0000000..1affb8d --- /dev/null +++ b/src/app/api/student-transport/bulk/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// Bulk assign transport to students in a class group +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, studentIds, transportType, routeId, pickupTime, dropoffTime, stopName } = body; + + if (!schoolId || !studentIds || !Array.isArray(studentIds) || studentIds.length === 0 || !transportType) { + return NextResponse.json( + { error: 'schoolId, studentIds (array), and transportType are required' }, + { status: 400 } + ); + } + + const adminRoles = ['SUPER_ADMIN', 'SCHOOL_ADMIN', 'VICE_PRINCIPAL']; + if (!adminRoles.includes(session.user?.role ?? '')) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + if (session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Create or update transport for each student + const results = []; + for (const studentId of studentIds) { + const existing = await db.studentTransport.findFirst({ + where: { schoolId, studentId, deletedAt: null }, + }); + + if (existing) { + const updated = await db.studentTransport.update({ + where: { id: existing.id }, + data: { + transportType, + routeId: routeId || null, + pickupTime: pickupTime || null, + dropoffTime: dropoffTime || null, + stopName: stopName || null, + }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + route: { select: { id: true, routeNumber: true, routeName: true } }, + }, + }); + results.push(updated); + } else { + const created = await db.studentTransport.create({ + data: { + schoolId, + studentId, + transportType, + routeId: routeId || null, + pickupTime: pickupTime || null, + dropoffTime: dropoffTime || null, + stopName: stopName || null, + }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + route: { select: { id: true, routeNumber: true, routeName: true } }, + }, + }); + results.push(created); + } + } + + return NextResponse.json({ count: results.length, assignments: results }, { status: 201 }); + } catch (error) { + console.error('StudentTransport bulk POST 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 index 04f9273..bc95bd3 100755 --- a/src/app/api/student-transport/route.ts +++ b/src/app/api/student-transport/route.ts @@ -35,6 +35,9 @@ export async function GET(request: Request) { student: { select: { id: true, firstName: true, lastName: true }, }, + route: { + select: { id: true, routeNumber: true, routeName: true, transportType: true }, + }, }, }); @@ -64,6 +67,7 @@ export async function POST(request: Request) { driverName, driverPhone, distanceKm, + routeId, notes, isDemo, } = body; @@ -91,6 +95,7 @@ export async function POST(request: Request) { driverName: driverName || null, driverPhone: driverPhone || null, distanceKm: distanceKm != null ? distanceKm : null, + routeId: routeId || null, notes: notes || null, isDemo: isDemo ?? false, }, @@ -98,6 +103,9 @@ export async function POST(request: Request) { student: { select: { id: true, firstName: true, lastName: true }, }, + route: { + select: { id: true, routeNumber: true, routeName: true, transportType: true }, + }, }, }); diff --git a/src/app/api/transport-routes/[id]/route.ts b/src/app/api/transport-routes/[id]/route.ts new file mode 100644 index 0000000..6bf6724 --- /dev/null +++ b/src/app/api/transport-routes/[id]/route.ts @@ -0,0 +1,145 @@ +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 route = await db.transportRoute.findFirst({ + where: { id, deletedAt: null }, + include: { + stops: { orderBy: { stopOrder: 'asc' } }, + assignments: { + where: { deletedAt: null }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }, + _count: { + select: { assignments: { where: { deletedAt: null } } }, + }, + }, + }); + + if (!route) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== route.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(route); + } catch (error) { + console.error('TransportRoute 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.transportRoute.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const adminRoles = ['SUPER_ADMIN', 'SCHOOL_ADMIN', 'VICE_PRINCIPAL']; + if (!adminRoles.includes(session.user?.role ?? '') && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { + routeNumber, + routeName, + transportType, + driverName, + driverPhone, + capacity, + isActive, + notes, + } = body; + + const updated = await db.transportRoute.update({ + where: { id }, + data: { + ...(routeNumber !== undefined && { routeNumber }), + ...(routeName !== undefined && { routeName }), + ...(transportType !== undefined && { transportType }), + ...(driverName !== undefined && { driverName: driverName || null }), + ...(driverPhone !== undefined && { driverPhone: driverPhone || null }), + ...(capacity !== undefined && { capacity }), + ...(isActive !== undefined && { isActive }), + ...(notes !== undefined && { notes }), + }, + include: { + stops: { orderBy: { stopOrder: 'asc' } }, + _count: { select: { assignments: { where: { deletedAt: null } } } }, + }, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('TransportRoute 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.transportRoute.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const adminRoles = ['SUPER_ADMIN', 'SCHOOL_ADMIN', 'VICE_PRINCIPAL']; + if (!adminRoles.includes(session.user?.role ?? '') && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Soft delete + await db.transportRoute.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('TransportRoute DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/transport-routes/[id]/stops/route.ts b/src/app/api/transport-routes/[id]/stops/route.ts new file mode 100644 index 0000000..2a512de --- /dev/null +++ b/src/app/api/transport-routes/[id]/stops/route.ts @@ -0,0 +1,100 @@ +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 route = await db.transportRoute.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!route) { + return NextResponse.json({ error: 'Route not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== route.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const stops = await db.transportStop.findMany({ + where: { routeId: id }, + orderBy: { stopOrder: 'asc' }, + }); + + return NextResponse.json(stops); + } catch (error) { + console.error('TransportStop GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST( + 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 route = await db.transportRoute.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!route) { + return NextResponse.json({ error: 'Route not found' }, { status: 404 }); + } + + const adminRoles = ['SUPER_ADMIN', 'SCHOOL_ADMIN', 'VICE_PRINCIPAL']; + if (!adminRoles.includes(session.user?.role ?? '') && session.user?.schoolId !== route.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { stopName, stopOrder, pickupTime, dropoffTime, address, latitude, longitude } = body; + + if (!stopName) { + return NextResponse.json({ error: 'stopName is required' }, { status: 400 }); + } + + // Get the current max stop order if not provided + let order = stopOrder; + if (order === undefined || order === null) { + const maxStop = await db.transportStop.findFirst({ + where: { routeId: id }, + orderBy: { stopOrder: 'desc' }, + select: { stopOrder: true }, + }); + order = (maxStop?.stopOrder ?? 0) + 1; + } + + const stop = await db.transportStop.create({ + data: { + routeId: id, + stopName, + stopOrder: order, + pickupTime: pickupTime || null, + dropoffTime: dropoffTime || null, + address: address || null, + latitude: latitude != null ? latitude : null, + longitude: longitude != null ? longitude : null, + }, + }); + + return NextResponse.json(stop, { status: 201 }); + } catch (error) { + console.error('TransportStop POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/transport-routes/route.ts b/src/app/api/transport-routes/route.ts new file mode 100644 index 0000000..f4f58dc --- /dev/null +++ b/src/app/api/transport-routes/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) { + 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 isActive = searchParams.get('isActive'); + + 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 (isActive !== null && isActive !== undefined) { + where.isActive = isActive === 'true'; + } + + const routes = await db.transportRoute.findMany({ + where, + orderBy: [{ routeNumber: 'asc' }], + include: { + stops: { + orderBy: { stopOrder: 'asc' }, + }, + _count: { + select: { assignments: { where: { deletedAt: null } } }, + }, + }, + }); + + return NextResponse.json(routes); + } catch (error) { + console.error('TransportRoute 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, + routeNumber, + routeName, + transportType, + driverName, + driverPhone, + capacity, + isActive, + notes, + } = body; + + if (!schoolId || !routeNumber || !routeName || !transportType) { + return NextResponse.json( + { error: 'schoolId, routeNumber, routeName, and transportType are required' }, + { status: 400 } + ); + } + + const adminRoles = ['SUPER_ADMIN', 'SCHOOL_ADMIN', 'VICE_PRINCIPAL']; + if (!adminRoles.includes(session.user?.role ?? '') && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const route = await db.transportRoute.create({ + data: { + schoolId, + routeNumber, + routeName, + transportType, + driverName: driverName || null, + driverPhone: driverPhone || null, + capacity: capacity ?? 40, + isActive: isActive ?? true, + notes: notes || null, + }, + include: { + stops: { orderBy: { stopOrder: 'asc' } }, + _count: { select: { assignments: { where: { deletedAt: null } } } }, + }, + }); + + return NextResponse.json(route, { status: 201 }); + } catch (error) { + console.error('TransportRoute 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 428b5aa..473505e 100755 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3595,4 +3595,359 @@ background: rgba(24, 24, 27, 0.7); border: 1px solid rgba(255, 255, 255, 0.06); } + + /* ─── Round 26: Attendance & Classes Enhancement Utilities ─── */ + + /* Attendance Gauge - Circular SVG gauge */ + .attendance-gauge { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + } + .attendance-gauge svg { + transform: rotate(-90deg); + } + .attendance-gauge-track { + fill: none; + stroke: oklch(0.93 0.005 155); + stroke-width: 6; + } + .dark .attendance-gauge-track { + stroke: oklch(0.24 0.02 260); + } + .attendance-gauge-fill { + fill: none; + stroke-width: 6; + stroke-linecap: round; + animation: drawCircle 1.2s ease-out forwards; + transition: stroke-dashoffset 0.6s ease-out; + } + .attendance-gauge-fill.rate-high { + stroke: oklch(0.55 0.17 155); + } + .attendance-gauge-fill.rate-medium { + stroke: oklch(0.72 0.19 85); + } + .attendance-gauge-fill.rate-low { + stroke: oklch(0.577 0.245 27.325); + } + .attendance-gauge-label { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + transform: rotate(0deg); + } + + /* Grade Indicator - Color-coded grade badge (1-6 German scale) */ + .grade-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.75rem; + height: 1.75rem; + padding: 0 0.375rem; + border-radius: 0.5rem; + font-size: 0.75rem; + font-weight: 700; + line-height: 1; + transition: transform 0.2s ease, box-shadow 0.2s ease; + } + .grade-indicator:hover { + transform: scale(1.08); + } + .grade-indicator.grade-1 { + background: oklch(0.55 0.17 155 / 0.15); + color: oklch(0.45 0.17 155); + border: 1px solid oklch(0.55 0.17 155 / 0.2); + } + .dark .grade-indicator.grade-1 { + background: oklch(0.65 0.17 155 / 0.15); + color: oklch(0.75 0.17 155); + border-color: oklch(0.65 0.17 155 / 0.25); + } + .grade-indicator.grade-2 { + background: oklch(0.53 0.14 175 / 0.15); + color: oklch(0.43 0.14 175); + border: 1px solid oklch(0.53 0.14 175 / 0.2); + } + .dark .grade-indicator.grade-2 { + background: oklch(0.63 0.14 175 / 0.15); + color: oklch(0.73 0.14 175); + border-color: oklch(0.63 0.14 175 / 0.25); + } + .grade-indicator.grade-3 { + background: oklch(0.72 0.19 85 / 0.15); + color: oklch(0.62 0.19 85); + border: 1px solid oklch(0.72 0.19 85 / 0.2); + } + .dark .grade-indicator.grade-3 { + background: oklch(0.82 0.19 85 / 0.15); + color: oklch(0.82 0.15 85); + border-color: oklch(0.82 0.19 85 / 0.25); + } + .grade-indicator.grade-4 { + background: oklch(0.75 0.15 60 / 0.15); + color: oklch(0.65 0.15 60); + border: 1px solid oklch(0.75 0.15 60 / 0.2); + } + .dark .grade-indicator.grade-4 { + background: oklch(0.85 0.15 60 / 0.15); + color: oklch(0.85 0.12 60); + border-color: oklch(0.85 0.15 60 / 0.25); + } + .grade-indicator.grade-5 { + background: oklch(0.65 0.2 40 / 0.15); + color: oklch(0.55 0.2 40); + border: 1px solid oklch(0.65 0.2 40 / 0.2); + } + .dark .grade-indicator.grade-5 { + background: oklch(0.75 0.2 40 / 0.15); + color: oklch(0.75 0.17 40); + border-color: oklch(0.75 0.2 40 / 0.25); + } + .grade-indicator.grade-6 { + background: oklch(0.577 0.245 27.325 / 0.15); + color: oklch(0.477 0.245 27.325); + border: 1px solid oklch(0.577 0.245 27.325 / 0.2); + } + .dark .grade-indicator.grade-6 { + background: oklch(0.677 0.245 27.325 / 0.15); + color: oklch(0.777 0.2 27.325); + border-color: oklch(0.677 0.245 27.325 / 0.25); + } + + /* Card Grid Auto - Auto-filling responsive grid for card layouts */ + .card-grid-auto { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; + } + @media (max-width: 640px) { + .card-grid-auto { + grid-template-columns: 1fr; + } + } + + /* Animate Count Up - Number counting animation */ + .animate-count-up { + animation: countUp 0.8s ease-out forwards; + } + + /* Status Badge - Status indicator badge with variants */ + .status-badge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.625rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + line-height: 1; + border: 1px solid transparent; + transition: transform 0.15s ease, box-shadow 0.15s ease; + } + .status-badge:hover { + transform: scale(1.05); + } + .status-badge-present { + background: oklch(0.55 0.17 155 / 0.12); + color: oklch(0.45 0.17 155); + border-color: oklch(0.55 0.17 155 / 0.2); + } + .dark .status-badge-present { + background: oklch(0.65 0.17 155 / 0.15); + color: oklch(0.75 0.17 155); + border-color: oklch(0.65 0.17 155 / 0.25); + } + .status-badge-late { + background: oklch(0.72 0.19 85 / 0.12); + color: oklch(0.62 0.19 85); + border-color: oklch(0.72 0.19 85 / 0.2); + } + .dark .status-badge-late { + background: oklch(0.82 0.19 85 / 0.15); + color: oklch(0.82 0.15 85); + border-color: oklch(0.82 0.19 85 / 0.25); + } + .status-badge-absent { + background: oklch(0.577 0.245 27.325 / 0.12); + color: oklch(0.477 0.245 27.325); + border-color: oklch(0.577 0.245 27.325 / 0.2); + } + .dark .status-badge-absent { + background: oklch(0.677 0.245 27.325 / 0.15); + color: oklch(0.777 0.2 27.325); + border-color: oklch(0.677 0.245 27.325 / 0.25); + } + .status-badge-excused { + background: oklch(0.72 0.15 85 / 0.12); + color: oklch(0.62 0.15 85); + border-color: oklch(0.72 0.15 85 / 0.2); + } + .dark .status-badge-excused { + background: oklch(0.82 0.15 85 / 0.15); + color: oklch(0.82 0.12 85); + border-color: oklch(0.82 0.15 85 / 0.25); + } + + /* Keyframe: countUp - Number counting up */ + @keyframes countUp { + 0% { opacity: 0; transform: translateY(8px); } + 60% { opacity: 1; } + 100% { opacity: 1; transform: translateY(0); } + } + + /* Keyframe: drawCircle - SVG circle stroke animation */ + @keyframes drawCircle { + from { stroke-dashoffset: var(--circumference, 251); } + to { stroke-dashoffset: var(--offset, 0); } + } + + /* Keyframe: fadeInScale - Fade in with scale */ + @keyframes fadeInScale { + from { opacity: 0; transform: scale(0.92); } + to { opacity: 1; transform: scale(1); } + } + + /* Glassmorphism enhanced card */ + .glass-card-enhanced { + background: rgba(255, 255, 255, 0.6); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.4); + box-shadow: 0 4px 24px -4px rgba(16, 185, 129, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); + transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease; + } + .glass-card-enhanced:hover { + transform: translateY(-3px); + box-shadow: 0 12px 32px -8px rgba(16, 185, 129, 0.15), 0 2px 4px rgba(0, 0, 0, 0.06); + border-color: rgba(16, 185, 129, 0.3); + } + .dark .glass-card-enhanced { + background: rgba(24, 24, 27, 0.6); + border: 1px solid rgba(255, 255, 255, 0.06); + box-shadow: 0 4px 24px -4px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2); + } + .dark .glass-card-enhanced:hover { + transform: translateY(-3px); + box-shadow: 0 12px 32px -8px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(16, 185, 129, 0.12); + border-color: rgba(16, 185, 129, 0.15); + } + + /* Gradient header banner */ + .gradient-header-banner { + background: linear-gradient(135deg, oklch(0.55 0.17 155 / 0.08) 0%, oklch(0.53 0.14 175 / 0.06) 50%, oklch(0.55 0.17 155 / 0.03) 100%); + border-bottom: 1px solid oklch(0.55 0.17 155 / 0.1); + } + .dark .gradient-header-banner { + background: linear-gradient(135deg, oklch(0.65 0.17 155 / 0.12) 0%, oklch(0.63 0.14 175 / 0.08) 50%, oklch(0.65 0.17 155 / 0.04) 100%); + border-bottom-color: oklch(0.65 0.17 155 / 0.15); + } + + /* Mini calendar date picker */ + .mini-calendar-day { + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.5rem; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + } + .mini-calendar-day:hover { + background: oklch(0.55 0.17 155 / 0.1); + color: oklch(0.35 0.17 155); + } + .dark .mini-calendar-day:hover { + background: oklch(0.65 0.17 155 / 0.15); + color: oklch(0.85 0.17 155); + } + .mini-calendar-day.selected { + background: oklch(0.55 0.17 155); + color: white; + box-shadow: 0 2px 8px -2px oklch(0.55 0.17 155 / 0.4); + } + .dark .mini-calendar-day.selected { + background: oklch(0.65 0.17 155); + } + .mini-calendar-day.today { + border: 1.5px solid oklch(0.55 0.17 155); + font-weight: 700; + } + .dark .mini-calendar-day.today { + border-color: oklch(0.65 0.17 155); + } + .mini-calendar-day.has-session { + position: relative; + } + .mini-calendar-day.has-session::after { + content: ''; + position: absolute; + bottom: 2px; + width: 4px; + height: 4px; + border-radius: 50%; + background: oklch(0.55 0.17 155); + } + .dark .mini-calendar-day.has-session::after { + background: oklch(0.65 0.17 155); + } + + /* Attendance timeline dot */ + .timeline-dot { + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid white; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1); + transition: transform 0.2s ease; + } + .timeline-dot:hover { + transform: scale(1.3); + } + .dark .timeline-dot { + border-color: rgba(24, 24, 27, 0.8); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1); + } + + /* Quick action button */ + .quick-action-btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border-radius: 0.5rem; + font-size: 0.75rem; + font-weight: 600; + border: 1px solid oklch(0.93 0.005 155); + background: oklch(0.99 0.002 155); + color: oklch(0.35 0.01 260); + transition: all 0.2s ease; + cursor: pointer; + } + .quick-action-btn:hover { + background: oklch(0.55 0.17 155 / 0.08); + border-color: oklch(0.55 0.17 155 / 0.2); + color: oklch(0.45 0.17 155); + transform: translateY(-1px); + box-shadow: 0 2px 8px -2px oklch(0.55 0.17 155 / 0.2); + } + .dark .quick-action-btn { + border-color: oklch(0.24 0.02 260); + background: oklch(0.18 0.015 260); + color: oklch(0.75 0.005 260); + } + .dark .quick-action-btn:hover { + background: oklch(0.65 0.17 155 / 0.12); + border-color: oklch(0.65 0.17 155 / 0.25); + color: oklch(0.85 0.17 155); + box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.3); + } } diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index c9d739d..a26e56b 100755 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -72,6 +72,8 @@ import { PartyPopper, Newspaper, Compass, + UsersRound, + Bus, } from 'lucide-react'; import { Sidebar, @@ -192,6 +194,9 @@ import SchoolEventsView from './school-events-view'; import StudentCareerView from './student-career-view'; import SubstituteTeacherView from './substitute-teacher-view'; import SchoolNewsletterView from './school-newsletter-view'; +import SchoolTransportView from './school-transport-view'; +import PeerAssessmentView from './peer-assessment-view'; +import GradeAnalyticsView from './grade-analytics-view'; // Dynamic imports for heavy components with loading skeletons const AnalyticsView = dynamic(() => import('./analytics-view'), { @@ -223,6 +228,7 @@ const navSections: NavSection[] = [ { key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' }, { key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' }, { key: 'analytics', icon: TrendingUp, labelKey: 'nav.analytics' }, + { key: 'grade-analytics', icon: BarChart3, labelKey: 'nav.grade-analytics' }, { key: 'reports', icon: FileText, labelKey: 'nav.reports' }, { key: 'coverage', icon: Target, labelKey: 'nav.coverage' }, ], @@ -258,6 +264,7 @@ const navSections: NavSection[] = [ { key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' }, { key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' }, { key: 'exam-calendar', icon: CalendarClock, labelKey: 'nav.exam_calendar' }, + { key: 'peer-assessment', icon: UsersRound, labelKey: 'nav.peer-assessment' }, { key: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' }, { key: 'seating-chart', icon: LayoutGridIcon, labelKey: 'nav.seating_chart' }, { key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' }, @@ -267,6 +274,7 @@ const navSections: NavSection[] = [ { key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' }, { key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' }, { key: 'school-newsletter', icon: Newspaper, labelKey: 'nav.school-newsletter' }, + { key: 'school-transport', icon: Bus, labelKey: 'nav.school-transport' }, { key: 'substitute-teacher', icon: UserCheck, labelKey: 'nav.substitute-teacher' }, { key: 'data-import-export', icon: Database, labelKey: 'nav.data-import-export' }, ], @@ -293,6 +301,7 @@ const studentNavSections: NavSection[] = [ { key: 'notebooks', icon: BookOpen, labelKey: 'nav.student_notebooks' }, { key: 'flower', icon: Flower2, labelKey: 'nav.student_competencies' }, { key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' }, + { key: 'grade-analytics', icon: BarChart3, labelKey: 'nav.grade-analytics' }, { key: 'homework', icon: BookCheck, labelKey: 'nav.homework' }, { key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' }, { key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' }, @@ -305,6 +314,7 @@ const studentNavSections: NavSection[] = [ { key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' }, { key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' }, { key: 'exam-calendar', icon: CalendarClock, labelKey: 'nav.exam_calendar' }, + { key: 'peer-assessment', icon: UsersRound, labelKey: 'nav.peer-assessment' }, { key: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' }, { key: 'seating-chart', icon: LayoutGridIcon, labelKey: 'nav.seating_chart' }, { key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' }, @@ -315,6 +325,7 @@ const studentNavSections: NavSection[] = [ { key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' }, { key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' }, { key: 'school-newsletter', icon: Newspaper, labelKey: 'nav.school-newsletter' }, + { key: 'school-transport', icon: Bus, labelKey: 'nav.school-transport' }, { key: 'substitute-teacher', icon: UserCheck, labelKey: 'nav.substitute-teacher' }, { key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' }, { key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' }, @@ -333,6 +344,7 @@ const parentNavSections: NavSection[] = [ { key: 'parent-portal', icon: Heart, labelKey: 'parent_portal.title' }, { key: 'parents', icon: Mail, labelKey: 'parent.messages' }, { key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' }, + { key: 'grade-analytics', icon: BarChart3, labelKey: 'nav.grade-analytics' }, { key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' }, { key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' }, { key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' }, @@ -341,10 +353,12 @@ const parentNavSections: NavSection[] = [ { key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' }, { key: 'student-study-planner', icon: Timer, labelKey: 'study_planner.title' }, { key: 'student-career', icon: Compass, labelKey: 'nav.student-career' }, + { key: 'peer-assessment', icon: UsersRound, labelKey: 'nav.peer-assessment' }, { key: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' }, { key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' }, { key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' }, { key: 'school-newsletter', icon: Newspaper, labelKey: 'nav.school-newsletter' }, + { key: 'school-transport', icon: Bus, labelKey: 'nav.school-transport' }, { key: 'substitute-teacher', icon: UserCheck, labelKey: 'nav.substitute-teacher' }, { key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' }, { key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' }, @@ -491,6 +505,8 @@ function renderView(view: ViewName) { case 'announcements': return ; case 'tablet-grading': return ; case 'exam-calendar': return ; + case 'peer-assessment': return ; + case 'grade-analytics': return ; case 'student-achievements': return ; case 'student-study-planner': return ; case 'seating-chart': return ; @@ -502,6 +518,7 @@ function renderView(view: ViewName) { case 'school-events': return ; case 'substitute-teacher': return ; case 'school-newsletter': return ; + case 'school-transport': return ; default: return ; } } diff --git a/src/components/attendance-view.tsx b/src/components/attendance-view.tsx index 8b7d09a..54650c5 100755 --- a/src/components/attendance-view.tsx +++ b/src/components/attendance-view.tsx @@ -13,6 +13,7 @@ import { Save, ChevronDown, ChevronRight, + ChevronLeft, Loader2, Users, TrendingUp, @@ -31,6 +32,9 @@ import { FileDown, RefreshCw, Heart, + Activity, + CalendarDays, + CircleDot, } from 'lucide-react'; import { LineChart, @@ -362,6 +366,197 @@ function MonthlyHeatmap({ sessions }: { sessions: AttendanceSession[] }) { ); } +/* ── Attendance Gauge SVG Component ─────────────────────────────────── */ + +function AttendanceGauge({ rate, size = 100 }: { rate: number; size?: number }) { + const strokeWidth = 8; + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (rate / 100) * circumference; + const rateClass = rate >= 90 ? 'rate-high' : rate >= 70 ? 'rate-medium' : 'rate-low'; + + return ( +
+ + + + +
+ {rate}% + {t('attendance.rate')} +
+
+ ); +} + +/* ── Mini Calendar Date Picker ──────────────────────────────────────── */ + +function MiniCalendar({ + selectedDate, + onDateSelect, + sessionDates, +}: { + selectedDate: string; + onDateSelect: (date: string) => void; + sessionDates: Set; +}) { + const [viewMonth, setViewMonth] = useState(() => { + const d = new Date(selectedDate); + return { year: d.getFullYear(), month: d.getMonth() }; + }); + + const daysInMonth = new Date(viewMonth.year, viewMonth.month + 1, 0).getDate(); + const firstDayOfWeek = new Date(viewMonth.year, viewMonth.month, 1).getDay(); + const adjustedFirstDay = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1; + const dayLabels = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; + const today = new Date(); + const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + + const cells: Array<{ day: number | null; dateKey: string | null }> = []; + for (let i = 0; i < adjustedFirstDay; i++) { + cells.push({ day: null, dateKey: null }); + } + for (let d = 1; d <= daysInMonth; d++) { + const dateKey = `${viewMonth.year}-${String(viewMonth.month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; + cells.push({ day: d, dateKey }); + } + + const monthNames = [ + t('date.jan') ?? 'Jan', t('date.feb') ?? 'Feb', t('date.mar') ?? 'Mar', + t('date.apr') ?? 'Apr', t('date.may') ?? 'May', t('date.jun') ?? 'Jun', + t('date.jul') ?? 'Jul', t('date.aug') ?? 'Aug', t('date.sep') ?? 'Sep', + t('date.oct') ?? 'Oct', t('date.nov') ?? 'Nov', t('date.dec') ?? 'Dec', + ]; + + return ( +
+
+ + + {monthNames[viewMonth.month]} {viewMonth.year} + + +
+
+ {dayLabels.map((d) => ( +
{d}
+ ))} + {cells.map((cell, i) => { + const isToday = cell.dateKey === todayStr; + const isSelected = cell.dateKey === selectedDate; + const hasSession = cell.dateKey ? sessionDates.has(cell.dateKey) : false; + return ( + + ); + })} +
+
+ ); +} + +/* ── Daily Attendance Timeline ──────────────────────────────────────── */ + +function DailyTimeline({ sessions, selectedDate }: { sessions: AttendanceSession[]; selectedDate: string }) { + const daySessions = useMemo(() => { + return sessions.filter((s) => { + const d = new Date(s.date); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + return key === selectedDate; + }); + }, [sessions, selectedDate]); + + if (daySessions.length === 0) { + return ( +
+ {t('attendance.no_sessions')} +
+ ); + } + + return ( +
+ {daySessions.map((session) => { + const presentCount = session.records.filter((r) => r.status === 'PRESENT').length; + const lateCount = session.records.filter((r) => r.status === 'LATE').length; + const absentCount = session.records.filter((r) => r.status === 'ABSENT').length; + const excusedCount = session.records.filter((r) => r.status === 'EXCUSED').length; + const total = session.records.length; + const rate = total > 0 ? Math.round(((presentCount + lateCount) / total) * 100) : 0; + + return ( +
+
+
= 90 ? 'bg-emerald-500' : rate >= 70 ? 'bg-amber-500' : 'bg-rose-500'}`} /> +
+
+
+
+
+ + {session.period ?? t('attendance.status')} + + {session.subject && ( + {session.subject.name} + )} +
+
+ {presentCount} + {lateCount} + {absentCount} + {excusedCount > 0 && {excusedCount}} +
+
+
+
+
0 ? (presentCount / total) * 100 : 0}%` }} /> +
0 ? (lateCount / total) * 100 : 0}%` }} /> +
0 ? (absentCount / total) * 100 : 0}%` }} /> +
+
+
+
+ ); + })} +
+ ); +} + /* ── Stats Cards ───────────────────────────────────────────────────── */ function StatsCards({ sessions }: { sessions: AttendanceSession[] }) { @@ -426,28 +621,41 @@ function StatsCards({ sessions }: { sessions: AttendanceSession[] }) { ]; return ( -
- {cards.map((card, idx) => ( - - - -
- - {card.label} -
-
- {card.value} -
-
-
-
- ))} +
+ {/* Attendance Gauge + Summary */} +
+ +
+
+
+ + {t('attendance.present')} +
+ {stats.present} +
+
+
+ + {t('attendance.absent')} +
+ {stats.absent} +
+
+
+ + {t('attendance.excused')} +
+ {stats.excused} +
+
+
+ + {t('attendance.late')} +
+ {stats.late} +
+
+
); } @@ -1300,19 +1508,48 @@ export default function AttendanceView() { return (
- {/* Header */} + {/* Gradient Header Banner */} -
-

- - {t('attendance.title')} -

-

{t('attendance.subtitle')}

-
+
+
+
+ +
+
+

+ {t('attendance.title')} +

+

{t('attendance.subtitle')}

+
+
+
+ {/* Today's Attendance Quick View */} + {sessions.length > 0 && (() => { + const today = new Date(); + const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + const todaySessions = sessions.filter((s) => { + const d = new Date(s.date); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + return key === todayStr; + }); + const todayPresent = todaySessions.reduce((sum, s) => sum + s.records.filter((r) => r.status === 'PRESENT').length, 0); + const todayTotal = todaySessions.reduce((sum, s) => sum + s.records.length, 0); + const todayRate = todayTotal > 0 ? Math.round((todayPresent / todayTotal) * 100) : 0; + if (todayTotal === 0) return null; + return ( +
+ + {t('date.today')}: + = 90 ? 'text-emerald-600 dark:text-emerald-400' : todayRate >= 70 ? 'text-amber-600 dark:text-amber-400' : 'text-rose-600 dark:text-rose-400'}`}>{todayRate}% +
+ ); + })()} +
{/* Class selector */} @@ -1412,6 +1649,7 @@ export default function AttendanceView() {
+
{/* No class selected */} @@ -1430,7 +1668,7 @@ export default function AttendanceView() {
{/* Quick Stats card — fills the empty space with school-wide attendance stats */} - +
@@ -1531,6 +1769,10 @@ export default function AttendanceView() { {t('attendance.calendar')} + + + {t('date.today')} + {t('qr.attendance')} @@ -1571,16 +1813,14 @@ export default function AttendanceView() { exit={{ opacity: 0, x: 20 }} transition={{ delay: idx * 0.05 }} > - setSelectedSessionId(session.id)} >
-
+
{sessionDate.getDate()} @@ -1614,7 +1854,7 @@ export default function AttendanceView() { -
+
{presentCount}
@@ -1625,7 +1865,7 @@ export default function AttendanceView() { -
+
{absentCount}
@@ -1636,7 +1876,7 @@ export default function AttendanceView() { -
+
{excusedCount}
@@ -1647,7 +1887,7 @@ export default function AttendanceView() { -
+
{lateCount}
@@ -1659,13 +1899,7 @@ export default function AttendanceView() { {/* Rate badge */}
= 90 - ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300' - : rate >= 70 - ? 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300' - : 'bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300' - }`} + className={`status-badge ${rate >= 90 ? 'status-badge-present' : rate >= 70 ? 'status-badge-late' : 'status-badge-absent'}`} > {rate}%
@@ -1710,9 +1944,100 @@ export default function AttendanceView() { + {/* Today's Attendance Tab */} + +
+ {/* Mini Calendar */} + + + + + {t('attendance.calendar')} + + + + { + const dates = new Set(); + for (const session of sessions) { + const d = new Date(session.date); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + dates.add(key); + } + return dates; + })()} + /> + + + {/* Daily Timeline */} +
+ + + + + {t('attendance.daily_timeline') ?? (t('date.today') === 'Heute' ? 'Tagesverlauf' : 'Daily Timeline')} + {newDate} + + + + + + + {/* Attendance Gauge for selected date */} + + + + + {t('attendance.rate') ?? 'Rate'} + + + +
+ {(() => { + const daySessions = sessions.filter((s) => { + const d = new Date(s.date); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + return key === newDate; + }); + const present = daySessions.reduce((sum, s) => sum + s.records.filter((r) => r.status === 'PRESENT').length, 0); + const total = daySessions.reduce((sum, s) => sum + s.records.length, 0); + const rate = total > 0 ? Math.round((present / total) * 100) : 0; + return ( + <> + +
+
+
+ {t('attendance.present')}: {present} +
+
+
+ {t('attendance.absent')}: {daySessions.reduce((sum, s) => sum + s.records.filter((r) => r.status === 'ABSENT').length, 0)} +
+
+
+ {t('attendance.late')}: {daySessions.reduce((sum, s) => sum + s.records.filter((r) => r.status === 'LATE').length, 0)} +
+
+
+ {t('attendance.total_records') ?? 'Total'}: {total} +
+
+ + ); + })()} +
+ + +
+
+ + {/* QR Attendance Tab */} - +
diff --git a/src/components/classes-view.tsx b/src/components/classes-view.tsx index 581410b..ddacb5e 100755 --- a/src/components/classes-view.tsx +++ b/src/components/classes-view.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; -import { motion } from 'framer-motion'; +import { motion, AnimatePresence } from 'framer-motion'; import { Users, Plus, Search, BookOpen, UserPlus, Grid3X3, School, Download, Upload, FileText, CheckCircle2, AlertTriangle, FileUp, @@ -13,6 +13,7 @@ import { Shuffle, Printer, Eraser, Columns3, Rows3, Move, Camera, UserCheck, Shield, Eye, MessageSquare, CalendarDays, AlertCircle, + Activity, Zap, TrendingUp, CircleDot, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -143,6 +144,86 @@ const masteryBadgeClass = (level: number) => { return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300'; }; +// Grade level color mapping (German school system 1-13) +const gradeLevelColors: Record = { + '1': 'bg-emerald-500', '2': 'bg-emerald-400', '3': 'bg-teal-500', + '4': 'bg-teal-400', '5': 'bg-cyan-500', '6': 'bg-cyan-400', + '7': 'bg-blue-500', '8': 'bg-blue-400', '9': 'bg-violet-500', + '10': 'bg-violet-400', '11': 'bg-purple-500', '12': 'bg-purple-400', + '13': 'bg-rose-500', +}; +const gradeLevelTextColors: Record = { + '1': 'text-emerald-700 dark:text-emerald-300', '2': 'text-emerald-600 dark:text-emerald-400', + '3': 'text-teal-700 dark:text-teal-300', '4': 'text-teal-600 dark:text-teal-400', + '5': 'text-cyan-700 dark:text-cyan-300', '6': 'text-cyan-600 dark:text-cyan-400', + '7': 'text-blue-700 dark:text-blue-300', '8': 'text-blue-600 dark:text-blue-400', + '9': 'text-violet-700 dark:text-violet-300', '10': 'text-violet-600 dark:text-violet-400', + '11': 'text-purple-700 dark:text-purple-300', '12': 'text-purple-600 dark:text-purple-400', + '13': 'text-rose-700 dark:text-rose-300', +}; +const gradeLevelBgColors: Record = { + '1': 'bg-emerald-100 dark:bg-emerald-900/30', '2': 'bg-emerald-50 dark:bg-emerald-900/20', + '3': 'bg-teal-100 dark:bg-teal-900/30', '4': 'bg-teal-50 dark:bg-teal-900/20', + '5': 'bg-cyan-100 dark:bg-cyan-900/30', '6': 'bg-cyan-50 dark:bg-cyan-900/20', + '7': 'bg-blue-100 dark:bg-blue-900/30', '8': 'bg-blue-50 dark:bg-blue-900/20', + '9': 'bg-violet-100 dark:bg-violet-900/30', '10': 'bg-violet-50 dark:bg-violet-900/20', + '11': 'bg-purple-100 dark:bg-purple-900/30', '12': 'bg-purple-50 dark:bg-purple-900/20', + '13': 'bg-rose-100 dark:bg-rose-900/30', +}; + +// Animated count-up component +function AnimatedCount({ value, duration = 800 }: { value: number; duration?: number }) { + const [display, setDisplay] = useState(0); + useEffect(() => { + let start = 0; + const end = value; + if (end === 0) { setDisplay(0); return; } + const stepTime = Math.max(Math.floor(duration / end), 16); + const timer = setInterval(() => { + start += 1; + setDisplay(start); + if (start >= end) clearInterval(timer); + }, stepTime); + return () => clearInterval(timer); + }, [value, duration]); + return {display}; +} + +// Attendance gauge SVG component +function AttendanceGauge({ rate, size = 80 }: { rate: number; size?: number }) { + const strokeWidth = 6; + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (rate / 100) * circumference; + const rateClass = rate >= 90 ? 'rate-high' : rate >= 70 ? 'rate-medium' : 'rate-low'; + + return ( +
+ + + + +
+ {rate}% + {t('attendance.rate')} +
+
+ ); +} + export default function ClassesView() { const currentUser = useAppStore((s) => s.currentUser); const currentClassId = useAppStore((s) => s.currentClassId); @@ -844,9 +925,134 @@ export default function ClassesView() { } return ( -
+
+ {/* Gradient Header Banner */} + +
+
+
+ +
+
+

+ {t('classes.title')} +

+

+ {locale === 'de' ? 'Klassen und Schueler verwalten' : 'Manage classes and students'} +

+
+
+
+
+ + + {locale === 'de' ? 'Klassen' : 'Classes'} + +
+
+ + + s + (c.studentCount ?? 0), 0)} /> {locale === 'de' ? 'Schueler' : 'Students'} + +
+
+
+
+ + {/* Class Statistics Summary */} + {filteredClasses.length > 0 && ( + +
+
+
+ +
+ {locale === 'de' ? 'Klassen' : 'Classes'} +
+

{filteredClasses.length}

+
+
+
+
+ +
+ {locale === 'de' ? 'Schueler' : 'Students'} +
+

{filteredClasses.reduce((s, c) => s + (c.studentCount ?? 0), 0)}

+
+
+
+
+ +
+ {locale === 'de' ? 'Faecher' : 'Subjects'} +
+

{filteredClasses.reduce((s, c) => s + (c._count?.competencyAssignments ?? 0), 0)}

+
+
+
+
+ +
+ {locale === 'de' ? 'Jahrgaenge' : 'Grades'} +
+

{new Set(filteredClasses.map((c) => c.gradeLevel)).size}

+
+
+ )} + + {/* Class Comparison Mini-Chart */} + {filteredClasses.length > 1 && ( + +
+ + {locale === 'de' ? 'Klassenvergleich' : 'Class Comparison'} + {locale === 'de' ? 'Schueler pro Klasse' : 'Students per class'} +
+
+ {filteredClasses.slice(0, 6).map((cls) => { + const count = cls.studentCount ?? 0; + const maxStudents = Math.max(...filteredClasses.map((c) => c.studentCount ?? 0), 1); + const pct = (count / maxStudents) * 100; + const gradeKey = String(cls.gradeLevel ?? 1); + return ( +
+ {cls.gradeLevel} + {cls.name} +
+ +
+ {count} +
+ ); + })} +
+
+ )} + +
{/* Class list */} - +
@@ -897,68 +1103,93 @@ export default function ClassesView() {
) : (
- {filteredClasses.map((cls) => ( - handleSelectClass(cls)} - className={`w-full text-left rounded-xl transition-all duration-200 border-l-3 ${schoolTypeAccent[cls.schoolType] ?? 'border-l-emerald-500'} bg-gradient-to-r ${schoolTypeGradient[cls.schoolType] ?? schoolTypeGradient.OTHER} hover:shadow-xl hover:shadow-emerald-500/8 card-shadow-transition ${ - selectedClass?.id === cls.id - ? 'shadow-lg ring-2 ring-emerald-300/60 dark:ring-emerald-700/50' - : 'hover:shadow-lg hover:shadow-emerald-200/30 dark:hover:shadow-emerald-900/20' - }`} - > - {/* Gradient overlay strip at top */} -
-
-
-
-
-

{cls.name}

- - {schoolTypeIcon[cls.schoolType] ?? } - {t(`school_type.${cls.schoolType.toLowerCase()}`)} - - {cls.responsibleTeacher && ( - - - {cls.responsibleTeacher.firstName} {cls.responsibleTeacher.lastName} + {filteredClasses.map((cls) => { + const gradeKey = String(cls.gradeLevel ?? 1); + return ( + handleSelectClass(cls)} + className={`w-full text-left glass-card-enhanced rounded-xl transition-all duration-200 border-l-3 ${schoolTypeAccent[cls.schoolType] ?? 'border-l-emerald-500'} ${ + selectedClass?.id === cls.id + ? 'shadow-lg ring-2 ring-emerald-300/60 dark:ring-emerald-700/50' + : '' + }`} + > + {/* Gradient overlay strip at top */} +
+
+
+
+
+

{cls.name}

+ {/* Grade level indicator */} + + {cls.gradeLevel} + + + {schoolTypeIcon[cls.schoolType] ?? } + {t(`school_type.${cls.schoolType.toLowerCase()}`)} - )} + {cls.responsibleTeacher && ( + + + {cls.responsibleTeacher.firstName} {cls.responsibleTeacher.lastName} + + )} +
+

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

+ {/* Student count + subject count with icons */} +
+ + + {t('label.student_count').toLowerCase()} + + + + {cls._count?.competencyAssignments ?? 0} {locale === 'de' ? 'Faecher' : 'subjects'} + +
+ {/* Quick Actions */} +
+ + +
-

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

- {/* Student count + subject count with icons */} -
- - - {cls.studentCount ?? 0} {t('label.student_count').toLowerCase()} - - - - {cls._count?.competencyAssignments ?? 0} {locale === 'de' ? 'Faecher' : 'subjects'} - +
+ {/* Small progress indicator */} +
+
+
+ {/* Animated student count badge */} + + + {cls.studentCount ?? 0} +
-
- {/* Small progress indicator */} -
-
-
- - {cls.studentCount ?? 0} - -
-
- - ))} + + ); + })}
)} @@ -968,7 +1199,7 @@ export default function ClassesView() {
{!selectedClass ? (
- +
@@ -985,7 +1216,7 @@ export default function ClassesView() { {/* Recent Activity widget — fills the empty space with useful info */} - +
@@ -1061,7 +1292,7 @@ export default function ClassesView() { ) : ( <> {/* Class details */} - +
@@ -1282,7 +1513,7 @@ export default function ClassesView() {
{/* Student roster */} - +
@@ -1873,6 +2104,7 @@ export default function ClassesView() { )}
+
{/* Add student dialog */} diff --git a/src/components/grade-analytics-view.tsx b/src/components/grade-analytics-view.tsx new file mode 100644 index 0000000..61cc8ff --- /dev/null +++ b/src/components/grade-analytics-view.tsx @@ -0,0 +1,1820 @@ +'use client'; + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { motion } from 'framer-motion'; +import { + BarChart3, + TrendingUp, + TrendingDown, + Users, + BookOpen, + FileText, + Download, + Filter, + AlertTriangle, + ChevronRight, + ArrowUpRight, + ArrowDownRight, + RefreshCw, + Calendar, + CheckCircle2, + Clock, + XCircle, + Loader2, + GraduationCap, + School, + Target, + BarChart2, + PieChart as PieChartIcon, + LineChart as LineChartIcon, + FileSpreadsheet, + Settings2, + Plus, +} from 'lucide-react'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, + LineChart, + Line, + AreaChart, + Area, + RadarChart, + PolarGrid, + PolarAngleAxis, + PolarRadiusAxis, + Radar, + Cell, +} from 'recharts'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, DialogClose } from '@/components/ui/dialog'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Progress } from '@/components/ui/progress'; +import { useAppStore } from '@/lib/store'; +import { t } from '@/lib/i18n'; + +// ── Types ────────────────────────────────────────────────────────── + +interface GradeDistribution { + grade: number; + count: number; + percentage: number; + color: string; + label: string; +} + +interface BellCurvePoint { + x: number; + y: number; +} + +interface DistributionData { + type: string; + distribution: GradeDistribution[]; + bellCurve: BellCurvePoint[]; + mean: number; + stdDev: number; + total: number; +} + +interface SubjectDistribution { + id: string; + name: string; + distribution: Record; +} + +interface TrendPoint { + date: string; + average: number; + count: number; + grade1: number; + grade2: number; + grade3: number; + grade4: number; + grade5: number; + grade6: number; +} + +interface StudentTrajectory { + id: string; + name: string; + data: Array<{ date: string; grade: number }>; +} + +interface StudentImprovement { + studentId: string; + name: string; + trend: 'improving' | 'stable' | 'regressing'; + firstGrade: number; + lastGrade: number; + change: number; +} + +interface PercentileData { + id: string; + name: string; + average: number; + percentile: number; +} + +interface ClassRanking { + id: string; + name: string; + gradeLevel: number; + average: number; + median: number; + stdDev: number; + gradeCount: number; + rank: number; + anonymizedName: string; +} + +interface SubjectComparison { + subjectId: string; + subjectName: string; + classes: Array<{ + classId: string; + className: string; + average: number; + count: number; + }>; +} + +interface SubjectRanking { + id: string; + name: string; + average: number; + variance: number; + gradeCount: number; + difficulty: string; +} + +interface CorrelationEntry { + subject1: string; + subject2: string; + name1: string; + name2: string; + correlation: number; +} + +interface TeacherRanking { + id: string; + name: string; + classIds: string[]; + average: number; + classCount: number; + gradeCount: number; +} + +interface GradeReportItem { + id: string; + title: string; + type: string; + dateRange: string; + status: string; + generatedBy: string; + createdAt: string; + hasFileData: boolean; +} + +interface AnalyticsOverview { + totalGrades: number; + averageGrade: number; + minGrade: number; + maxGrade: number; + finalizedCount: number; + draftCount: number; + distribution: Record; + topPerformers: Array<{ id: string; name: string; average: number; gradeCount: number }>; + bottomPerformers: Array<{ id: string; name: string; average: number; gradeCount: number }>; + riskStudents: Array<{ id: string; name: string; average: number; gradeCount: number }>; + classes: Array<{ id: string; name: string; gradeLevel: number }>; + subjects: Array<{ id: string; name: string }>; + subjectDifficulty: Array<{ id: string; name: string; average: number; gradeCount: number }>; + classAverages: Array<{ id: string; name: string; average: number; gradeCount: number }>; + teacherComparison: Array<{ id: string; name: string; average: number; gradeCount: number }>; +} + +// ── Grade color mapping (German 1-6 scale) ──────────────────────── + +const GRADE_COLORS: Record = { + 1: '#10b981', // emerald + 2: '#22c55e', // green + 3: '#eab308', // yellow + 4: '#f59e0b', // amber + 5: '#f97316', // orange + 6: '#ef4444', // red +}; + +const GRADE_LABELS_DE: Record = { + 1: 'Sehr gut', + 2: 'Gut', + 3: 'Befriedigend', + 4: 'Ausreichend', + 5: 'Mangelhaft', + 6: 'Ungenügend', +}; + +const GRADE_LABELS_EN: Record = { + 1: 'Very good', + 2: 'Good', + 3: 'Satisfactory', + 4: 'Adequate', + 5: 'Poor', + 6: 'Inadequate', +}; + +// ── Animated counter component ───────────────────────────────────── + +function AnimatedCounter({ value, duration = 1200, decimals = 0 }: { value: number; duration?: number; decimals?: number }) { + const [display, setDisplay] = useState(0); + + useEffect(() => { + let start = 0; + const startTime = Date.now(); + const animate = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + const current = start + (value - start) * eased; + setDisplay(current); + if (progress < 1) requestAnimationFrame(animate); + }; + requestAnimationFrame(animate); + }, [value, duration]); + + return {display.toFixed(decimals)}; +} + +// ── Main Component ───────────────────────────────────────────────── + +export default function GradeAnalyticsView() { + const currentUser = useAppStore((s) => s.currentUser); + const locale = useAppStore((s) => s.locale); + const schoolYearId = useAppStore((s) => s.schoolYearId); + + const role = currentUser?.role ?? ''; + const schoolId = currentUser?.schoolId ?? ''; + const isAdmin = role === 'SUPER_ADMIN' || role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL'; + const isTeacher = role === 'TEACHER'; + const isStudent = role === 'STUDENT'; + const isParent = role === 'PARENT'; + + // State + const [activeTab, setActiveTab] = useState('distribution'); + const [loading, setLoading] = useState(true); + const [overview, setOverview] = useState(null); + const [distribution, setDistribution] = useState(null); + const [subjectDistribution, setSubjectDistribution] = useState<{ subjects: SubjectDistribution[]; gradeColors: Record } | null>(null); + const [classDistribution, setClassDistribution] = useState<{ classes: SubjectDistribution[]; gradeColors: Record } | null>(null); + const [trends, setTrends] = useState<{ trendData: TrendPoint[]; studentTrajectories: StudentTrajectory[]; studentImprovements: StudentImprovement[]; percentileData: PercentileData[]; totalAssessments: number } | null>(null); + const [comparison, setComparison] = useState<{ classRanking: ClassRanking[]; subjectComparison: SubjectComparison[] } | null>(null); + const [subjectAnalysis, setSubjectAnalysis] = useState<{ subjectRanking: SubjectRanking[]; correlationMatrix: CorrelationEntry[]; recommendations: Array<{ subjectId: string; subjectName: string; recommendations: string[] }> } | null>(null); + const [teacherComparison, setTeacherComparison] = useState<{ teacherRanking: TeacherRanking[] } | null>(null); + const [reports, setReports] = useState([]); + + // Filters + const [selectedClassId, setSelectedClassId] = useState('all'); + const [selectedSubjectId, setSelectedSubjectId] = useState('all'); + const [distributionGroupBy, setDistributionGroupBy] = useState('overall'); + + // Report builder + const [reportDialogOpen, setReportDialogOpen] = useState(false); + const [reportTitle, setReportTitle] = useState(''); + const [reportType, setReportType] = useState('quarterly'); + const [reportDateFrom, setReportDateFrom] = useState(''); + const [reportDateTo, setReportDateTo] = useState(''); + const [reportMetrics, setReportMetrics] = useState(['distribution', 'averages', 'classComparison', 'subjectAnalysis']); + const [generatingReport, setGeneratingReport] = useState(false); + + // Distribution group by + const [distView, setDistView] = useState<'overall' | 'subject' | 'class'>('overall'); + + // Fetch overview data + const fetchOverview = useCallback(async () => { + try { + setLoading(true); + const params = new URLSearchParams(); + if (schoolId) params.set('schoolId', schoolId); + if (selectedClassId !== 'all') params.set('classGroupId', selectedClassId); + if (selectedSubjectId !== 'all') params.set('subjectId', selectedSubjectId); + if (schoolYearId) params.set('schoolYearId', schoolYearId); + + const res = await fetch(`/api/grade-analytics?${params.toString()}`); + if (res.ok) { + const data = await res.json(); + setOverview(data); + } + } catch (err) { + console.error('Failed to fetch overview:', err); + } finally { + setLoading(false); + } + }, [schoolId, selectedClassId, selectedSubjectId, schoolYearId]); + + // Fetch distribution data + const fetchDistribution = useCallback(async (groupBy: string) => { + try { + const params = new URLSearchParams(); + if (schoolId) params.set('schoolId', schoolId); + if (selectedClassId !== 'all') params.set('classGroupId', selectedClassId); + if (selectedSubjectId !== 'all') params.set('subjectId', selectedSubjectId); + if (schoolYearId) params.set('schoolYearId', schoolYearId); + params.set('groupBy', groupBy); + + const res = await fetch(`/api/grade-analytics/distribution?${params.toString()}`); + if (res.ok) { + const data = await res.json(); + if (groupBy === 'overall') setDistribution(data); + else if (groupBy === 'subject') setSubjectDistribution(data); + else if (groupBy === 'class') setClassDistribution(data); + } + } catch (err) { + console.error('Failed to fetch distribution:', err); + } + }, [schoolId, selectedClassId, selectedSubjectId, schoolYearId]); + + // Fetch trends data + const fetchTrends = useCallback(async () => { + try { + const params = new URLSearchParams(); + if (schoolId) params.set('schoolId', schoolId); + if (selectedClassId !== 'all') params.set('classGroupId', selectedClassId); + if (selectedSubjectId !== 'all') params.set('subjectId', selectedSubjectId); + if (schoolYearId) params.set('schoolYearId', schoolYearId); + + const res = await fetch(`/api/grade-analytics/trends?${params.toString()}`); + if (res.ok) { + const data = await res.json(); + setTrends(data); + } + } catch (err) { + console.error('Failed to fetch trends:', err); + } + }, [schoolId, selectedClassId, selectedSubjectId, schoolYearId]); + + // Fetch comparison data + const fetchComparison = useCallback(async (type: string) => { + try { + const params = new URLSearchParams(); + if (schoolId) params.set('schoolId', schoolId); + if (schoolYearId) params.set('schoolYearId', schoolYearId); + params.set('compareType', type); + + const res = await fetch(`/api/grade-analytics/comparison?${params.toString()}`); + if (res.ok) { + const data = await res.json(); + if (type === 'class') setComparison(data); + else if (type === 'subject') setSubjectAnalysis(data); + else if (type === 'teacher') setTeacherComparison(data); + } + } catch (err) { + console.error('Failed to fetch comparison:', err); + } + }, [schoolId, schoolYearId]); + + // Fetch reports + const fetchReports = useCallback(async () => { + try { + const params = new URLSearchParams(); + if (schoolId) params.set('schoolId', schoolId); + + const res = await fetch(`/api/grade-analytics/reports?${params.toString()}`); + if (res.ok) { + const data = await res.json(); + setReports(data.reports || []); + } + } catch (err) { + console.error('Failed to fetch reports:', err); + } + }, [schoolId]); + + // Initial data load + useEffect(() => { + fetchOverview(); + fetchDistribution('overall'); + fetchTrends(); + fetchComparison('class'); + fetchComparison('subject'); + fetchReports(); + if (isAdmin) { + fetchComparison('teacher'); + } + }, [fetchOverview, fetchDistribution, fetchTrends, fetchComparison, fetchReports, isAdmin]); + + // Fetch distribution when view changes + useEffect(() => { + if (activeTab === 'distribution') { + fetchDistribution(distView); + } + }, [activeTab, distView, fetchDistribution]); + + // Generate report + const handleGenerateReport = async () => { + try { + setGeneratingReport(true); + const res = await fetch('/api/grade-analytics/reports', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: reportTitle || `${reportType} Report`, + type: reportType, + dateRange: JSON.stringify({ from: reportDateFrom || null, to: reportDateTo || null }), + classIds: selectedClassId !== 'all' ? JSON.stringify([selectedClassId]) : null, + subjectIds: selectedSubjectId !== 'all' ? JSON.stringify([selectedSubjectId]) : null, + metrics: JSON.stringify(reportMetrics), + schoolId, + }), + }); + if (res.ok) { + await fetchReports(); + setReportDialogOpen(false); + setReportTitle(''); + } + } catch (err) { + console.error('Failed to generate report:', err); + } finally { + setGeneratingReport(false); + } + }; + + // Export CSV + const handleExportCSV = useCallback(() => { + if (!overview) return; + const rows: string[] = []; + rows.push(['Grade', 'Count', 'Percentage'].join(',')); + Object.entries(overview.distribution).forEach(([grade, count]) => { + const pct = overview.totalGrades > 0 ? Math.round((count / overview.totalGrades) * 10000) / 100 : 0; + rows.push([grade, count, `${pct}%`].join(',')); + }); + rows.push(''); + rows.push(['Class', 'Average Grade', 'Grade Count'].join(',')); + overview.classAverages.forEach((c) => { + rows.push([c.name, c.average, c.gradeCount].join(',')); + }); + rows.push(''); + rows.push(['Subject', 'Average Grade', 'Grade Count'].join(',')); + overview.subjectDifficulty.forEach((s) => { + rows.push([s.name, s.average, s.gradeCount].join(',')); + }); + + const blob = new Blob([rows.join('\n')], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `grade-analytics-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + }, [overview]); + + // Grade labels based on locale + const gradeLabels = locale === 'de' ? GRADE_LABELS_DE : GRADE_LABELS_EN; + + // Distribution chart data + const distributionChartData = useMemo(() => { + if (!distribution) return []; + return distribution.distribution.map((d) => ({ + name: `${d.grade} - ${gradeLabels[d.grade as keyof typeof gradeLabels]}`, + count: d.count, + percentage: d.percentage, + fill: d.color, + })); + }, [distribution, gradeLabels]); + + // Subject distribution chart data + const subjectDistChartData = useMemo(() => { + if (!subjectDistribution) return []; + const subjects = subjectDistribution.subjects.slice(0, 8); + return subjects.map((s) => ({ + name: s.name, + grade1: s.distribution[1] || 0, + grade2: s.distribution[2] || 0, + grade3: s.distribution[3] || 0, + grade4: s.distribution[4] || 0, + grade5: s.distribution[5] || 0, + grade6: s.distribution[6] || 0, + })); + }, [subjectDistribution]); + + // Class distribution chart data + const classDistChartData = useMemo(() => { + if (!classDistribution) return []; + const classes = classDistribution.classes.slice(0, 8); + return classes.map((c) => ({ + name: c.name, + grade1: c.distribution[1] || 0, + grade2: c.distribution[2] || 0, + grade3: c.distribution[3] || 0, + grade4: c.distribution[4] || 0, + grade5: c.distribution[5] || 0, + grade6: c.distribution[6] || 0, + })); + }, [classDistribution]); + + // Class comparison chart data + const classComparisonData = useMemo(() => { + if (!comparison) return []; + return comparison.classRanking.map((c) => ({ + name: c.name, + average: c.average, + median: c.median, + stdDev: c.stdDev, + gradeCount: c.gradeCount, + })); + }, [comparison]); + + // Subject comparison radar data + const subjectRadarData = useMemo(() => { + if (!overview) return []; + return overview.subjectDifficulty.map((s) => ({ + subject: s.name.length > 12 ? s.name.substring(0, 12) + '...' : s.name, + average: Math.max(0, 7 - s.average), // Invert: higher = better performance + fullMark: 6, + })); + }, [overview]); + + // Trend chart data + const trendChartData = useMemo(() => { + if (!trends) return []; + return trends.trendData.map((t) => ({ + date: t.date, + average: t.average, + count: t.count, + })); + }, [trends]); + + // Loading state + if (loading && !overview) { + return ( +
+
+ +

{t('grade_analytics.loading')}

+
+
+ ); + } + + return ( +
+ {/* Header Banner */} + +
+
+
+ +

{t('grade_analytics.title')}

+
+

+ {t('grade_analytics.subtitle')} +

+
+ {overview && ( + <> + +
{t('grade_analytics.total_grades')}
+
+
+ +
{t('grade_analytics.average_grade')}
+
+
+ +
{t('grade_analytics.finalized')}
+
+
+ +
{t('grade_analytics.risk_students')}
+
+
+ + )} +
+
+ + + {/* Filters */} + + + +
+
+ + {t('grade_analytics.filters')} +
+ {overview && ( + <> + + + + )} + +
+ + {(isAdmin || isTeacher) && ( + + + + + + + {t('grade_analytics.report_builder')} + +
+
+ + setReportTitle(e.target.value)} + placeholder={t('grade_analytics.report_title_placeholder')} + /> +
+
+ + +
+
+
+ + setReportDateFrom(e.target.value)} /> +
+
+ + setReportDateTo(e.target.value)} /> +
+
+
+ +
+ {[ + { key: 'distribution', label: t('grade_analytics.metric_distribution') }, + { key: 'averages', label: t('grade_analytics.metric_averages') }, + { key: 'classComparison', label: t('grade_analytics.metric_class_comparison') }, + { key: 'subjectAnalysis', label: t('grade_analytics.metric_subject_analysis') }, + { key: 'studentGrades', label: t('grade_analytics.metric_student_grades') }, + { key: 'trends', label: t('grade_analytics.metric_trends') }, + ].map((metric) => ( + + ))} +
+
+
+ + + + + + +
+
+ )} +
+
+
+
+
+ + {/* Main Tabs */} + + + + + {t('grade_analytics.distribution')} + + + + {t('grade_analytics.performance')} + + + + {t('grade_analytics.class_comparison')} + + + + {t('grade_analytics.subject_analysis')} + + + + {t('grade_analytics.trends')} + + + + {t('grade_analytics.reports')} + + + + {/* ── Distribution Tab ─────────────────────────────────────────── */} + +
+ {(['overall', 'subject', 'class'] as const).map((view) => ( + + ))} +
+ + {/* Overall Distribution */} + {distView === 'overall' && distribution && ( +
+ + + + + + {t('grade_analytics.grade_distribution')} + + + {t('grade_analytics.distribution_desc')} (1-6 {t('grade_analytics.scale')}) + + + + + + + + + { + if (name === 'count') return [value, t('grade_analytics.count')]; + return [value, name]; + }} + /> + + {distributionChartData.map((entry, index) => ( + + ))} + + + + + + + + + + + + + {t('grade_analytics.bell_curve')} + + + {t('grade_analytics.mean')}: {distribution.mean} | {t('grade_analytics.std_dev')}: {distribution.stdDev} + + + + + + + + + + + + + + {/* Grade breakdown cards */} +
+ {distribution.distribution.map((d) => ( + +
{d.grade}
+
{d.count}
+
{d.percentage}%
+
+ ))} +
+
+
+
+
+ )} + + {/* Subject Distribution */} + {distView === 'subject' && subjectDistribution && ( + + + + + + {t('grade_analytics.distribution_by_subject')} + + {t('grade_analytics.distribution_by_subject_desc')} + + + + + + + + + + + + + + + + + + + + + )} + + {/* Class Distribution */} + {distView === 'class' && classDistribution && ( + + + + + + {t('grade_analytics.distribution_by_class')} + + {t('grade_analytics.distribution_by_class_desc')} + + + + + + + + + + + + + + + + + + + + + )} +
+ + {/* ── Performance Tab ───────────────────────────────────────────── */} + + {overview && ( + <> + {/* Top/Bottom Performers */} +
+ + + + + + {t('grade_analytics.top_performers')} + + {t('grade_analytics.top_performers_desc')} + + + +
+ {overview.topPerformers.map((student, idx) => ( + +
+ {idx + 1} +
+
+
{student.name}
+
{student.gradeCount} {t('grade_analytics.grades')}
+
+ + {student.average.toFixed(2)} + +
+ ))} + {overview.topPerformers.length === 0 && ( +

{t('grade_analytics.no_data')}

+ )} +
+
+
+
+
+ + + + + + + {t('grade_analytics.bottom_performers')} + + {t('grade_analytics.bottom_performers_desc')} + + + +
+ {overview.bottomPerformers.map((student, idx) => ( + +
+ {idx + 1} +
+
+
{student.name}
+
{student.gradeCount} {t('grade_analytics.grades')}
+
+ + {student.average.toFixed(2)} + +
+ ))} + {overview.bottomPerformers.length === 0 && ( +

{t('grade_analytics.no_data')}

+ )} +
+
+
+
+
+
+ + {/* Risk Students */} + + + + + + {t('grade_analytics.risk_students_title')} + + {t('grade_analytics.risk_students_desc')} + + + {overview.riskStudents.length > 0 ? ( + +
+ {overview.riskStudents.map((student, idx) => ( + + +
+
{student.name}
+
{student.gradeCount} {t('grade_analytics.grades')}
+
+
+ + {student.average.toFixed(2)} + +
+ {t('grade_analytics.at_risk')} +
+
+
+ ))} +
+
+ ) : ( +
+ +

{t('grade_analytics.no_risk_students')}

+
+ )} +
+
+
+ + {/* Student Improvement/Regression */} + {trends && trends.studentImprovements.length > 0 && ( + + + + + + {t('grade_analytics.improvement_detection')} + + {t('grade_analytics.improvement_detection_desc')} + + + +
+ {trends.studentImprovements + .filter((s) => s.trend !== 'stable') + .slice(0, 20) + .map((student, idx) => ( + + {student.trend === 'improving' ? ( + + ) : ( + + )} +
+
{student.name}
+
+ {t('grade_analytics.first_grade')}: {student.firstGrade.toFixed(1)} → {t('grade_analytics.last_grade')}: {student.lastGrade.toFixed(1)} +
+
+ + {student.trend === 'improving' ? t('grade_analytics.improving') : t('grade_analytics.regressing')} + {' '} + ({student.change > 0 ? '+' : ''}{student.change.toFixed(2)}) + +
+ ))} +
+
+
+
+
+ )} + + {/* Percentile Data */} + {trends && trends.percentileData.length > 0 && ( + + + + + + {t('grade_analytics.percentile_ranking')} + + {t('grade_analytics.percentile_ranking_desc')} + + + +
+ {trends.percentileData.slice(0, 30).map((student, idx) => ( +
+
+ {idx + 1} +
+
+
{student.name}
+
+
{student.average.toFixed(2)}
+
+ +
+
+ {t('grade_analytics.percentile')} {student.percentile} +
+
+ ))} +
+
+
+
+
+ )} + + )} +
+ + {/* ── Class Comparison Tab ──────────────────────────────────────── */} + + {comparison && ( + <> + {/* Class Average Comparison */} + + + + + + {t('grade_analytics.class_average_comparison')} + + {t('grade_analytics.class_average_comparison_desc')} + + + + + + + + + + + + + + + + + + {/* Class Ranking Table */} + + + + + + {t('grade_analytics.class_ranking')} + + {t('grade_analytics.class_ranking_desc')} + + + +
+ {comparison.classRanking.map((cls, idx) => ( + +
+ #{cls.rank} +
+
+
{cls.name}
+
+ {t('grade_analytics.anonymized')}: {cls.anonymizedName} | {t('grade_analytics.grade_level')}: {cls.gradeLevel} +
+
+
+
+
{t('grade_analytics.average')}
+
{cls.average.toFixed(2)}
+
+
+
{t('grade_analytics.median')}
+
{cls.median.toFixed(2)}
+
+
+
{t('grade_analytics.std_dev')}
+
{cls.stdDev.toFixed(2)}
+
+
+
{t('grade_analytics.count')}
+
{cls.gradeCount}
+
+
+ + {cls.average <= 2 ? t('grade_analytics.excellent') : + cls.average <= 3 ? t('grade_analytics.good') : + cls.average <= 4 ? t('grade_analytics.satisfactory') : + t('grade_analytics.needs_improvement')} + +
+ ))} +
+
+
+
+
+ + {/* Subject Comparison Across Classes */} + {comparison.subjectComparison.length > 0 && ( + + + + + + {t('grade_analytics.subject_comparison_across_classes')} + + {t('grade_analytics.subject_comparison_across_classes_desc')} + + + + { + const entry: Record = { name: sc.subjectName }; + sc.classes.forEach((c) => { + entry[c.className] = c.average; + }); + return entry; + })} + margin={{ top: 20, right: 30, left: 0, bottom: 5 }} + > + + + + + + {comparison.subjectComparison[0]?.classes.slice(0, 5).map((cls, idx) => { + const colors = ['#10b981', '#14b8a6', '#f59e0b', '#6366f1', '#ec4899']; + return ( + + ); + })} + + + + + + )} + + {/* Teacher Comparison (Admin Only) */} + {isAdmin && teacherComparison && teacherComparison.teacherRanking.length > 0 && ( + + + + + + {t('grade_analytics.teacher_comparison')} + + {t('grade_analytics.teacher_comparison_desc')} + + + + ({ + name: t.name, + average: t.average, + gradeCount: t.gradeCount, + }))} + margin={{ top: 20, right: 30, left: 0, bottom: 5 }} + > + + + + + + + + + + + )} + + )} +
+ + {/* ── Subject Analysis Tab ──────────────────────────────────────── */} + + {subjectAnalysis && ( + <> + {/* Subject Difficulty Ranking */} + + + + + + {t('grade_analytics.subject_difficulty_ranking')} + + {t('grade_analytics.subject_difficulty_ranking_desc')} + + + + ({ + name: s.name, + average: s.average, + variance: s.variance, + }))} + layout="vertical" + margin={{ top: 20, right: 30, left: 80, bottom: 5 }} + > + + + + + + + + + + + + + + {/* Subject Radar Chart */} + {overview && ( + + + + + + {t('grade_analytics.subject_performance_radar')} + + {t('grade_analytics.subject_performance_radar_desc')} + + + + + + + + + + + + + + )} + + {/* Cross-Subject Correlation */} + {subjectAnalysis.correlationMatrix.length > 0 && ( + + + + + + {t('grade_analytics.cross_subject_correlation')} + + {t('grade_analytics.cross_subject_correlation_desc')} + + + +
+ {subjectAnalysis.correlationMatrix + .filter((c) => c.subject1 !== c.subject2) + .sort((a, b) => Math.abs(b.correlation) - Math.abs(a.correlation)) + .slice(0, 20) + .map((corr, idx) => ( + +
+
+ {corr.name1} ↔ {corr.name2} +
+
+
+
+ +
+ 0.5 + ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200 border-0' + : corr.correlation < -0.5 + ? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 border-0' + : 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200 border-0' + } + > + {corr.correlation.toFixed(2)} + +
+
+ ))} +
+
+
+
+
+ )} + + {/* Subject Recommendations */} + {subjectAnalysis.recommendations.length > 0 && ( + + + + + + {t('grade_analytics.subject_recommendations')} + + {t('grade_analytics.subject_recommendations_desc')} + + + +
+ {subjectAnalysis.recommendations.map((rec, idx) => ( + +

{rec.subjectName}

+
    + {rec.recommendations.map((r, rIdx) => ( +
  • + + {r} +
  • + ))} +
+
+ ))} +
+
+
+
+
+ )} + + )} +
+ + {/* ── Trends Tab ────────────────────────────────────────────────── */} + + {trends && ( + <> + {/* Grade Trend Over Time */} + + + + + + {t('grade_analytics.grade_trends_over_time')} + + {t('grade_analytics.grade_trends_over_time_desc')} + + + {trendChartData.length > 0 ? ( + + + + + + + + + + + + ) : ( +
+ +

{t('grade_analytics.no_trend_data')}

+
+ )} +
+
+
+ + {/* Student Trajectories */} + {trends.studentTrajectories.length > 0 && ( + + + + + + {t('grade_analytics.student_trajectories')} + + {t('grade_analytics.student_trajectories_desc')} + + + + { + // Get all unique dates + const allDates = new Set(); + trends.studentTrajectories.slice(0, 8).forEach((st) => { + st.data.forEach((d) => allDates.add(d.date)); + }); + const sortedDates = Array.from(allDates).sort(); + return sortedDates.map((date) => { + const entry: Record = { date }; + trends.studentTrajectories.slice(0, 8).forEach((st) => { + const point = st.data.find((d) => d.date === date); + if (point) { + entry[st.name] = point.grade; + } + }); + return entry; + }); + })()} + margin={{ top: 20, right: 30, left: 0, bottom: 5 }} + > + + + + + + {trends.studentTrajectories.slice(0, 8).map((st, idx) => { + const colors = ['#10b981', '#14b8a6', '#f59e0b', '#ef4444', '#6366f1', '#ec4899', '#8b5cf6', '#06b6d4']; + return ( + + ); + })} + + + + + + )} + + )} +
+ + {/* ── Reports Tab ───────────────────────────────────────────────── */} + + {/* Report Templates */} + + + + + + {t('grade_analytics.report_templates')} + + {t('grade_analytics.report_templates_desc')} + + +
+ {[ + { type: 'quarterly', icon: Calendar, color: 'emerald' }, + { type: 'semester', icon: Clock, color: 'teal' }, + { type: 'annual', icon: FileText, color: 'amber' }, + { type: 'custom', icon: Settings2, color: 'purple' }, + ].map((template) => ( + { + setReportType(template.type); + setReportDialogOpen(true); + }} + > +
+ + {t(`grade_analytics.${template.type}`)} +
+

+ {t(`grade_analytics.${template.type}_desc`)} +

+
+ ))} +
+
+
+
+ + {/* Existing Reports */} + + + + + + {t('grade_analytics.generated_reports')} + + {t('grade_analytics.generated_reports_desc')} + + + {reports.length > 0 ? ( + +
+ {reports.map((report, idx) => ( + +
+ +
+
+
{report.title}
+
+ {report.generatedBy} • {new Date(report.createdAt).toLocaleDateString()} +
+
+ + {t(`grade_analytics.${report.type}`)} + + + {report.status === 'completed' ? ( + <> {t('grade_analytics.completed')} + ) : report.status === 'pending' ? ( + <> {t('grade_analytics.pending')} + ) : ( + <> {t('grade_analytics.failed')} + )} + + {report.hasFileData && ( + + )} +
+ ))} +
+
+ ) : ( +
+ +

{t('grade_analytics.no_reports')}

+ {(isAdmin || isTeacher) && ( + + )} +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/src/components/peer-assessment-view.tsx b/src/components/peer-assessment-view.tsx new file mode 100644 index 0000000..674c2d7 --- /dev/null +++ b/src/components/peer-assessment-view.tsx @@ -0,0 +1,2151 @@ +'use client'; + +import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import { useTheme } from 'next-themes'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + UsersRound, + Plus, + CheckCircle2, + Clock, + BarChart3, + Eye, + Shield, + Send, + X, + ChevronRight, + Trash2, + Edit3, + Calendar, + Lock, + Unlock, + UserCheck, + AlertTriangle, + Star, + TrendingUp, + Activity, + FileText, + ArrowRight, + RefreshCw, + Search, + Filter, + Users, + MessageSquare, + Sliders, + Award, + Target, + Settings, +} from 'lucide-react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Progress } from '@/components/ui/progress'; +import { Separator } from '@/components/ui/separator'; +import { Slider } from '@/components/ui/slider'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/components/ui/tabs'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useAppStore } from '@/lib/store'; +import { t } from '@/lib/i18n'; +import { + Radar, + RadarChart, + PolarGrid, + PolarAngleAxis, + PolarRadiusAxis, + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Legend, + Tooltip as RechartsTooltip, +} from 'recharts'; + +// ─── Types ──────────────────────────────────────────────────────────── + +interface Criterion { + name: string; + description: string; + maxScore: number; +} + +interface AssignedPair { + assessorId: string; + assessedId: string; +} + +interface StudentInfo { + id: string; + firstName: string; + lastName: string; +} + +interface SessionStats { + total: number; + submitted: number; + pending: number; +} + +interface PeerAssessmentSessionType { + id: string; + schoolId: string; + teacherId: string | null; + classGroupId: string | null; + title: string; + description: string | null; + assessmentType: string; + criteria: string | null; + anonymityMode: string; + status: string; + deadline: string | null; + assignMode: string; + assignedPairs: string | null; + isDemo: boolean; + createdAt: string; + updatedAt: string; + teacher: { id: string; firstName: string; lastName: string } | null; + classGroup: { id: string; name: string } | null; + _count?: { peerAssessments: number }; + stats?: SessionStats; +} + +interface PeerAssessmentType { + id: string; + schoolId: string; + sessionId: string | null; + assessorId: string; + assessedId: string; + assessmentType: string; + criteria: string | null; + level: number | null; + comment: string | null; + isAnonymous: boolean; + status: string; + assessor: StudentInfo; + assessed: StudentInfo; + competency?: { id: string; code: string; title: string } | null; +} + +interface ResultData { + student: StudentInfo; + ratings: number[]; + criteriaScores: Record; + comments: string[]; + averageRating: number; + assessmentCount: number; +} + +interface ResultsPayload { + session: PeerAssessmentSessionType; + studentResults: ResultData[]; + overallStats: { + totalAssessments: number; + overallAverage: number; + ratingStdDev: number; + uniqueAssessors: number; + uniqueAssessed: number; + }; + competencyAverages: Record; + outliers: { + id: string; + assessor: StudentInfo; + assessed: StudentInfo; + level: number | null; + comment: string | null; + }[]; +} + +// ─── Animated Counter ───────────────────────────────────────────────── + +function AnimatedCounter({ value, duration = 1200 }: { value: number; duration?: number }) { + const [display, setDisplay] = useState(0); + useEffect(() => { + let start = 0; + const startTime = Date.now(); + const step = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + setDisplay(Math.round(eased * value * 100) / 100); + if (progress < 1) requestAnimationFrame(step); + }; + requestAnimationFrame(step); + }, [value, duration]); + return {display % 1 === 0 ? display : display.toFixed(1)}; +} + +// ─── Rating Slider ──────────────────────────────────────────────────── + +function RatingSlider({ + value, + onChange, + maxScore = 5, + label, +}: { + value: number; + onChange: (v: number) => void; + maxScore?: number; + label?: string; +}) { + const getColor = (v: number, max: number) => { + const ratio = v / max; + if (ratio >= 0.8) return 'text-emerald-500'; + if (ratio >= 0.6) return 'text-yellow-500'; + if (ratio >= 0.4) return 'text-orange-500'; + return 'text-red-500'; + }; + + const getBgColor = (v: number, max: number) => { + const ratio = v / max; + if (ratio >= 0.8) return 'bg-emerald-500'; + if (ratio >= 0.6) return 'bg-yellow-500'; + if (ratio >= 0.4) return 'bg-orange-500'; + return 'bg-red-500'; + }; + + return ( +
+ {label && ( +
+ + + {value} / {maxScore} + +
+ )} +
+ onChange(v)} + min={0} + max={maxScore} + step={1} + className="flex-1" + /> +
+ {Array.from({ length: maxScore }, (_, i) => ( +
+ ))} +
+
+
+ ); +} + +// ─── Main Component ─────────────────────────────────────────────────── + +export default function PeerAssessmentView() { + const { theme } = useTheme(); + const currentUser = useAppStore((s) => s.currentUser); + const locale = useAppStore((s) => s.locale); + const isDark = theme === 'dark'; + + // ── State ── + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState('dashboard'); + const [selectedSession, setSelectedSession] = useState(null); + const [sessionDetail, setSessionDetail] = useState(null); + const [sessionAssessments, setSessionAssessments] = useState([]); + const [results, setResults] = useState(null); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [showConductDialog, setShowConductDialog] = useState(false); + const [conductingAssessment, setConductingAssessment] = useState(null); + const [showResultsDialog, setShowResultsDialog] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [filterStatus, setFilterStatus] = useState('all'); + const [filterType, setFilterType] = useState('all'); + const [submitting, setSubmitting] = useState(false); + + // Create form state + const [createForm, setCreateForm] = useState({ + title: '', + description: '', + assessmentType: 'competency', + anonymityMode: 'anonymous', + assignMode: 'manual', + classGroupId: '', + deadline: '', + criteria: [{ name: '', description: '', maxScore: 5 }] as Criterion[], + }); + + // Conduct assessment state + const [conductRatings, setConductRatings] = useState>({}); + const [conductComment, setConductComment] = useState(''); + const [conductOverallRating, setConductOverallRating] = useState(3); + + // ── Data Fetching ── + + const fetchSessions = useCallback(async () => { + if (!currentUser?.schoolId) return; + setLoading(true); + try { + const res = await fetch( + `/api/peer-assessment-sessions?schoolId=${currentUser.schoolId}` + ); + if (res.ok) { + const data = await res.json(); + setSessions(data); + } + } catch (err) { + console.error('Failed to fetch sessions:', err); + } finally { + setLoading(false); + } + }, [currentUser?.schoolId]); + + const fetchSessionDetail = useCallback(async (sessionId: string) => { + try { + const res = await fetch(`/api/peer-assessment-sessions/${sessionId}`); + if (res.ok) { + const data = await res.json(); + setSessionDetail(data); + setSessionAssessments(data.peerAssessments || []); + } + } catch (err) { + console.error('Failed to fetch session detail:', err); + } + }, []); + + const fetchResults = useCallback(async (sessionId: string) => { + try { + const res = await fetch(`/api/peer-assessment-sessions/${sessionId}/results`); + if (res.ok) { + const data = await res.json(); + setResults(data); + } + } catch (err) { + console.error('Failed to fetch results:', err); + } + }, []); + + useEffect(() => { + fetchSessions(); + }, [fetchSessions]); + + // ── Computed Stats ── + + const stats = useMemo(() => { + const total = sessions.length; + const active = sessions.filter((s) => s.status === 'active').length; + const completed = sessions.filter((s) => s.status === 'completed').length; + const totalAssessments = sessions.reduce((acc, s) => acc + (s.stats?.total || 0), 0); + const submittedAssessments = sessions.reduce((acc, s) => acc + (s.stats?.submitted || 0), 0); + const completionRate = totalAssessments > 0 + ? Math.round((submittedAssessments / totalAssessments) * 100) + : 0; + return { total, active, completed, totalAssessments, submittedAssessments, completionRate }; + }, [sessions]); + + const filteredSessions = useMemo(() => { + return sessions.filter((s) => { + if (searchQuery && !s.title.toLowerCase().includes(searchQuery.toLowerCase())) return false; + if (filterStatus !== 'all' && s.status !== filterStatus) return false; + if (filterType !== 'all' && s.assessmentType !== filterType) return false; + return true; + }); + }, [sessions, searchQuery, filterStatus, filterType]); + + // ── Handlers ── + + const handleCreateSession = async () => { + if (!currentUser?.schoolId || !createForm.title) return; + try { + const res = await fetch('/api/peer-assessment-sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + schoolId: currentUser.schoolId, + teacherId: currentUser.id, + title: createForm.title, + description: createForm.description, + assessmentType: createForm.assessmentType, + criteria: createForm.criteria.filter((c) => c.name), + anonymityMode: createForm.anonymityMode, + assignMode: createForm.assignMode, + classGroupId: createForm.classGroupId || null, + deadline: createForm.deadline || null, + assignedPairs: [], + }), + }); + if (res.ok) { + setShowCreateDialog(false); + setCreateForm({ + title: '', + description: '', + assessmentType: 'competency', + anonymityMode: 'anonymous', + assignMode: 'manual', + classGroupId: '', + deadline: '', + criteria: [{ name: '', description: '', maxScore: 5 }], + }); + fetchSessions(); + } + } catch (err) { + console.error('Failed to create session:', err); + } + }; + + const handleDeleteSession = async (sessionId: string) => { + try { + const res = await fetch(`/api/peer-assessment-sessions/${sessionId}`, { + method: 'DELETE', + }); + if (res.ok) { + fetchSessions(); + setSelectedSession(null); + setSessionDetail(null); + } + } catch (err) { + console.error('Failed to delete session:', err); + } + }; + + const handleCloseSession = async (sessionId: string) => { + try { + const res = await fetch(`/api/peer-assessment-sessions/${sessionId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'closed' }), + }); + if (res.ok) { + fetchSessions(); + if (selectedSession?.id === sessionId) { + fetchSessionDetail(sessionId); + } + } + } catch (err) { + console.error('Failed to close session:', err); + } + }; + + const handleSubmitAssessment = async () => { + if (!selectedSession || !conductingAssessment) return; + setSubmitting(true); + try { + const criteria = sessionDetail?.criteria + ? JSON.parse(sessionDetail.criteria) + : []; + const updatedCriteria = criteria.map((c: Criterion) => ({ + name: c.name, + score: conductRatings[c.name] || 0, + maxScore: c.maxScore, + })); + + const res = await fetch( + `/api/peer-assessment-sessions/${selectedSession.id}/submit`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + assessmentId: conductingAssessment.id, + criteria: updatedCriteria, + level: conductOverallRating, + comment: conductComment, + }), + } + ); + if (res.ok) { + setShowConductDialog(false); + setConductingAssessment(null); + setConductRatings({}); + setConductComment(''); + setConductOverallRating(3); + fetchSessionDetail(selectedSession.id); + fetchSessions(); + } + } catch (err) { + console.error('Failed to submit assessment:', err); + } finally { + setSubmitting(false); + } + }; + + const handleViewResults = (session: PeerAssessmentSessionType) => { + setSelectedSession(session); + fetchResults(session.id); + setShowResultsDialog(true); + }; + + const handleStartConduct = (assessment: PeerAssessmentType) => { + setConductingAssessment(assessment); + setConductRatings({}); + setConductComment(''); + setConductOverallRating(3); + // Initialize criteria ratings from session + if (sessionDetail?.criteria) { + try { + const criteria = JSON.parse(sessionDetail.criteria); + const ratings: Record = {}; + criteria.forEach((c: Criterion) => { + ratings[c.name] = 0; + }); + setConductRatings(ratings); + } catch { + // ignore + } + } + setShowConductDialog(true); + }; + + const addCriterion = () => { + setCreateForm((prev) => ({ + ...prev, + criteria: [...prev.criteria, { name: '', description: '', maxScore: 5 }], + })); + }; + + const removeCriterion = (index: number) => { + setCreateForm((prev) => ({ + ...prev, + criteria: prev.criteria.filter((_, i) => i !== index), + })); + }; + + const updateCriterion = (index: number, field: keyof Criterion, value: string | number) => { + setCreateForm((prev) => ({ + ...prev, + criteria: prev.criteria.map((c, i) => + i === index ? { ...c, [field]: value } : c + ), + })); + }; + + const getAnonymityIcon = (mode: string) => { + switch (mode) { + case 'anonymous': return ; + case 'semi-anonymous': return ; + case 'open': return ; + default: return ; + } + }; + + const getAnonymityLabel = (mode: string) => { + switch (mode) { + case 'anonymous': return t('pa.anonymous'); + case 'semi-anonymous': return t('pa.semi_anonymous'); + case 'open': return t('pa.open'); + default: return mode; + } + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'active': + return {t('pa.status_active')}; + case 'completed': + return {t('pa.status_completed')}; + case 'closed': + return {t('pa.status_closed')}; + case 'pending': + return {t('pa.status_pending')}; + case 'submitted': + return {t('pa.status_submitted')}; + default: + return {status}; + } + }; + + const getTypeIcon = (type: string) => { + switch (type) { + case 'competency': return ; + case 'project': return ; + case 'presentation': return ; + case 'teamwork': return ; + default: return ; + } + }; + + const getTypeLabel = (type: string) => { + switch (type) { + case 'competency': return t('pa.type_competency'); + case 'project': return t('pa.type_project'); + case 'presentation': return t('pa.type_presentation'); + case 'teamwork': return t('pa.type_teamwork'); + default: return type; + } + }; + + const isTeacherOrAdmin = currentUser?.role === 'TEACHER' || + currentUser?.role === 'SCHOOL_ADMIN' || + currentUser?.role === 'SUPER_ADMIN' || + currentUser?.role === 'VICE_PRINCIPAL'; + + const isStudent = currentUser?.role === 'STUDENT'; + const isParent = currentUser?.role === 'PARENT'; + + // ── Radar Chart Data ── + + const radarData = useMemo(() => { + if (!results) return []; + return Object.entries(results.competencyAverages).map(([name, avg]) => ({ + criterion: name, + average: avg, + fullMark: 5, + })); + }, [results]); + + // ── Bar Chart Data (self vs peer comparison) ── + + const barData = useMemo(() => { + if (!results) return []; + return results.studentResults.slice(0, 10).map((sr) => ({ + name: `${sr.student.firstName} ${sr.student.lastName.charAt(0)}.`, + peerAvg: sr.averageRating, + selfAvg: sr.ratings.length > 0 ? sr.ratings[0] : 0, + })); + }, [results]); + + // ── Activity Timeline ── + + const recentActivity = useMemo(() => { + return sessions + .slice(0, 5) + .map((s) => ({ + id: s.id, + title: s.title, + type: s.status === 'active' ? 'created' : s.status === 'completed' ? 'completed' : 'closed', + date: new Date(s.createdAt).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US'), + time: new Date(s.createdAt).toLocaleTimeString(locale === 'de' ? 'de-DE' : 'en-US', { + hour: '2-digit', + minute: '2-digit', + }), + })); + }, [sessions, locale]); + + // ─── Render ────────────────────────────────────────────────────────── + + return ( +
+ {/* Gradient Header Banner */} + +
+
+
+ + + +
+

{t('pa.title')}

+

{t('pa.subtitle')}

+
+
+
+ + + {/* Main Tabs */} + +
+ + + + {t('pa.tab_dashboard')} + + + + {t('pa.tab_sessions')} + + {isTeacherOrAdmin && ( + + + {t('pa.tab_create')} + + )} + {(isStudent || isTeacherOrAdmin) && ( + + + {t('pa.tab_conduct')} + + )} + + + {t('pa.tab_results')} + + + + {isTeacherOrAdmin && activeTab === 'sessions' && ( + + )} +
+ + {/* ── Dashboard Tab ── */} + + {/* Quick Stats Cards */} +
+ {[ + { + label: t('pa.stat_total_sessions'), + value: stats.total, + icon: FileText, + color: 'from-emerald-500 to-teal-500', + accent: 'bg-emerald-50 dark:bg-emerald-950/30', + }, + { + label: t('pa.stat_active'), + value: stats.active, + icon: Activity, + color: 'from-blue-500 to-cyan-500', + accent: 'bg-blue-50 dark:bg-blue-950/30', + }, + { + label: t('pa.stat_completed'), + value: stats.completed, + icon: CheckCircle2, + color: 'from-green-500 to-emerald-500', + accent: 'bg-green-50 dark:bg-green-950/30', + }, + { + label: t('pa.stat_completion_rate'), + value: stats.completionRate, + icon: Target, + color: 'from-purple-500 to-pink-500', + accent: 'bg-purple-50 dark:bg-purple-950/30', + suffix: '%', + }, + ].map((stat, i) => ( + + +
+ +
+
+

{stat.label}

+

+ + {stat.suffix || ''} +

+
+
+ +
+
+
+ + + ))} +
+ + {/* Assessment Overview & Recent Activity */} +
+ {/* Sessions Overview */} + + + + + {t('pa.sessions_overview')} + + + + {sessions.length === 0 ? ( +
+ +

{t('pa.no_sessions')}

+ {isTeacherOrAdmin && ( + + )} +
+ ) : ( +
+ {sessions.slice(0, 8).map((session, i) => ( + { + setSelectedSession(session); + fetchSessionDetail(session.id); + setActiveTab('conduct'); + }} + > +
+ {getTypeIcon(session.assessmentType)} +
+
+

{session.title}

+
+ + {getTypeLabel(session.assessmentType)} + + {getAnonymityIcon(session.anonymityMode)} +
+
+
+ {getStatusBadge(session.status)} + {session.stats && ( + + {session.stats.submitted}/{session.stats.total} + + )} +
+ +
+ ))} +
+ )} +
+
+ + {/* Recent Activity */} + + + + + {t('pa.recent_activity')} + + + + {recentActivity.length === 0 ? ( +

{t('pa.no_activity')}

+ ) : ( +
+ {recentActivity.map((activity, i) => ( + +
+ {activity.type === 'created' ? ( + + ) : activity.type === 'completed' ? ( + + ) : ( + + )} +
+
+

{activity.title}

+

{activity.date} {activity.time}

+
+
+ ))} +
+ )} +
+
+
+ + {/* Average Ratings by Competency */} + {sessions.length > 0 && ( + + + + + {t('pa.avg_ratings_by_type')} + + + +
+ {['competency', 'project', 'presentation', 'teamwork'].map((type) => { + const typeSessions = sessions.filter((s) => s.assessmentType === type); + const count = typeSessions.length; + const totalSubmitted = typeSessions.reduce((a, s) => a + (s.stats?.submitted || 0), 0); + return ( + +
+ {getTypeIcon(type)} +
+

{getTypeLabel(type)}

+

+ {count} +

+

+ {totalSubmitted} {t('pa.submitted')} +

+
+ ); + })} +
+
+
+ )} + + + {/* ── Sessions Tab ── */} + + {/* Search & Filters */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ + + +
+ + {/* Session List */} + {loading ? ( +
+ {[1, 2, 3, 4].map((i) => ( + + +
+
+
+ + + ))} +
+ ) : filteredSessions.length === 0 ? ( + + + +

{t('pa.no_sessions_found')}

+
+
+ ) : ( +
+ + {filteredSessions.map((session, i) => ( + + + +
+
+
+ {getTypeIcon(session.assessmentType)} +
+
+

{session.title}

+ {session.description && ( +

+ {session.description} +

+ )} +
+ {getStatusBadge(session.status)} + + {getTypeIcon(session.assessmentType)} + {getTypeLabel(session.assessmentType)} + + + {getAnonymityIcon(session.anonymityMode)} + {getAnonymityLabel(session.anonymityMode)} + +
+
+
+
+ {isTeacherOrAdmin && session.status === 'active' && ( + + + + + + {t('pa.close_session')} + + + )} + {isTeacherOrAdmin && ( + + + + + + {t('pa.delete_session')} + + + )} +
+
+ + {/* Progress */} + {session.stats && session.stats.total > 0 && ( +
+
+ {t('pa.progress')} + + {session.stats.submitted}/{session.stats.total} ({Math.round((session.stats.submitted / session.stats.total) * 100)}%) + +
+ +
+ )} + + {/* Footer info */} +
+
+ + {new Date(session.createdAt).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')} +
+ {session.deadline && ( +
+ + {new Date(session.deadline).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')} +
+ )} + {session.classGroup && ( + {session.classGroup.name} + )} +
+ + {/* Action buttons */} +
+ + +
+
+
+
+ ))} +
+
+ )} + + + {/* ── Create Tab ── */} + {isTeacherOrAdmin && ( + + + + + + {t('pa.create_session')} + + {t('pa.create_session_desc')} + + + {/* Basic Info */} +
+
+
+ + setCreateForm((p) => ({ ...p, title: e.target.value }))} + placeholder={t('pa.session_title_placeholder')} + /> +
+
+ + +
+
+ +
+ +