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
This commit is contained in:
1 parent
3d0d518bb9
commit
7f0810c88f
28 files changed
+10204
-67
No files matched your search
+115
-9
@@ -69,9 +69,11 @@ model School {
|
|||||||
emailTemplates EmailTemplate[]
|
emailTemplates EmailTemplate[]
|
||||||
emailLogs EmailLog[]
|
emailLogs EmailLog[]
|
||||||
peerAssessments PeerAssessment[]
|
peerAssessments PeerAssessment[]
|
||||||
|
peerAssessmentSessions PeerAssessmentSession[]
|
||||||
emergencyContacts EmergencyContact[]
|
emergencyContacts EmergencyContact[]
|
||||||
schoolEvents SchoolEvent[]
|
schoolEvents SchoolEvent[]
|
||||||
studentTransports StudentTransport[]
|
studentTransports StudentTransport[]
|
||||||
|
transportRoutes TransportRoute[]
|
||||||
healthRecords HealthRecord[]
|
healthRecords HealthRecord[]
|
||||||
badges Badge[]
|
badges Badge[]
|
||||||
studentBadges StudentBadge[]
|
studentBadges StudentBadge[]
|
||||||
@@ -93,6 +95,7 @@ model School {
|
|||||||
counselingAppointments CounselingAppointment[]
|
counselingAppointments CounselingAppointment[]
|
||||||
disciplinaryCommittees DisciplinaryCommittee[]
|
disciplinaryCommittees DisciplinaryCommittee[]
|
||||||
communicationRooms CommunicationRoom[]
|
communicationRooms CommunicationRoom[]
|
||||||
|
gradeReports GradeReport[]
|
||||||
aiTestGenerations AITestGeneration[]
|
aiTestGenerations AITestGeneration[]
|
||||||
gradingReviews TeacherGradingReview[]
|
gradingReviews TeacherGradingReview[]
|
||||||
gradingAnnotations GradingAnnotation[]
|
gradingAnnotations GradingAnnotation[]
|
||||||
@@ -195,7 +198,9 @@ model User {
|
|||||||
responsibleClasses ClassGroup[] @relation("ResponsibleTeacher")
|
responsibleClasses ClassGroup[] @relation("ResponsibleTeacher")
|
||||||
examPlans ExamPlan[]
|
examPlans ExamPlan[]
|
||||||
seatingCharts SeatingChart[]
|
seatingCharts SeatingChart[]
|
||||||
|
peerAssessmentSessions PeerAssessmentSession[] @relation("PeerAssessmentTeacher")
|
||||||
bookCheckouts BookCheckout[]
|
bookCheckouts BookCheckout[]
|
||||||
|
gradeReports GradeReport[] @relation("GradeReportGenerator")
|
||||||
bookReservations BookReservation[]
|
bookReservations BookReservation[]
|
||||||
dataImportJobs DataImportJob[]
|
dataImportJobs DataImportJob[]
|
||||||
dataExportJobs DataExportJob[]
|
dataExportJobs DataExportJob[]
|
||||||
@@ -256,6 +261,7 @@ model ClassGroup {
|
|||||||
timetableSlots TimetableSlot[]
|
timetableSlots TimetableSlot[]
|
||||||
resources Resource[]
|
resources Resource[]
|
||||||
peerAssessments PeerAssessment[]
|
peerAssessments PeerAssessment[]
|
||||||
|
peerAssessmentSessions PeerAssessmentSession[] @relation("PeerAssessmentClassGroup")
|
||||||
schoolEvents SchoolEvent[]
|
schoolEvents SchoolEvent[]
|
||||||
aiTestGenerations AITestGeneration[]
|
aiTestGenerations AITestGeneration[]
|
||||||
examPlans ExamPlan[]
|
examPlans ExamPlan[]
|
||||||
@@ -1579,24 +1585,57 @@ model EmailLog {
|
|||||||
|
|
||||||
// ─── Peer Assessment ──────────────────────────────────────────────
|
// ─── Peer Assessment ──────────────────────────────────────────────
|
||||||
|
|
||||||
model PeerAssessment {
|
model PeerAssessmentSession {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
schoolId String
|
schoolId String
|
||||||
assessorId String // student who is assessing
|
teacherId String? // teacher who created the session
|
||||||
assessedId String // student being assessed
|
|
||||||
competencyId String?
|
|
||||||
classGroupId String?
|
classGroupId String?
|
||||||
assessmentType String // "competency", "project", "presentation", "teamwork", "behavior"
|
title String
|
||||||
level Int? // 1-6 rating
|
description String?
|
||||||
comment String?
|
assessmentType String @default("competency") // competency, project, presentation, teamwork
|
||||||
rubricId String? // optional rubric reference
|
criteria String? // JSON: [{ name, description, maxScore }]
|
||||||
isAnonymous Boolean @default(false)
|
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)
|
isDemo Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
|
|
||||||
school School @relation(fields: [schoolId], references: [id])
|
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])
|
assessor Student @relation("PeerAssessor", fields: [assessorId], references: [id])
|
||||||
assessed Student @relation("PeerAssessed", fields: [assessedId], references: [id])
|
assessed Student @relation("PeerAssessed", fields: [assessedId], references: [id])
|
||||||
competency Competency? @relation(fields: [competencyId], references: [id])
|
competency Competency? @relation(fields: [competencyId], references: [id])
|
||||||
@@ -1605,6 +1644,7 @@ model PeerAssessment {
|
|||||||
|
|
||||||
@@index([schoolId, assessedId])
|
@@index([schoolId, assessedId])
|
||||||
@@index([schoolId, assessorId])
|
@@index([schoolId, assessorId])
|
||||||
|
@@index([sessionId])
|
||||||
@@map("peer_assessments")
|
@@map("peer_assessments")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1721,6 +1761,7 @@ model StudentTransport {
|
|||||||
driverName String? // for car/bus
|
driverName String? // for car/bus
|
||||||
driverPhone String? // for car/bus
|
driverPhone String? // for car/bus
|
||||||
distanceKm Float? // distance in km
|
distanceKm Float? // distance in km
|
||||||
|
routeId String? // link to TransportRoute
|
||||||
notes String?
|
notes String?
|
||||||
isDemo Boolean @default(false)
|
isDemo Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -1729,11 +1770,53 @@ model StudentTransport {
|
|||||||
|
|
||||||
school School @relation(fields: [schoolId], references: [id])
|
school School @relation(fields: [schoolId], references: [id])
|
||||||
student Student @relation(fields: [studentId], references: [id])
|
student Student @relation(fields: [studentId], references: [id])
|
||||||
|
route TransportRoute? @relation(fields: [routeId], references: [id])
|
||||||
|
|
||||||
@@index([schoolId, studentId])
|
@@index([schoolId, studentId])
|
||||||
@@map("student_transport")
|
@@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 ──────────────────────────────────────────────
|
// ─── Health Records ──────────────────────────────────────────────
|
||||||
|
|
||||||
model HealthRecord {
|
model HealthRecord {
|
||||||
@@ -3110,3 +3193,26 @@ model SubstitutionAssignment {
|
|||||||
@@index([date])
|
@@index([date])
|
||||||
@@map("substitution_assignments")
|
@@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])
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {};
|
||||||
|
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<string, {
|
||||||
|
name: string;
|
||||||
|
gradeLevel: number;
|
||||||
|
grades: number[];
|
||||||
|
average: number;
|
||||||
|
median: number;
|
||||||
|
stdDev: number;
|
||||||
|
gradeCount: number;
|
||||||
|
}> = {};
|
||||||
|
|
||||||
|
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<string, Record<string, { total: number; count: number; name: string }>> = {};
|
||||||
|
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<string, string> = {};
|
||||||
|
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<string, {
|
||||||
|
name: string;
|
||||||
|
grades: number[];
|
||||||
|
average: number;
|
||||||
|
variance: number;
|
||||||
|
gradeCount: number;
|
||||||
|
}> = {};
|
||||||
|
|
||||||
|
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<string, Record<string, number[]>> = {};
|
||||||
|
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<string, {
|
||||||
|
name: string;
|
||||||
|
classIds: string[];
|
||||||
|
grades: number[];
|
||||||
|
average: number;
|
||||||
|
classCount: number;
|
||||||
|
gradeCount: number;
|
||||||
|
}> = {};
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {};
|
||||||
|
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<number, string> = {
|
||||||
|
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<number, { count: number; percentage: number; color: string }> = {};
|
||||||
|
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<string, { name: string; distribution: Record<number, number> }> = {};
|
||||||
|
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<string, { name: string; distribution: Record<number, number> }> = {};
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {};
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<number, number> = { 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<string, { name: string; average: number; count: number }> = {};
|
||||||
|
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<string, { name: string; average: number; count: number }> = {};
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {};
|
||||||
|
if (classGroupId) gradeWhere.classGroupId = classGroupId;
|
||||||
|
if (subjectId) gradeWhere.subjectId = subjectId;
|
||||||
|
if (schoolYearId) gradeWhere.schoolYearId = schoolYearId;
|
||||||
|
|
||||||
|
// Scope by school
|
||||||
|
if (schoolId) {
|
||||||
|
const classWhere: Record<string, unknown> = { 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<number, number> = { 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<string, { total: number; count: number; name: string }> = {};
|
||||||
|
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<string, { total: number; count: number; name: string }> = {};
|
||||||
|
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<string, { total: number; count: number; name: string }> = {};
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {};
|
||||||
|
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<string, { date: string; average: number; count: number; grade1: number; grade2: number; grade3: number; grade4: number; grade5: number; grade6: number }> = {};
|
||||||
|
|
||||||
|
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<string, { name: string; data: Array<{ date: string; grade: number }> }> = {};
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, {
|
||||||
|
student: { id: string; firstName: string; lastName: string };
|
||||||
|
ratings: number[];
|
||||||
|
criteriaScores: Record<string, { total: number; count: number; scores: number[] }>;
|
||||||
|
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<string, number> = {};
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ export async function GET(
|
|||||||
rubric: {
|
rubric: {
|
||||||
select: { id: true, title: true },
|
select: { id: true, title: true },
|
||||||
},
|
},
|
||||||
|
session: {
|
||||||
|
select: { id: true, title: true, anonymityMode: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,6 +84,8 @@ export async function PUT(
|
|||||||
classGroupId,
|
classGroupId,
|
||||||
rubricId,
|
rubricId,
|
||||||
isAnonymous,
|
isAnonymous,
|
||||||
|
criteria,
|
||||||
|
status,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (level !== undefined && level !== null && (level < 1 || level > 6)) {
|
if (level !== undefined && level !== null && (level < 1 || level > 6)) {
|
||||||
@@ -97,6 +102,8 @@ export async function PUT(
|
|||||||
...(classGroupId !== undefined && { classGroupId: classGroupId || null }),
|
...(classGroupId !== undefined && { classGroupId: classGroupId || null }),
|
||||||
...(rubricId !== undefined && { rubricId: rubricId || null }),
|
...(rubricId !== undefined && { rubricId: rubricId || null }),
|
||||||
...(isAnonymous !== undefined && { isAnonymous }),
|
...(isAnonymous !== undefined && { isAnonymous }),
|
||||||
|
...(criteria !== undefined && { criteria: JSON.stringify(criteria) }),
|
||||||
|
...(status !== undefined && { status }),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
assessor: {
|
assessor: {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export async function GET(request: Request) {
|
|||||||
const competencyId = searchParams.get('competencyId');
|
const competencyId = searchParams.get('competencyId');
|
||||||
const classGroupId = searchParams.get('classGroupId');
|
const classGroupId = searchParams.get('classGroupId');
|
||||||
const assessmentType = searchParams.get('assessmentType');
|
const assessmentType = searchParams.get('assessmentType');
|
||||||
|
const sessionId = searchParams.get('sessionId');
|
||||||
|
const status = searchParams.get('status');
|
||||||
|
|
||||||
if (!schoolId) {
|
if (!schoolId) {
|
||||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
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 (competencyId) where.competencyId = competencyId;
|
||||||
if (classGroupId) where.classGroupId = classGroupId;
|
if (classGroupId) where.classGroupId = classGroupId;
|
||||||
if (assessmentType) where.assessmentType = assessmentType;
|
if (assessmentType) where.assessmentType = assessmentType;
|
||||||
|
if (sessionId) where.sessionId = sessionId;
|
||||||
|
if (status) where.status = status;
|
||||||
|
|
||||||
const assessments = await db.peerAssessment.findMany({
|
const assessments = await db.peerAssessment.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -55,6 +59,9 @@ export async function GET(request: Request) {
|
|||||||
rubric: {
|
rubric: {
|
||||||
select: { id: true, title: true },
|
select: { id: true, title: true },
|
||||||
},
|
},
|
||||||
|
session: {
|
||||||
|
select: { id: true, title: true, anonymityMode: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,10 +87,12 @@ export async function POST(request: Request) {
|
|||||||
competencyId,
|
competencyId,
|
||||||
classGroupId,
|
classGroupId,
|
||||||
assessmentType,
|
assessmentType,
|
||||||
|
criteria,
|
||||||
level,
|
level,
|
||||||
comment,
|
comment,
|
||||||
rubricId,
|
rubricId,
|
||||||
isAnonymous,
|
isAnonymous,
|
||||||
|
sessionId,
|
||||||
isDemo,
|
isDemo,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
@@ -113,10 +122,13 @@ export async function POST(request: Request) {
|
|||||||
competencyId: competencyId || null,
|
competencyId: competencyId || null,
|
||||||
classGroupId: classGroupId || null,
|
classGroupId: classGroupId || null,
|
||||||
assessmentType,
|
assessmentType,
|
||||||
|
criteria: criteria ? JSON.stringify(criteria) : null,
|
||||||
level: level ?? null,
|
level: level ?? null,
|
||||||
comment: comment || null,
|
comment: comment || null,
|
||||||
rubricId: rubricId || null,
|
rubricId: rubricId || null,
|
||||||
isAnonymous: isAnonymous ?? false,
|
isAnonymous: isAnonymous ?? false,
|
||||||
|
sessionId: sessionId || null,
|
||||||
|
status: 'pending',
|
||||||
isDemo: isDemo ?? false,
|
isDemo: isDemo ?? false,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export async function GET(
|
|||||||
student: {
|
student: {
|
||||||
select: { id: true, firstName: true, lastName: true },
|
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,
|
driverName,
|
||||||
driverPhone,
|
driverPhone,
|
||||||
distanceKm,
|
distanceKm,
|
||||||
|
routeId,
|
||||||
notes,
|
notes,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
@@ -84,12 +88,16 @@ export async function PUT(
|
|||||||
...(driverName !== undefined && { driverName: driverName || null }),
|
...(driverName !== undefined && { driverName: driverName || null }),
|
||||||
...(driverPhone !== undefined && { driverPhone: driverPhone || null }),
|
...(driverPhone !== undefined && { driverPhone: driverPhone || null }),
|
||||||
...(distanceKm !== undefined && { distanceKm: distanceKm != null ? distanceKm : null }),
|
...(distanceKm !== undefined && { distanceKm: distanceKm != null ? distanceKm : null }),
|
||||||
|
...(routeId !== undefined && { routeId: routeId || null }),
|
||||||
...(notes !== undefined && { notes }),
|
...(notes !== undefined && { notes }),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
student: {
|
student: {
|
||||||
select: { id: true, firstName: true, lastName: true },
|
select: { id: true, firstName: true, lastName: true },
|
||||||
},
|
},
|
||||||
|
route: {
|
||||||
|
select: { id: true, routeNumber: true, routeName: true, transportType: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,9 @@ export async function GET(request: Request) {
|
|||||||
student: {
|
student: {
|
||||||
select: { id: true, firstName: true, lastName: true },
|
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,
|
driverName,
|
||||||
driverPhone,
|
driverPhone,
|
||||||
distanceKm,
|
distanceKm,
|
||||||
|
routeId,
|
||||||
notes,
|
notes,
|
||||||
isDemo,
|
isDemo,
|
||||||
} = body;
|
} = body;
|
||||||
@@ -91,6 +95,7 @@ export async function POST(request: Request) {
|
|||||||
driverName: driverName || null,
|
driverName: driverName || null,
|
||||||
driverPhone: driverPhone || null,
|
driverPhone: driverPhone || null,
|
||||||
distanceKm: distanceKm != null ? distanceKm : null,
|
distanceKm: distanceKm != null ? distanceKm : null,
|
||||||
|
routeId: routeId || null,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
isDemo: isDemo ?? false,
|
isDemo: isDemo ?? false,
|
||||||
},
|
},
|
||||||
@@ -98,6 +103,9 @@ export async function POST(request: Request) {
|
|||||||
student: {
|
student: {
|
||||||
select: { id: true, firstName: true, lastName: true },
|
select: { id: true, firstName: true, lastName: true },
|
||||||
},
|
},
|
||||||
|
route: {
|
||||||
|
select: { id: true, routeNumber: true, routeName: true, transportType: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3595,4 +3595,359 @@
|
|||||||
background: rgba(24, 24, 27, 0.7);
|
background: rgba(24, 24, 27, 0.7);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +72,8 @@ import {
|
|||||||
PartyPopper,
|
PartyPopper,
|
||||||
Newspaper,
|
Newspaper,
|
||||||
Compass,
|
Compass,
|
||||||
|
UsersRound,
|
||||||
|
Bus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -192,6 +194,9 @@ import SchoolEventsView from './school-events-view';
|
|||||||
import StudentCareerView from './student-career-view';
|
import StudentCareerView from './student-career-view';
|
||||||
import SubstituteTeacherView from './substitute-teacher-view';
|
import SubstituteTeacherView from './substitute-teacher-view';
|
||||||
import SchoolNewsletterView from './school-newsletter-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
|
// Dynamic imports for heavy components with loading skeletons
|
||||||
const AnalyticsView = dynamic(() => import('./analytics-view'), {
|
const AnalyticsView = dynamic(() => import('./analytics-view'), {
|
||||||
@@ -223,6 +228,7 @@ const navSections: NavSection[] = [
|
|||||||
{ key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
|
{ key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
|
||||||
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
|
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
|
||||||
{ key: 'analytics', icon: TrendingUp, labelKey: 'nav.analytics' },
|
{ key: 'analytics', icon: TrendingUp, labelKey: 'nav.analytics' },
|
||||||
|
{ key: 'grade-analytics', icon: BarChart3, labelKey: 'nav.grade-analytics' },
|
||||||
{ key: 'reports', icon: FileText, labelKey: 'nav.reports' },
|
{ key: 'reports', icon: FileText, labelKey: 'nav.reports' },
|
||||||
{ key: 'coverage', icon: Target, labelKey: 'nav.coverage' },
|
{ key: 'coverage', icon: Target, labelKey: 'nav.coverage' },
|
||||||
],
|
],
|
||||||
@@ -258,6 +264,7 @@ const navSections: NavSection[] = [
|
|||||||
{ key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' },
|
{ key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' },
|
||||||
{ key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' },
|
{ key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' },
|
||||||
{ key: 'exam-calendar', icon: CalendarClock, labelKey: 'nav.exam_calendar' },
|
{ 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: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' },
|
||||||
{ key: 'seating-chart', icon: LayoutGridIcon, labelKey: 'nav.seating_chart' },
|
{ key: 'seating-chart', icon: LayoutGridIcon, labelKey: 'nav.seating_chart' },
|
||||||
{ key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' },
|
{ key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' },
|
||||||
@@ -267,6 +274,7 @@ const navSections: NavSection[] = [
|
|||||||
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
||||||
{ key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' },
|
{ key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' },
|
||||||
{ key: 'school-newsletter', icon: Newspaper, labelKey: 'nav.school-newsletter' },
|
{ 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: 'substitute-teacher', icon: UserCheck, labelKey: 'nav.substitute-teacher' },
|
||||||
{ key: 'data-import-export', icon: Database, labelKey: 'nav.data-import-export' },
|
{ 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: 'notebooks', icon: BookOpen, labelKey: 'nav.student_notebooks' },
|
||||||
{ key: 'flower', icon: Flower2, labelKey: 'nav.student_competencies' },
|
{ key: 'flower', icon: Flower2, labelKey: 'nav.student_competencies' },
|
||||||
{ key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' },
|
{ 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: 'homework', icon: BookCheck, labelKey: 'nav.homework' },
|
||||||
{ key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' },
|
{ key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' },
|
||||||
{ key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' },
|
{ key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' },
|
||||||
@@ -305,6 +314,7 @@ const studentNavSections: NavSection[] = [
|
|||||||
{ key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' },
|
{ key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' },
|
||||||
{ key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' },
|
{ key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' },
|
||||||
{ key: 'exam-calendar', icon: CalendarClock, labelKey: 'nav.exam_calendar' },
|
{ 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: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' },
|
||||||
{ key: 'seating-chart', icon: LayoutGridIcon, labelKey: 'nav.seating_chart' },
|
{ key: 'seating-chart', icon: LayoutGridIcon, labelKey: 'nav.seating_chart' },
|
||||||
{ key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' },
|
{ 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-library', icon: LibraryIcon, labelKey: 'nav.school_library' },
|
||||||
{ key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' },
|
{ key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' },
|
||||||
{ key: 'school-newsletter', icon: Newspaper, labelKey: 'nav.school-newsletter' },
|
{ 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: 'substitute-teacher', icon: UserCheck, labelKey: 'nav.substitute-teacher' },
|
||||||
{ key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
|
{ key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
|
||||||
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
||||||
@@ -333,6 +344,7 @@ const parentNavSections: NavSection[] = [
|
|||||||
{ key: 'parent-portal', icon: Heart, labelKey: 'parent_portal.title' },
|
{ key: 'parent-portal', icon: Heart, labelKey: 'parent_portal.title' },
|
||||||
{ key: 'parents', icon: Mail, labelKey: 'parent.messages' },
|
{ key: 'parents', icon: Mail, labelKey: 'parent.messages' },
|
||||||
{ key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' },
|
{ 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: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' },
|
||||||
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
|
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
|
||||||
{ key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' },
|
{ key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' },
|
||||||
@@ -341,10 +353,12 @@ const parentNavSections: NavSection[] = [
|
|||||||
{ key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' },
|
{ key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' },
|
||||||
{ key: 'student-study-planner', icon: Timer, labelKey: 'study_planner.title' },
|
{ key: 'student-study-planner', icon: Timer, labelKey: 'study_planner.title' },
|
||||||
{ key: 'student-career', icon: Compass, labelKey: 'nav.student-career' },
|
{ 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: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' },
|
||||||
{ key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' },
|
{ key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' },
|
||||||
{ key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' },
|
{ key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' },
|
||||||
{ key: 'school-newsletter', icon: Newspaper, labelKey: 'nav.school-newsletter' },
|
{ 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: 'substitute-teacher', icon: UserCheck, labelKey: 'nav.substitute-teacher' },
|
||||||
{ key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
|
{ key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
|
||||||
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
||||||
@@ -491,6 +505,8 @@ function renderView(view: ViewName) {
|
|||||||
case 'announcements': return <SchoolAnnouncementsView />;
|
case 'announcements': return <SchoolAnnouncementsView />;
|
||||||
case 'tablet-grading': return <TabletGradingView />;
|
case 'tablet-grading': return <TabletGradingView />;
|
||||||
case 'exam-calendar': return <ExamCalendarView />;
|
case 'exam-calendar': return <ExamCalendarView />;
|
||||||
|
case 'peer-assessment': return <PeerAssessmentView />;
|
||||||
|
case 'grade-analytics': return <GradeAnalyticsView />;
|
||||||
case 'student-achievements': return <StudentAchievementsView />;
|
case 'student-achievements': return <StudentAchievementsView />;
|
||||||
case 'student-study-planner': return <StudentStudyPlannerView />;
|
case 'student-study-planner': return <StudentStudyPlannerView />;
|
||||||
case 'seating-chart': return <SeatingChartView />;
|
case 'seating-chart': return <SeatingChartView />;
|
||||||
@@ -502,6 +518,7 @@ function renderView(view: ViewName) {
|
|||||||
case 'school-events': return <SchoolEventsView />;
|
case 'school-events': return <SchoolEventsView />;
|
||||||
case 'substitute-teacher': return <SubstituteTeacherView />;
|
case 'substitute-teacher': return <SubstituteTeacherView />;
|
||||||
case 'school-newsletter': return <SchoolNewsletterView />;
|
case 'school-newsletter': return <SchoolNewsletterView />;
|
||||||
|
case 'school-transport': return <SchoolTransportView />;
|
||||||
default: return <DashboardView />;
|
default: return <DashboardView />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ChevronLeft,
|
||||||
Loader2,
|
Loader2,
|
||||||
Users,
|
Users,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -31,6 +32,9 @@ import {
|
|||||||
FileDown,
|
FileDown,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Heart,
|
Heart,
|
||||||
|
Activity,
|
||||||
|
CalendarDays,
|
||||||
|
CircleDot,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
LineChart,
|
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 (
|
||||||
|
<div className="attendance-gauge" style={{ width: size, height: size }}>
|
||||||
|
<svg width={size} height={size}>
|
||||||
|
<circle
|
||||||
|
className="attendance-gauge-track"
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className={`attendance-gauge-fill ${rateClass}`}
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
style={{ '--circumference': circumference, '--offset': offset } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="attendance-gauge-label">
|
||||||
|
<span className="text-xl font-bold text-gray-900 dark:text-gray-100">{rate}%</span>
|
||||||
|
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">{t('attendance.rate')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mini Calendar Date Picker ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
function MiniCalendar({
|
||||||
|
selectedDate,
|
||||||
|
onDateSelect,
|
||||||
|
sessionDates,
|
||||||
|
}: {
|
||||||
|
selectedDate: string;
|
||||||
|
onDateSelect: (date: string) => void;
|
||||||
|
sessionDates: Set<string>;
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMonth((v) => {
|
||||||
|
const d = new Date(v.year, v.month - 1, 1);
|
||||||
|
return { year: d.getFullYear(), month: d.getMonth() };
|
||||||
|
})}
|
||||||
|
className="p-1 rounded-md hover:bg-emerald-100 dark:hover:bg-emerald-900/30 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
{monthNames[viewMonth.month]} {viewMonth.year}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMonth((v) => {
|
||||||
|
const d = new Date(v.year, v.month + 1, 1);
|
||||||
|
return { year: d.getFullYear(), month: d.getMonth() };
|
||||||
|
})}
|
||||||
|
className="p-1 rounded-md hover:bg-emerald-100 dark:hover:bg-emerald-900/30 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-0.5">
|
||||||
|
{dayLabels.map((d) => (
|
||||||
|
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">{d}</div>
|
||||||
|
))}
|
||||||
|
{cells.map((cell, i) => {
|
||||||
|
const isToday = cell.dateKey === todayStr;
|
||||||
|
const isSelected = cell.dateKey === selectedDate;
|
||||||
|
const hasSession = cell.dateKey ? sessionDates.has(cell.dateKey) : false;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
disabled={!cell.day}
|
||||||
|
onClick={() => cell.dateKey && onDateSelect(cell.dateKey)}
|
||||||
|
className={`mini-calendar-day ${isSelected ? 'selected' : ''} ${isToday ? 'today' : ''} ${hasSession ? 'has-session' : ''} ${!cell.day ? 'opacity-0 cursor-default' : ''}`}
|
||||||
|
>
|
||||||
|
{cell.day}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 (
|
||||||
|
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||||
|
{t('attendance.no_sessions')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{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 (
|
||||||
|
<div key={session.id} className="flex items-center gap-3">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className={`timeline-dot ${rate >= 90 ? 'bg-emerald-500' : rate >= 70 ? 'bg-amber-500' : 'bg-rose-500'}`} />
|
||||||
|
<div className="w-px h-8 bg-gray-200 dark:bg-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-3 rounded-lg bg-gradient-to-r from-white to-gray-50/50 dark:from-gray-900 dark:to-gray-800/50 border border-gray-100 dark:border-gray-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{session.period ?? t('attendance.status')}
|
||||||
|
</span>
|
||||||
|
{session.subject && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">{session.subject.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="status-badge status-badge-present"><CheckCircle2 className="w-3 h-3" />{presentCount}</span>
|
||||||
|
<span className="status-badge status-badge-late"><Timer className="w-3 h-3" />{lateCount}</span>
|
||||||
|
<span className="status-badge status-badge-absent"><XCircle className="w-3 h-3" />{absentCount}</span>
|
||||||
|
{excusedCount > 0 && <span className="status-badge status-badge-excused"><ClipboardList className="w-3 h-3" />{excusedCount}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 w-full h-1.5 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
|
||||||
|
<div className="flex h-full">
|
||||||
|
<div className="bg-emerald-500 h-full" style={{ width: `${total > 0 ? (presentCount / total) * 100 : 0}%` }} />
|
||||||
|
<div className="bg-amber-500 h-full" style={{ width: `${total > 0 ? (lateCount / total) * 100 : 0}%` }} />
|
||||||
|
<div className="bg-rose-500 h-full" style={{ width: `${total > 0 ? (absentCount / total) * 100 : 0}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Stats Cards ───────────────────────────────────────────────────── */
|
/* ── Stats Cards ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function StatsCards({ sessions }: { sessions: AttendanceSession[] }) {
|
function StatsCards({ sessions }: { sessions: AttendanceSession[] }) {
|
||||||
@@ -426,28 +621,41 @@ function StatsCards({ sessions }: { sessions: AttendanceSession[] }) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
<div className="space-y-4">
|
||||||
{cards.map((card, idx) => (
|
{/* Attendance Gauge + Summary */}
|
||||||
<motion.div
|
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||||
key={card.label}
|
<AttendanceGauge rate={stats.rate} size={100} />
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 flex-1">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<div className="glass-card-enhanced rounded-xl p-3 text-center">
|
||||||
transition={{ duration: 0.3, delay: idx * 0.06 }}
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
whileHover={{ y: -2 }}
|
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
>
|
<span className="text-[10px] uppercase tracking-wider font-semibold text-emerald-600/70 dark:text-emerald-400/60">{t('attendance.present')}</span>
|
||||||
<Card className={`card-hover-lift border-2 ${card.border} shadow-md hover:shadow-lg transition-shadow rounded-xl overflow-hidden`}>
|
</div>
|
||||||
<CardContent className="p-4">
|
<span className="text-xl font-bold text-emerald-600 dark:text-emerald-400">{stats.present}</span>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
</div>
|
||||||
<card.icon className="w-4 h-4" />
|
<div className="glass-card-enhanced rounded-xl p-3 text-center">
|
||||||
<span className="text-xs font-medium text-muted-foreground">{card.label}</span>
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
|
<XCircle className="w-3.5 h-3.5 text-rose-500" />
|
||||||
|
<span className="text-[10px] uppercase tracking-wider font-semibold text-rose-600/70 dark:text-rose-400/60">{t('attendance.absent')}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-rose-600 dark:text-rose-400">{stats.absent}</span>
|
||||||
|
</div>
|
||||||
|
<div className="glass-card-enhanced rounded-xl p-3 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
|
<ClipboardList className="w-3.5 h-3.5 text-amber-500" />
|
||||||
|
<span className="text-[10px] uppercase tracking-wider font-semibold text-amber-600/70 dark:text-amber-400/60">{t('attendance.excused')}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-amber-600 dark:text-amber-400">{stats.excused}</span>
|
||||||
|
</div>
|
||||||
|
<div className="glass-card-enhanced rounded-xl p-3 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
|
<Timer className="w-3.5 h-3.5 text-orange-500" />
|
||||||
|
<span className="text-[10px] uppercase tracking-wider font-semibold text-orange-600/70 dark:text-orange-400/60">{t('attendance.late')}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-orange-600 dark:text-orange-400">{stats.late}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`text-2xl font-bold bg-gradient-to-r ${card.gradient} bg-clip-text text-transparent`}>
|
|
||||||
{card.value}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1300,18 +1508,47 @@ export default function AttendanceView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6 max-w-7xl mx-auto space-y-6">
|
<div className="p-4 sm:p-6 max-w-7xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* Gradient Header Banner */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -10 }}
|
initial={{ opacity: 0, y: -12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"
|
transition={{ duration: 0.4 }}
|
||||||
|
className="gradient-header-banner rounded-xl px-6 py-5"
|
||||||
>
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-lg shadow-emerald-300/30">
|
||||||
|
<CalendarCheck className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold bg-gradient-to-r from-emerald-500 to-teal-600 bg-clip-text text-transparent flex items-center gap-2">
|
<h1 className="text-2xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 dark:from-emerald-400 dark:to-teal-400 bg-clip-text text-transparent">
|
||||||
<CalendarCheck className="h-8 w-8 text-emerald-500" />
|
|
||||||
{t('attendance.title')}
|
{t('attendance.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-emerald-600/60 dark:text-emerald-400/40 mt-1">{t('attendance.subtitle')}</p>
|
<p className="text-sm text-emerald-600/60 dark:text-emerald-400/40 mt-0.5">{t('attendance.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 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 (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/60 dark:bg-gray-800/40 border border-emerald-200/30 dark:border-emerald-900/30">
|
||||||
|
<Activity className="h-4 w-4 text-emerald-500" />
|
||||||
|
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">{t('date.today')}:</span>
|
||||||
|
<span className={`text-sm font-bold ${todayRate >= 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}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -1412,6 +1649,7 @@ export default function AttendanceView() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* No class selected */}
|
{/* No class selected */}
|
||||||
@@ -1430,7 +1668,7 @@ export default function AttendanceView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats card — fills the empty space with school-wide attendance stats */}
|
{/* Quick Stats card — fills the empty space with school-wide attendance stats */}
|
||||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-400 overflow-hidden">
|
<Card className="glass-card-enhanced rounded-xl border-l-3 border-l-emerald-400 overflow-hidden">
|
||||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||||
<CardTitle className="text-base font-bold flex items-center gap-2">
|
<CardTitle className="text-base font-bold flex items-center gap-2">
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||||
@@ -1531,6 +1769,10 @@ export default function AttendanceView() {
|
|||||||
<TabsTrigger value="calendar" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
|
<TabsTrigger value="calendar" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
|
||||||
{t('attendance.calendar')}
|
{t('attendance.calendar')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="today" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
|
||||||
|
<CalendarDays className="h-4 w-4 mr-1" />
|
||||||
|
{t('date.today')}
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="qr" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
|
<TabsTrigger value="qr" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
|
||||||
<QrCode className="h-4 w-4 mr-1" />
|
<QrCode className="h-4 w-4 mr-1" />
|
||||||
{t('qr.attendance')}
|
{t('qr.attendance')}
|
||||||
@@ -1571,16 +1813,14 @@ export default function AttendanceView() {
|
|||||||
exit={{ opacity: 0, x: 20 }}
|
exit={{ opacity: 0, x: 20 }}
|
||||||
transition={{ delay: idx * 0.05 }}
|
transition={{ delay: idx * 0.05 }}
|
||||||
>
|
>
|
||||||
<Card
|
<Card className="cursor-pointer glass-card-enhanced transition-all
|
||||||
className="cursor-pointer hover:shadow-lg transition-all border-0 shadow-md
|
|
||||||
bg-gradient-to-r from-white to-gray-50/50 dark:from-gray-900 dark:to-gray-800/50
|
|
||||||
hover:from-emerald-50/30 hover:to-teal-50/20 dark:hover:from-emerald-950/20 dark:hover:to-teal-950/10"
|
hover:from-emerald-50/30 hover:to-teal-50/20 dark:hover:from-emerald-950/20 dark:hover:to-teal-950/10"
|
||||||
onClick={() => setSelectedSessionId(session.id)}
|
onClick={() => setSelectedSessionId(session.id)}
|
||||||
>
|
>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-emerald-100 dark:bg-emerald-900/50">
|
<div className="flex flex-col items-center justify-center w-12 h-12 rounded-lg glass-card-enhanced">
|
||||||
<span className="text-lg font-bold text-emerald-700 dark:text-emerald-300">
|
<span className="text-lg font-bold text-emerald-700 dark:text-emerald-300">
|
||||||
{sessionDate.getDate()}
|
{sessionDate.getDate()}
|
||||||
</span>
|
</span>
|
||||||
@@ -1614,7 +1854,7 @@ export default function AttendanceView() {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center gap-1 bg-emerald-50 dark:bg-emerald-900/30 px-2 py-1 rounded-md">
|
<div className="flex items-center gap-1 bg-emerald-50/60 dark:bg-emerald-900/30 px-2 py-1 rounded-md">
|
||||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
<span className="text-xs font-medium text-emerald-700 dark:text-emerald-300">{presentCount}</span>
|
<span className="text-xs font-medium text-emerald-700 dark:text-emerald-300">{presentCount}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1625,7 +1865,7 @@ export default function AttendanceView() {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center gap-1 bg-rose-50 dark:bg-rose-900/30 px-2 py-1 rounded-md">
|
<div className="flex items-center gap-1 bg-rose-50/60 dark:bg-rose-900/30 px-2 py-1 rounded-md">
|
||||||
<XCircle className="w-3.5 h-3.5" />
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
<span className="text-xs font-medium text-rose-700 dark:text-rose-300">{absentCount}</span>
|
<span className="text-xs font-medium text-rose-700 dark:text-rose-300">{absentCount}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1636,7 +1876,7 @@ export default function AttendanceView() {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center gap-1 bg-amber-50 dark:bg-amber-900/30 px-2 py-1 rounded-md">
|
<div className="flex items-center gap-1 bg-amber-50/60 dark:bg-amber-900/30 px-2 py-1 rounded-md">
|
||||||
<ClipboardList className="w-3.5 h-3.5" />
|
<ClipboardList className="w-3.5 h-3.5" />
|
||||||
<span className="text-xs font-medium text-amber-700 dark:text-amber-300">{excusedCount}</span>
|
<span className="text-xs font-medium text-amber-700 dark:text-amber-300">{excusedCount}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1647,7 +1887,7 @@ export default function AttendanceView() {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center gap-1 bg-orange-50 dark:bg-orange-900/30 px-2 py-1 rounded-md">
|
<div className="flex items-center gap-1 bg-orange-50/60 dark:bg-orange-900/30 px-2 py-1 rounded-md">
|
||||||
<Timer className="w-3.5 h-3.5" />
|
<Timer className="w-3.5 h-3.5" />
|
||||||
<span className="text-xs font-medium text-orange-700 dark:text-orange-300">{lateCount}</span>
|
<span className="text-xs font-medium text-orange-700 dark:text-orange-300">{lateCount}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1659,13 +1899,7 @@ export default function AttendanceView() {
|
|||||||
|
|
||||||
{/* Rate badge */}
|
{/* Rate badge */}
|
||||||
<div
|
<div
|
||||||
className={`text-sm font-bold px-2 py-1 rounded-lg ${
|
className={`status-badge ${rate >= 90 ? 'status-badge-present' : rate >= 70 ? 'status-badge-late' : 'status-badge-absent'}`}
|
||||||
rate >= 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'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{rate}%
|
{rate}%
|
||||||
</div>
|
</div>
|
||||||
@@ -1710,9 +1944,100 @@ export default function AttendanceView() {
|
|||||||
<MonthlyHeatmap sessions={sessions} />
|
<MonthlyHeatmap sessions={sessions} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Today's Attendance Tab */}
|
||||||
|
<TabsContent value="today" className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
{/* Mini Calendar */}
|
||||||
|
<Card className="glass-card-enhanced rounded-xl overflow-hidden">
|
||||||
|
<CardHeader className="pb-3 pt-5">
|
||||||
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||||
|
<CalendarDays className="h-4 w-4 text-emerald-500" />
|
||||||
|
{t('attendance.calendar')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<MiniCalendar
|
||||||
|
selectedDate={newDate}
|
||||||
|
onDateSelect={setNewDate}
|
||||||
|
sessionDates={(() => {
|
||||||
|
const dates = new Set<string>();
|
||||||
|
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;
|
||||||
|
})()}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/* Daily Timeline */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<Card className="glass-card-enhanced rounded-xl overflow-hidden">
|
||||||
|
<CardHeader className="pb-3 pt-5">
|
||||||
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||||
|
<Activity className="h-4 w-4 text-teal-500" />
|
||||||
|
{t('attendance.daily_timeline') ?? (t('date.today') === 'Heute' ? 'Tagesverlauf' : 'Daily Timeline')}
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">{newDate}</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DailyTimeline sessions={sessions} selectedDate={newDate} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/* Attendance Gauge for selected date */}
|
||||||
|
<Card className="glass-card-enhanced rounded-xl overflow-hidden">
|
||||||
|
<CardHeader className="pb-3 pt-5">
|
||||||
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||||
|
<CircleDot className="h-4 w-4 text-emerald-500" />
|
||||||
|
{t('attendance.rate') ?? 'Rate'}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-center gap-6">
|
||||||
|
{(() => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<AttendanceGauge rate={rate} size={120} />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-emerald-500" />
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">{t('attendance.present')}: {present}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-rose-500" />
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">{t('attendance.absent')}: {daySessions.reduce((sum, s) => sum + s.records.filter((r) => r.status === 'ABSENT').length, 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-amber-500" />
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">{t('attendance.late')}: {daySessions.reduce((sum, s) => sum + s.records.filter((r) => r.status === 'LATE').length, 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-gray-400" />
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">{t('attendance.total_records') ?? 'Total'}: {total}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* QR Attendance Tab */}
|
{/* QR Attendance Tab */}
|
||||||
<TabsContent value="qr" className="space-y-4">
|
<TabsContent value="qr" className="space-y-4">
|
||||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
<Card className="glass-card-enhanced rounded-xl overflow-hidden">
|
||||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||||
|
|||||||
+246
-14
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Users, Plus, Search, BookOpen, UserPlus, Grid3X3, School,
|
Users, Plus, Search, BookOpen, UserPlus, Grid3X3, School,
|
||||||
Download, Upload, FileText, CheckCircle2, AlertTriangle, FileUp,
|
Download, Upload, FileText, CheckCircle2, AlertTriangle, FileUp,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Shuffle, Printer, Eraser, Columns3, Rows3, Move,
|
Shuffle, Printer, Eraser, Columns3, Rows3, Move,
|
||||||
Camera,
|
Camera,
|
||||||
UserCheck, Shield, Eye, MessageSquare, CalendarDays, AlertCircle,
|
UserCheck, Shield, Eye, MessageSquare, CalendarDays, AlertCircle,
|
||||||
|
Activity, Zap, TrendingUp, CircleDot,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
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';
|
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<string, string> = {
|
||||||
|
'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<string, string> = {
|
||||||
|
'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<string, string> = {
|
||||||
|
'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 <span className="animate-count-up">{display}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="attendance-gauge" style={{ width: size, height: size }}>
|
||||||
|
<svg width={size} height={size}>
|
||||||
|
<circle
|
||||||
|
className="attendance-gauge-track"
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className={`attendance-gauge-fill ${rateClass}`}
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
style={{ '--circumference': circumference, '--offset': offset } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="attendance-gauge-label">
|
||||||
|
<span className="text-lg font-bold text-gray-900 dark:text-gray-100">{rate}%</span>
|
||||||
|
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">{t('attendance.rate')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ClassesView() {
|
export default function ClassesView() {
|
||||||
const currentUser = useAppStore((s) => s.currentUser);
|
const currentUser = useAppStore((s) => s.currentUser);
|
||||||
const currentClassId = useAppStore((s) => s.currentClassId);
|
const currentClassId = useAppStore((s) => s.currentClassId);
|
||||||
@@ -844,9 +925,134 @@ export default function ClassesView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Gradient Header Banner */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="gradient-header-banner rounded-xl px-6 py-5"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-lg shadow-emerald-300/30">
|
||||||
|
<School className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 dark:from-emerald-400 dark:to-teal-400 bg-clip-text text-transparent">
|
||||||
|
{t('classes.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-emerald-600/60 dark:text-emerald-400/40 mt-0.5">
|
||||||
|
{locale === 'de' ? 'Klassen und Schueler verwalten' : 'Manage classes and students'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/60 dark:bg-gray-800/40 border border-emerald-200/30 dark:border-emerald-900/30">
|
||||||
|
<Users className="h-4 w-4 text-emerald-500" />
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
<AnimatedCount value={filteredClasses.length} /> {locale === 'de' ? 'Klassen' : 'Classes'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/60 dark:bg-gray-800/40 border border-emerald-200/30 dark:border-emerald-900/30">
|
||||||
|
<Activity className="h-4 w-4 text-teal-500" />
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
<AnimatedCount value={filteredClasses.reduce((s, c) => s + (c.studentCount ?? 0), 0)} /> {locale === 'de' ? 'Schueler' : 'Students'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Class Statistics Summary */}
|
||||||
|
{filteredClasses.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.1 }}
|
||||||
|
className="grid grid-cols-2 sm:grid-cols-4 gap-3"
|
||||||
|
>
|
||||||
|
<div className="glass-card-enhanced rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
|
||||||
|
<School className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider font-semibold text-emerald-600/70 dark:text-emerald-400/60">{locale === 'de' ? 'Klassen' : 'Classes'}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100 animate-count-up">{filteredClasses.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="glass-card-enhanced rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-sm">
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider font-semibold text-teal-600/70 dark:text-teal-400/60">{locale === 'de' ? 'Schueler' : 'Students'}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100 animate-count-up">{filteredClasses.reduce((s, c) => s + (c.studentCount ?? 0), 0)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="glass-card-enhanced rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm">
|
||||||
|
<BookOpen className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider font-semibold text-amber-600/70 dark:text-amber-400/60">{locale === 'de' ? 'Faecher' : 'Subjects'}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100 animate-count-up">{filteredClasses.reduce((s, c) => s + (c._count?.competencyAssignments ?? 0), 0)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="glass-card-enhanced rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-violet-400 to-violet-500 text-white shadow-sm">
|
||||||
|
<BarChart3 className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider font-semibold text-violet-600/70 dark:text-violet-400/60">{locale === 'de' ? 'Jahrgaenge' : 'Grades'}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100 animate-count-up">{new Set(filteredClasses.map((c) => c.gradeLevel)).size}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Class Comparison Mini-Chart */}
|
||||||
|
{filteredClasses.length > 1 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.15 }}
|
||||||
|
className="glass-card-enhanced rounded-xl p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<TrendingUp className="h-4 w-4 text-emerald-500" />
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">{locale === 'de' ? 'Klassenvergleich' : 'Class Comparison'}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{locale === 'de' ? 'Schueler pro Klasse' : 'Students per class'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{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 (
|
||||||
|
<div key={cls.id} className="flex items-center gap-2">
|
||||||
|
<span className="grade-indicator grade-{gradeKey}">{cls.gradeLevel}</span>
|
||||||
|
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 w-20 truncate">{cls.name}</span>
|
||||||
|
<div className="flex-1 h-5 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${Math.max(pct, 3)}%` }}
|
||||||
|
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||||
|
className={`h-full rounded-full ${gradeLevelColors[gradeKey] ?? 'bg-emerald-500'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-gray-600 dark:text-gray-400 w-8 text-right">{count}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{/* Class list */}
|
{/* Class list */}
|
||||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
<Card className="glass-card-enhanced rounded-xl overflow-hidden">
|
||||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm shadow-emerald-300/20">
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm shadow-emerald-300/20">
|
||||||
@@ -897,17 +1103,19 @@ export default function ClassesView() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filteredClasses.map((cls) => (
|
{filteredClasses.map((cls) => {
|
||||||
|
const gradeKey = String(cls.gradeLevel ?? 1);
|
||||||
|
return (
|
||||||
<motion.button
|
<motion.button
|
||||||
key={cls.id}
|
key={cls.id}
|
||||||
whileHover={{ scale: 1.02, y: -3 }}
|
whileHover={{ scale: 1.02, y: -3 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
onClick={() => handleSelectClass(cls)}
|
onClick={() => 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 ${
|
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
|
selectedClass?.id === cls.id
|
||||||
? 'shadow-lg ring-2 ring-emerald-300/60 dark:ring-emerald-700/50'
|
? '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 */}
|
{/* Gradient overlay strip at top */}
|
||||||
@@ -917,6 +1125,10 @@ export default function ClassesView() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{cls.name}</p>
|
<p className="font-semibold text-gray-900 dark:text-gray-100">{cls.name}</p>
|
||||||
|
{/* Grade level indicator */}
|
||||||
|
<span className={`grade-indicator grade-${Math.min(Math.max(cls.gradeLevel ?? 1, 1), 6)}`}>
|
||||||
|
{cls.gradeLevel}
|
||||||
|
</span>
|
||||||
<Badge className={`${schoolTypeBadgeBg[cls.schoolType] ?? schoolTypeBadgeBg.OTHER} text-[10px] font-semibold rounded-md px-1.5 py-0.5 flex items-center gap-1`}>
|
<Badge className={`${schoolTypeBadgeBg[cls.schoolType] ?? schoolTypeBadgeBg.OTHER} text-[10px] font-semibold rounded-md px-1.5 py-0.5 flex items-center gap-1`}>
|
||||||
{schoolTypeIcon[cls.schoolType] ?? <Backpack className="w-3 h-3" />}
|
{schoolTypeIcon[cls.schoolType] ?? <Backpack className="w-3 h-3" />}
|
||||||
{t(`school_type.${cls.schoolType.toLowerCase()}`)}
|
{t(`school_type.${cls.schoolType.toLowerCase()}`)}
|
||||||
@@ -935,13 +1147,24 @@ export default function ClassesView() {
|
|||||||
<div className="flex items-center gap-3 mt-2">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-600/70 dark:text-emerald-400/60">
|
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-600/70 dark:text-emerald-400/60">
|
||||||
<Users className="h-3 w-3" />
|
<Users className="h-3 w-3" />
|
||||||
{cls.studentCount ?? 0} {t('label.student_count').toLowerCase()}
|
<AnimatedCount value={cls.studentCount ?? 0} duration={400} /> {t('label.student_count').toLowerCase()}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1 text-[11px] text-teal-600/70 dark:text-teal-400/60">
|
<span className="inline-flex items-center gap-1 text-[11px] text-teal-600/70 dark:text-teal-400/60">
|
||||||
<BookOpen className="h-3 w-3" />
|
<BookOpen className="h-3 w-3" />
|
||||||
{cls._count?.competencyAssignments ?? 0} {locale === 'de' ? 'Faecher' : 'subjects'}
|
{cls._count?.competencyAssignments ?? 0} {locale === 'de' ? 'Faecher' : 'subjects'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="flex items-center gap-1.5 mt-2">
|
||||||
|
<button className="quick-action-btn" onClick={(e) => { e.stopPropagation(); handleSelectClass(cls); }}>
|
||||||
|
<Zap className="h-3 w-3" />
|
||||||
|
{locale === 'de' ? 'Details' : 'Details'}
|
||||||
|
</button>
|
||||||
|
<button className="quick-action-btn" onClick={(e) => { e.stopPropagation(); if (selectedClass?.id === cls.id) { setSeatingChartOpen(true); } else { handleSelectClass(cls); } }}>
|
||||||
|
<Grid3X3 className="h-3 w-3" />
|
||||||
|
{locale === 'de' ? 'Sitzplan' : 'Seating'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{/* Small progress indicator */}
|
{/* Small progress indicator */}
|
||||||
@@ -951,14 +1174,22 @@ export default function ClassesView() {
|
|||||||
style={{ width: `${progressPercent(cls)}%` }}
|
style={{ width: `${progressPercent(cls)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs font-medium">
|
{/* Animated student count badge */}
|
||||||
{cls.studentCount ?? 0} <Users className="h-3 w-3 ml-1" />
|
<motion.div
|
||||||
</Badge>
|
initial={{ scale: 0.8 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-gradient-to-r from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 border border-emerald-200/30 dark:border-emerald-800/30"
|
||||||
|
>
|
||||||
|
<Users className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
|
||||||
|
<span className="text-xs font-bold text-emerald-700 dark:text-emerald-300">{cls.studentCount ?? 0}</span>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -968,7 +1199,7 @@ export default function ClassesView() {
|
|||||||
<div className="md:col-span-1 lg:col-span-2 space-y-6">
|
<div className="md:col-span-1 lg:col-span-2 space-y-6">
|
||||||
{!selectedClass ? (
|
{!selectedClass ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
<Card className="glass-card-enhanced rounded-xl overflow-hidden">
|
||||||
<CardContent className="py-16 text-center">
|
<CardContent className="py-16 text-center">
|
||||||
<div className="relative flex items-center justify-center w-24 h-24 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-6 shadow-lg shadow-emerald-200/50 dark:shadow-emerald-900/20 ring-4 ring-emerald-50 dark:ring-emerald-900/30">
|
<div className="relative flex items-center justify-center w-24 h-24 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-6 shadow-lg shadow-emerald-200/50 dark:shadow-emerald-900/20 ring-4 ring-emerald-50 dark:ring-emerald-900/30">
|
||||||
<Users className="h-12 w-12 text-emerald-500 dark:text-emerald-400" />
|
<Users className="h-12 w-12 text-emerald-500 dark:text-emerald-400" />
|
||||||
@@ -985,7 +1216,7 @@ export default function ClassesView() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Recent Activity widget — fills the empty space with useful info */}
|
{/* Recent Activity widget — fills the empty space with useful info */}
|
||||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-400 overflow-hidden">
|
<Card className="glass-card-enhanced rounded-xl border-l-3 border-l-teal-400 overflow-hidden">
|
||||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
|
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
|
||||||
<CardTitle className="text-base font-bold flex items-center gap-2">
|
<CardTitle className="text-base font-bold flex items-center gap-2">
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-sm shadow-teal-300/20">
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-sm shadow-teal-300/20">
|
||||||
@@ -1061,7 +1292,7 @@ export default function ClassesView() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Class details */}
|
{/* Class details */}
|
||||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
|
<Card className="glass-card-enhanced rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
|
||||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -1282,7 +1513,7 @@ export default function ClassesView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Student roster */}
|
{/* Student roster */}
|
||||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
|
<Card className="glass-card-enhanced rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
|
||||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
|
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||||
@@ -1873,6 +2104,7 @@ export default function ClassesView() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Add student dialog */}
|
{/* Add student dialog */}
|
||||||
<Dialog open={addStudentOpen} onOpenChange={setAddStudentOpen}>
|
<Dialog open={addStudentOpen} onOpenChange={setAddStudentOpen}>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+660
@@ -6320,6 +6320,336 @@ const de: TranslationDict = {
|
|||||||
'newsletter.engagement_distribution': 'Engagement-Verteilung',
|
'newsletter.engagement_distribution': 'Engagement-Verteilung',
|
||||||
'newsletter.recent_performance': 'Aktuelle Performance',
|
'newsletter.recent_performance': 'Aktuelle Performance',
|
||||||
'newsletter.subscriber_growth': 'Abonnenten-Wachstum',
|
'newsletter.subscriber_growth': 'Abonnenten-Wachstum',
|
||||||
|
|
||||||
|
// ─── Peer Assessment ──────────────────────────────────────────────
|
||||||
|
'nav.peer-assessment': 'Peer-Beurteilung',
|
||||||
|
'pa.title': 'Peer-Beurteilung',
|
||||||
|
'pa.subtitle': 'Schüler bewerten Schüler — strukturiert und fair',
|
||||||
|
'pa.tab_dashboard': 'Dashboard',
|
||||||
|
'pa.tab_sessions': 'Sitzungen',
|
||||||
|
'pa.tab_create': 'Erstellen',
|
||||||
|
'pa.tab_conduct': 'Durchführen',
|
||||||
|
'pa.tab_results': 'Ergebnisse',
|
||||||
|
'pa.new_session': 'Neue Sitzung',
|
||||||
|
'pa.stat_total_sessions': 'Sitzungen gesamt',
|
||||||
|
'pa.stat_active': 'Aktiv',
|
||||||
|
'pa.stat_completed': 'Abgeschlossen',
|
||||||
|
'pa.stat_completion_rate': 'Abschlussrate',
|
||||||
|
'pa.sessions_overview': 'Sitzungsübersicht',
|
||||||
|
'pa.no_sessions': 'Noch keine Peer-Beurteilungen erstellt.',
|
||||||
|
'pa.create_first': 'Erste erstellen',
|
||||||
|
'pa.recent_activity': 'Letzte Aktivität',
|
||||||
|
'pa.no_activity': 'Keine Aktivitäten vorhanden.',
|
||||||
|
'pa.avg_ratings_by_type': 'Durchschnittsbewertungen nach Typ',
|
||||||
|
'pa.submitted': 'eingereicht',
|
||||||
|
'pa.search_sessions': 'Sitzungen suchen...',
|
||||||
|
'pa.all_statuses': 'Alle Status',
|
||||||
|
'pa.all_types': 'Alle Typen',
|
||||||
|
'pa.no_sessions_found': 'Keine Sitzungen gefunden.',
|
||||||
|
'pa.close_session': 'Sitzung schließen',
|
||||||
|
'pa.delete_session': 'Sitzung löschen',
|
||||||
|
'pa.progress': 'Fortschritt',
|
||||||
|
'pa.view_details': 'Details',
|
||||||
|
'pa.view_results': 'Ergebnisse',
|
||||||
|
'pa.create_session': 'Sitzung erstellen',
|
||||||
|
'pa.create_session_desc': 'Erstellen Sie eine neue Peer-Beurteilungssitzung mit Kriterien und Einstellungen.',
|
||||||
|
'pa.session_title': 'Titel',
|
||||||
|
'pa.session_title_placeholder': 'z.B. Projektdarstellung Klasse 8a',
|
||||||
|
'pa.assessment_type': 'Beurteilungstyp',
|
||||||
|
'pa.type_competency': 'Kompetenz',
|
||||||
|
'pa.type_project': 'Projekt',
|
||||||
|
'pa.type_presentation': 'Präsentation',
|
||||||
|
'pa.type_teamwork': 'Teamarbeit',
|
||||||
|
'pa.description': 'Beschreibung',
|
||||||
|
'pa.description_placeholder': 'Beschreibung der Peer-Beurteilung...',
|
||||||
|
'pa.settings': 'Einstellungen',
|
||||||
|
'pa.anonymity_mode': 'Anonymitätsmodus',
|
||||||
|
'pa.anonymous': 'Anonym',
|
||||||
|
'pa.semi_anonymous': 'Teil-anonym',
|
||||||
|
'pa.open': 'Offen',
|
||||||
|
'pa.anonymous_desc': 'Bewerter und Bewerteter bleiben anonym.',
|
||||||
|
'pa.semi_anonymous_desc': 'Bewerter bleibt anonym, Bewerteter ist sichtbar.',
|
||||||
|
'pa.open_desc': 'Alle Namen sind sichtbar.',
|
||||||
|
'pa.assign_mode': 'Zuweisungsmodus',
|
||||||
|
'pa.assign_manual': 'Manuell',
|
||||||
|
'pa.assign_random': 'Zufällig',
|
||||||
|
'pa.assign_group': 'Gruppenbasiert',
|
||||||
|
'pa.deadline': 'Abgabefrist',
|
||||||
|
'pa.criteria_builder': 'Kriterien-Builder',
|
||||||
|
'pa.add_criterion': 'Kriterium hinzufügen',
|
||||||
|
'pa.criterion_name': 'Name',
|
||||||
|
'pa.criterion_name_placeholder': 'z.B. Fachwissen',
|
||||||
|
'pa.criterion_desc': 'Beschreibung',
|
||||||
|
'pa.criterion_desc_placeholder': 'Beschreibung des Kriteriums...',
|
||||||
|
'pa.max_score': 'Max. Punkte',
|
||||||
|
'pa.select_session': 'Wählen Sie eine Sitzung, um eine Bewertung durchzuführen.',
|
||||||
|
'pa.go_to_sessions': 'Zu den Sitzungen',
|
||||||
|
'pa.criteria': 'Kriterien',
|
||||||
|
'pa.points': 'Punkte',
|
||||||
|
'pa.assessment_list': 'Bewertungsliste',
|
||||||
|
'pa.no_assessments': 'Keine Bewertungen in dieser Sitzung.',
|
||||||
|
'pa.assessed_by': 'Bewertet von',
|
||||||
|
'pa.anonymous_mode': 'Anonymer Modus',
|
||||||
|
'pa.anonymous_assessor': 'Anonymer Bewerter',
|
||||||
|
'pa.rate': 'Bewerten',
|
||||||
|
'pa.rate_peer': 'Peer bewerten',
|
||||||
|
'pa.rating_for': 'Bewertung für',
|
||||||
|
'pa.anonymous_mode_active': 'Anonymer Modus aktiv — Ihre Bewertung ist anonym.',
|
||||||
|
'pa.criteria_ratings': 'Kriterienbewertungen',
|
||||||
|
'pa.overall_rating': 'Gesamtbewertung',
|
||||||
|
'pa.feedback_comment': 'Feedback-Kommentar',
|
||||||
|
'pa.comment_placeholder': 'Schreiben Sie hier Ihr Feedback...',
|
||||||
|
'pa.submit_assessment': 'Bewertung einreichen',
|
||||||
|
'pa.select_session_results': 'Sitzung für Ergebnisse auswählen',
|
||||||
|
'pa.no_sessions_for_results': 'Keine Sitzungen für Ergebnisse verfügbar.',
|
||||||
|
'pa.total_assessments': 'Bewertungen gesamt',
|
||||||
|
'pa.overall_average': 'Gesamtdurchschnitt',
|
||||||
|
'pa.std_deviation': 'Standardabweichung',
|
||||||
|
'pa.unique_assessors': 'Eindeutige Bewerter',
|
||||||
|
'pa.unique_assessed': 'Eindeutig Bewertete',
|
||||||
|
'pa.competency_radar': 'Kompetenz-Radar',
|
||||||
|
'pa.average_score': 'Durchschnitt',
|
||||||
|
'pa.student_results': 'Schülerergebnisse',
|
||||||
|
'pa.no_results': 'Keine Ergebnisse verfügbar.',
|
||||||
|
'pa.assessments_received': 'Bewertungen erhalten',
|
||||||
|
'pa.more_comments': 'weitere Kommentare',
|
||||||
|
'pa.self_vs_peer': 'Selbst- vs. Peer-Bewertung',
|
||||||
|
'pa.peer_avg': 'Peer-Durchschnitt',
|
||||||
|
'pa.self_avg': 'Selbst-Bewertung',
|
||||||
|
'pa.outliers_detected': 'Ausreißer erkannt',
|
||||||
|
'pa.rated_by': 'Bewertet von',
|
||||||
|
'pa.ratings': 'Bewertungen',
|
||||||
|
'pa.results_title': 'Ergebnisse',
|
||||||
|
'pa.status_active': 'Aktiv',
|
||||||
|
'pa.status_completed': 'Abgeschlossen',
|
||||||
|
'pa.status_closed': 'Geschlossen',
|
||||||
|
'pa.status_pending': 'Ausstehend',
|
||||||
|
'pa.status_submitted': 'Eingereicht',
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
'nav.school-transport': 'Schulverkehr',
|
||||||
|
|
||||||
|
// Transport
|
||||||
|
'transport.title': 'Schulverkehr & Busrouten',
|
||||||
|
'transport.subtitle': 'Schülertransport, Routen und Logistik verwalten',
|
||||||
|
'transport.total_students': 'Schüler gesamt',
|
||||||
|
'transport.active_routes': 'Aktive Routen',
|
||||||
|
'transport.total_capacity': 'Gesamtkapazität',
|
||||||
|
'transport.avg_distance': 'Ø Entfernung',
|
||||||
|
'transport.over_capacity_warning': 'Route(n) über Kapazität',
|
||||||
|
'transport.over_capacity_desc': 'Eine oder mehrere Routen haben mehr Schüler zugewiesen als Plätze verfügbar sind.',
|
||||||
|
'transport.type_distribution': 'Transportart-Verteilung',
|
||||||
|
'transport.type_distribution_desc': 'Verteilung der Schüler nach Transportart',
|
||||||
|
'transport.route_capacity': 'Routenkapazität',
|
||||||
|
'transport.route_capacity_desc': 'Belegung vs. Kapazität der aktiven Routen',
|
||||||
|
'transport.assigned': 'Zugewiesen',
|
||||||
|
'transport.capacity': 'Kapazität',
|
||||||
|
'transport.type_summary': 'Transportart-Übersicht',
|
||||||
|
'transport.quick_actions': 'Schnellaktionen',
|
||||||
|
'transport.add_route': 'Route hinzufügen',
|
||||||
|
'transport.assign_transport': 'Transport zuweisen',
|
||||||
|
'transport.bulk_assign': 'Massenzuweisung',
|
||||||
|
'transport.search_routes': 'Routen suchen...',
|
||||||
|
'transport.filter_type': 'Transportart',
|
||||||
|
'transport.filter_status': 'Status',
|
||||||
|
'transport.all_types': 'Alle Arten',
|
||||||
|
'transport.all_status': 'Alle Status',
|
||||||
|
'transport.active': 'Aktiv',
|
||||||
|
'transport.inactive': 'Inaktiv',
|
||||||
|
'transport.no_routes': 'Keine Routen gefunden',
|
||||||
|
'transport.create_first_route': 'Erste Route erstellen',
|
||||||
|
'transport.stops_count': '{count} Haltestellen',
|
||||||
|
'transport.add_stop': 'Haltestelle',
|
||||||
|
'transport.no_data': 'Noch keine Daten vorhanden',
|
||||||
|
'transport.no_routes_data': 'Keine Routen mit Daten vorhanden',
|
||||||
|
'transport.search_students': 'Schüler suchen...',
|
||||||
|
'transport.no_assignments': 'Keine Transportzuweisungen vorhanden',
|
||||||
|
'transport.student': 'Schüler',
|
||||||
|
'transport.type': 'Art',
|
||||||
|
'transport.route': 'Route',
|
||||||
|
'transport.stop': 'Haltestelle',
|
||||||
|
'transport.pickup': 'Abholung',
|
||||||
|
'transport.dropoff': 'Absetzung',
|
||||||
|
'transport.distance': 'Entfernung',
|
||||||
|
'transport.actions': 'Aktionen',
|
||||||
|
'transport.no_schedule': 'Kein Fahrplan verfügbar',
|
||||||
|
'transport.no_schedule_desc': 'Erstellen Sie Routen mit Haltestellen, um den Fahrplan zu sehen.',
|
||||||
|
'transport.my_transport': 'Mein Transport',
|
||||||
|
'transport.my_route': 'Meine Route',
|
||||||
|
'transport.no_personal_transport': 'Keine Transportinformationen verfügbar',
|
||||||
|
'transport.tab_overview': 'Übersicht',
|
||||||
|
'transport.tab_routes': 'Routen',
|
||||||
|
'transport.tab_assignments': 'Zuweisungen',
|
||||||
|
'transport.tab_schedule': 'Fahrplan',
|
||||||
|
'transport.edit_route': 'Route bearbeiten',
|
||||||
|
'transport.create_route': 'Route erstellen',
|
||||||
|
'transport.edit_route_desc': 'Bearbeiten Sie die Routendetails',
|
||||||
|
'transport.create_route_desc': 'Erstellen Sie eine neue Transportroute',
|
||||||
|
'transport.route_number': 'Routennummer',
|
||||||
|
'transport.route_name': 'Routenname',
|
||||||
|
'transport.route_name_placeholder': 'z.B. Nordroute',
|
||||||
|
'transport.transport_type': 'Transportart',
|
||||||
|
'transport.driver_name': 'Fahrer',
|
||||||
|
'transport.driver_name_placeholder': 'Name des Fahrers',
|
||||||
|
'transport.driver_phone': 'Telefon Fahrer',
|
||||||
|
'transport.notes': 'Notizen',
|
||||||
|
'transport.notes_placeholder': 'Zusätzliche Hinweise...',
|
||||||
|
'transport.edit_assignment': 'Zuweisung bearbeiten',
|
||||||
|
'transport.edit_assignment_desc': 'Bearbeiten Sie die Transportzuweisung',
|
||||||
|
'transport.assign_transport_desc': 'Weisen Sie einem Schüler eine Transportart zu',
|
||||||
|
'transport.student_id': 'Schüler-ID',
|
||||||
|
'transport.student_id_placeholder': 'Schüler-ID eingeben',
|
||||||
|
'transport.assign_route': 'Route zuweisen',
|
||||||
|
'transport.no_route': 'Keine Route',
|
||||||
|
'transport.stop_name': 'Haltestelle',
|
||||||
|
'transport.stop_name_placeholder': 'z.B. Hauptbahnhof',
|
||||||
|
'transport.pickup_time': 'Abholzeit',
|
||||||
|
'transport.dropoff_time': 'Absetzzeit',
|
||||||
|
'transport.distance_km': 'Entfernung (km)',
|
||||||
|
'transport.add_stop_to': 'Haltestelle zu Route {route} hinzufügen',
|
||||||
|
'transport.add_stop_desc': 'Fügen Sie eine neue Haltestelle zur Route hinzu',
|
||||||
|
'transport.stop_order': 'Reihenfolge',
|
||||||
|
'transport.stop_order_auto': 'Automatisch',
|
||||||
|
'transport.address': 'Adresse',
|
||||||
|
'transport.address_placeholder': 'Straße, PLZ Ort',
|
||||||
|
'transport.route_detail_desc': 'Detaillierte Informationen zur Route',
|
||||||
|
'transport.driver_info': 'Fahrerinformationen',
|
||||||
|
'transport.utilization': 'Auslastung',
|
||||||
|
'transport.stops_timeline': 'Haltestellen-Fahrplan',
|
||||||
|
'transport.assigned_students': 'Zugewiesene Schüler',
|
||||||
|
'transport.confirm_delete': 'Löschen bestätigen',
|
||||||
|
'transport.confirm_delete_desc': 'Möchten Sie dieses Element wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||||
|
'transport.bulk_assign_desc': 'Weisen Sie mehreren Schülern gleichzeitig eine Transportart zu',
|
||||||
|
'transport.student_ids': 'Schüler-IDs',
|
||||||
|
'transport.student_ids_placeholder': 'Eine ID pro Zeile',
|
||||||
|
'transport.student_ids_help': 'Geben Sie eine Schüler-ID pro Zeile ein',
|
||||||
|
'transport.assign': 'Zuweisen',
|
||||||
|
'transport.type_bus': 'Bus',
|
||||||
|
'transport.type_tram': 'Straßenbahn',
|
||||||
|
'transport.type_train': 'Zug',
|
||||||
|
'transport.type_walk': 'Zu Fuß',
|
||||||
|
'transport.type_car': 'Auto',
|
||||||
|
'transport.type_bike': 'Fahrrad',
|
||||||
|
'transport.type_other': 'Sonstige',
|
||||||
|
'transport.status': 'Status',
|
||||||
|
|
||||||
|
// ─── Grade Analytics ──────────────────────────────────────────────
|
||||||
|
'nav.grade-analytics': 'Notenanalyse',
|
||||||
|
'grade_analytics.title': 'Notenanalyse & Berichte',
|
||||||
|
'grade_analytics.subtitle': 'Umfassende Analyse der Notenverteilung, Schülerleistung und Klassenvergleiche auf der deutschen Notenskala (1-6).',
|
||||||
|
'grade_analytics.loading': 'Notenanalyse wird geladen...',
|
||||||
|
'grade_analytics.total_grades': 'Gesamtnoten',
|
||||||
|
'grade_analytics.average_grade': 'Durchschnittsnote',
|
||||||
|
'grade_analytics.finalized': 'Finalisiert',
|
||||||
|
'grade_analytics.risk_students': 'Risikoschüler',
|
||||||
|
'grade_analytics.filters': 'Filter',
|
||||||
|
'grade_analytics.all_classes': 'Alle Klassen',
|
||||||
|
'grade_analytics.all_subjects': 'Alle Fächer',
|
||||||
|
'grade_analytics.distribution': 'Verteilung',
|
||||||
|
'grade_analytics.performance': 'Leistung',
|
||||||
|
'grade_analytics.class_comparison': 'Klassenvergleich',
|
||||||
|
'grade_analytics.subject_analysis': 'Fachanalyse',
|
||||||
|
'grade_analytics.trends': 'Trends',
|
||||||
|
'grade_analytics.reports': 'Berichte',
|
||||||
|
'grade_analytics.grade_distribution': 'Notenverteilung',
|
||||||
|
'grade_analytics.distribution_desc': 'Verteilung der Noten auf der deutschen Skala',
|
||||||
|
'grade_analytics.scale': 'Skala',
|
||||||
|
'grade_analytics.count': 'Anzahl',
|
||||||
|
'grade_analytics.bell_curve': 'Glockenkurve',
|
||||||
|
'grade_analytics.mean': 'Mittelwert',
|
||||||
|
'grade_analytics.std_dev': 'Standardabweichung',
|
||||||
|
'grade_analytics.grade': 'Note',
|
||||||
|
'grade_analytics.view_overall': 'Gesamt',
|
||||||
|
'grade_analytics.view_subject': 'Nach Fach',
|
||||||
|
'grade_analytics.view_class': 'Nach Klasse',
|
||||||
|
'grade_analytics.distribution_by_subject': 'Notenverteilung nach Fach',
|
||||||
|
'grade_analytics.distribution_by_subject_desc': 'Vergleich der Notenverteilung zwischen verschiedenen Fächern',
|
||||||
|
'grade_analytics.distribution_by_class': 'Notenverteilung nach Klasse',
|
||||||
|
'grade_analytics.distribution_by_class_desc': 'Vergleich der Notenverteilung zwischen verschiedenen Klassen',
|
||||||
|
'grade_analytics.top_performers': 'Beste Schüler',
|
||||||
|
'grade_analytics.top_performers_desc': 'Schüler mit dem niedrigsten Notendurchschnitt (beste Leistung)',
|
||||||
|
'grade_analytics.bottom_performers': 'Schwächste Schüler',
|
||||||
|
'grade_analytics.bottom_performers_desc': 'Schüler mit dem höchsten Notendurchschnitt (schwachste Leistung)',
|
||||||
|
'grade_analytics.grades': 'Noten',
|
||||||
|
'grade_analytics.no_data': 'Keine Daten verfügbar',
|
||||||
|
'grade_analytics.risk_students_title': 'Risikoschüler',
|
||||||
|
'grade_analytics.risk_students_desc': 'Schüler mit einem Durchschnitt von 4,5 oder schlechter (tendieren zum Durchfallen)',
|
||||||
|
'grade_analytics.at_risk': 'Gefährdet',
|
||||||
|
'grade_analytics.no_risk_students': 'Keine Risikoschüler erkannt',
|
||||||
|
'grade_analytics.improvement_detection': 'Verbesserungs-/Verschlechterungserkennung',
|
||||||
|
'grade_analytics.improvement_detection_desc': 'Schüler mit signifikanten Notenveränderungen über die Zeit',
|
||||||
|
'grade_analytics.first_grade': 'Erste Note',
|
||||||
|
'grade_analytics.last_grade': 'Letzte Note',
|
||||||
|
'grade_analytics.improving': 'Verbesserung',
|
||||||
|
'grade_analytics.regressing': 'Verschlechterung',
|
||||||
|
'grade_analytics.percentile_ranking': 'Perzentil-Rangfolge',
|
||||||
|
'grade_analytics.percentile_ranking_desc': 'Rangfolge der Schüler nach Perzentil basierend auf dem Notendurchschnitt',
|
||||||
|
'grade_analytics.percentile': 'Perzentil',
|
||||||
|
'grade_analytics.class_average_comparison': 'Klassen-Durchschnittsvergleich',
|
||||||
|
'grade_analytics.class_average_comparison_desc': 'Vergleich der durchschnittlichen Noten zwischen den Klassen',
|
||||||
|
'grade_analytics.average': 'Durchschnitt',
|
||||||
|
'grade_analytics.median': 'Median',
|
||||||
|
'grade_analytics.class_ranking': 'Klassenrangliste',
|
||||||
|
'grade_analytics.class_ranking_desc': 'Rangfolge der Klassen nach Notendurchschnitt (niedriger = besser)',
|
||||||
|
'grade_analytics.anonymized': 'Anonymisiert',
|
||||||
|
'grade_analytics.grade_level': 'Klassenstufe',
|
||||||
|
'grade_analytics.excellent': 'Ausgezeichnet',
|
||||||
|
'grade_analytics.good': 'Gut',
|
||||||
|
'grade_analytics.satisfactory': 'Befriedigend',
|
||||||
|
'grade_analytics.needs_improvement': 'Verbesserungsbedarf',
|
||||||
|
'grade_analytics.subject_comparison_across_classes': 'Fachvergleich über Klassen',
|
||||||
|
'grade_analytics.subject_comparison_across_classes_desc': 'Vergleich der Fachnoten zwischen verschiedenen Klassen',
|
||||||
|
'grade_analytics.teacher_comparison': 'Lehrervergleich',
|
||||||
|
'grade_analytics.teacher_comparison_desc': 'Vergleich der Schülerleistung nach Lehrer (nur für Administratoren)',
|
||||||
|
'grade_analytics.subject_difficulty_ranking': 'Fachschwierigkeitsrangliste',
|
||||||
|
'grade_analytics.subject_difficulty_ranking_desc': 'Rangfolge der Fächer nach durchschnittlicher Note (höher = schwieriger)',
|
||||||
|
'grade_analytics.average_grade_short': 'Durchschnittsnote',
|
||||||
|
'grade_analytics.variance': 'Varianz',
|
||||||
|
'grade_analytics.subject_performance_radar': 'Fachleistungsradar',
|
||||||
|
'grade_analytics.subject_performance_radar_desc': 'Radardiagramm der Fachleistung (höher = bessere Leistung)',
|
||||||
|
'grade_analytics.performance': 'Leistung',
|
||||||
|
'grade_analytics.cross_subject_correlation': 'Fachübergreifende Korrelation',
|
||||||
|
'grade_analytics.cross_subject_correlation_desc': 'Korrelationsanalyse zwischen verschiedenen Fächern',
|
||||||
|
'grade_analytics.subject_recommendations': 'Fachempfehlungen',
|
||||||
|
'grade_analytics.subject_recommendations_desc': 'Automatische Empfehlungen basierend auf der Fachanalyse',
|
||||||
|
'grade_analytics.grade_trends_over_time': 'Notentrends über die Zeit',
|
||||||
|
'grade_analytics.grade_trends_over_time_desc': 'Entwicklung der durchschnittlichen Noten über die Zeit',
|
||||||
|
'grade_analytics.assessment_count': 'Anzahl Bewertungen',
|
||||||
|
'grade_analytics.no_trend_data': 'Keine Trenddaten verfügbar',
|
||||||
|
'grade_analytics.student_trajectories': 'Schülerverläufe',
|
||||||
|
'grade_analytics.student_trajectories_desc': 'Notenverlauf einzelner Schüler über die Zeit',
|
||||||
|
'grade_analytics.report_templates': 'Berichtsvorlagen',
|
||||||
|
'grade_analytics.report_templates_desc': 'Wählen Sie eine Vorlage zur schnellen Berichtserstellung',
|
||||||
|
'grade_analytics.quarterly': 'Quartalsbericht',
|
||||||
|
'grade_analytics.quarterly_desc': 'Notenanalyse für ein Quartal',
|
||||||
|
'grade_analytics.semester': 'Semesterbericht',
|
||||||
|
'grade_analytics.semester_desc': 'Umfassende Semesteranalyse',
|
||||||
|
'grade_analytics.annual': 'Jahresbericht',
|
||||||
|
'grade_analytics.annual_desc': 'Vollständiger Jahresbericht',
|
||||||
|
'grade_analytics.custom': 'Benutzerdefiniert',
|
||||||
|
'grade_analytics.custom_desc': 'Erstellen Sie einen individuellen Bericht',
|
||||||
|
'grade_analytics.generated_reports': 'Erstellte Berichte',
|
||||||
|
'grade_analytics.generated_reports_desc': 'Liste aller erstellten Berichte',
|
||||||
|
'grade_analytics.no_reports': 'Noch keine Berichte erstellt',
|
||||||
|
'grade_analytics.generate_report': 'Bericht erstellen',
|
||||||
|
'grade_analytics.report_builder': 'Berichtsgenerator',
|
||||||
|
'grade_analytics.report_title': 'Berichtstitel',
|
||||||
|
'grade_analytics.report_title_placeholder': 'Berichtstitel eingeben...',
|
||||||
|
'grade_analytics.report_type': 'Berichtstyp',
|
||||||
|
'grade_analytics.date_from': 'Von Datum',
|
||||||
|
'grade_analytics.date_to': 'Bis Datum',
|
||||||
|
'grade_analytics.report_metrics': 'Berichtsmetriken',
|
||||||
|
'grade_analytics.metric_distribution': 'Notenverteilung',
|
||||||
|
'grade_analytics.metric_averages': 'Durchschnitte',
|
||||||
|
'grade_analytics.metric_class_comparison': 'Klassenvergleich',
|
||||||
|
'grade_analytics.metric_subject_analysis': 'Fachanalyse',
|
||||||
|
'grade_analytics.metric_student_grades': 'Schülernoten',
|
||||||
|
'grade_analytics.metric_trends': 'Trends',
|
||||||
|
'grade_analytics.generating': 'Wird erstellt...',
|
||||||
|
'grade_analytics.generate': 'Erstellen',
|
||||||
|
'grade_analytics.completed': 'Abgeschlossen',
|
||||||
|
'grade_analytics.pending': 'Ausstehend',
|
||||||
|
'grade_analytics.failed': 'Fehlgeschlagen',
|
||||||
};
|
};
|
||||||
|
|
||||||
const en: TranslationDict = {
|
const en: TranslationDict = {
|
||||||
@@ -12638,6 +12968,336 @@ const en: TranslationDict = {
|
|||||||
'newsletter.engagement_distribution': 'Engagement Distribution',
|
'newsletter.engagement_distribution': 'Engagement Distribution',
|
||||||
'newsletter.recent_performance': 'Recent Performance',
|
'newsletter.recent_performance': 'Recent Performance',
|
||||||
'newsletter.subscriber_growth': 'Subscriber Growth',
|
'newsletter.subscriber_growth': 'Subscriber Growth',
|
||||||
|
|
||||||
|
// ─── Peer Assessment ──────────────────────────────────────────────
|
||||||
|
'nav.peer-assessment': 'Peer Assessment',
|
||||||
|
'pa.title': 'Peer Assessment',
|
||||||
|
'pa.subtitle': 'Students assess students — structured and fair',
|
||||||
|
'pa.tab_dashboard': 'Dashboard',
|
||||||
|
'pa.tab_sessions': 'Sessions',
|
||||||
|
'pa.tab_create': 'Create',
|
||||||
|
'pa.tab_conduct': 'Conduct',
|
||||||
|
'pa.tab_results': 'Results',
|
||||||
|
'pa.new_session': 'New Session',
|
||||||
|
'pa.stat_total_sessions': 'Total Sessions',
|
||||||
|
'pa.stat_active': 'Active',
|
||||||
|
'pa.stat_completed': 'Completed',
|
||||||
|
'pa.stat_completion_rate': 'Completion Rate',
|
||||||
|
'pa.sessions_overview': 'Sessions Overview',
|
||||||
|
'pa.no_sessions': 'No peer assessments created yet.',
|
||||||
|
'pa.create_first': 'Create First',
|
||||||
|
'pa.recent_activity': 'Recent Activity',
|
||||||
|
'pa.no_activity': 'No activities available.',
|
||||||
|
'pa.avg_ratings_by_type': 'Average Ratings by Type',
|
||||||
|
'pa.submitted': 'submitted',
|
||||||
|
'pa.search_sessions': 'Search sessions...',
|
||||||
|
'pa.all_statuses': 'All Statuses',
|
||||||
|
'pa.all_types': 'All Types',
|
||||||
|
'pa.no_sessions_found': 'No sessions found.',
|
||||||
|
'pa.close_session': 'Close Session',
|
||||||
|
'pa.delete_session': 'Delete Session',
|
||||||
|
'pa.progress': 'Progress',
|
||||||
|
'pa.view_details': 'Details',
|
||||||
|
'pa.view_results': 'Results',
|
||||||
|
'pa.create_session': 'Create Session',
|
||||||
|
'pa.create_session_desc': 'Create a new peer assessment session with criteria and settings.',
|
||||||
|
'pa.session_title': 'Title',
|
||||||
|
'pa.session_title_placeholder': 'e.g. Project Presentation Class 8a',
|
||||||
|
'pa.assessment_type': 'Assessment Type',
|
||||||
|
'pa.type_competency': 'Competency',
|
||||||
|
'pa.type_project': 'Project',
|
||||||
|
'pa.type_presentation': 'Presentation',
|
||||||
|
'pa.type_teamwork': 'Teamwork',
|
||||||
|
'pa.description': 'Description',
|
||||||
|
'pa.description_placeholder': 'Description of the peer assessment...',
|
||||||
|
'pa.settings': 'Settings',
|
||||||
|
'pa.anonymity_mode': 'Anonymity Mode',
|
||||||
|
'pa.anonymous': 'Anonymous',
|
||||||
|
'pa.semi_anonymous': 'Semi-anonymous',
|
||||||
|
'pa.open': 'Open',
|
||||||
|
'pa.anonymous_desc': 'Assessor and assessed remain anonymous.',
|
||||||
|
'pa.semi_anonymous_desc': 'Assessor is anonymous, assessed is visible.',
|
||||||
|
'pa.open_desc': 'All names are visible.',
|
||||||
|
'pa.assign_mode': 'Assignment Mode',
|
||||||
|
'pa.assign_manual': 'Manual',
|
||||||
|
'pa.assign_random': 'Random',
|
||||||
|
'pa.assign_group': 'Group-based',
|
||||||
|
'pa.deadline': 'Deadline',
|
||||||
|
'pa.criteria_builder': 'Criteria Builder',
|
||||||
|
'pa.add_criterion': 'Add Criterion',
|
||||||
|
'pa.criterion_name': 'Name',
|
||||||
|
'pa.criterion_name_placeholder': 'e.g. Subject Knowledge',
|
||||||
|
'pa.criterion_desc': 'Description',
|
||||||
|
'pa.criterion_desc_placeholder': 'Description of the criterion...',
|
||||||
|
'pa.max_score': 'Max Score',
|
||||||
|
'pa.select_session': 'Select a session to conduct an assessment.',
|
||||||
|
'pa.go_to_sessions': 'Go to Sessions',
|
||||||
|
'pa.criteria': 'Criteria',
|
||||||
|
'pa.points': 'Points',
|
||||||
|
'pa.assessment_list': 'Assessment List',
|
||||||
|
'pa.no_assessments': 'No assessments in this session.',
|
||||||
|
'pa.assessed_by': 'Assessed by',
|
||||||
|
'pa.anonymous_mode': 'Anonymous Mode',
|
||||||
|
'pa.anonymous_assessor': 'Anonymous Assessor',
|
||||||
|
'pa.rate': 'Rate',
|
||||||
|
'pa.rate_peer': 'Rate Peer',
|
||||||
|
'pa.rating_for': 'Rating for',
|
||||||
|
'pa.anonymous_mode_active': 'Anonymous mode active — your assessment is anonymous.',
|
||||||
|
'pa.criteria_ratings': 'Criteria Ratings',
|
||||||
|
'pa.overall_rating': 'Overall Rating',
|
||||||
|
'pa.feedback_comment': 'Feedback Comment',
|
||||||
|
'pa.comment_placeholder': 'Write your feedback here...',
|
||||||
|
'pa.submit_assessment': 'Submit Assessment',
|
||||||
|
'pa.select_session_results': 'Select session for results',
|
||||||
|
'pa.no_sessions_for_results': 'No sessions available for results.',
|
||||||
|
'pa.total_assessments': 'Total Assessments',
|
||||||
|
'pa.overall_average': 'Overall Average',
|
||||||
|
'pa.std_deviation': 'Standard Deviation',
|
||||||
|
'pa.unique_assessors': 'Unique Assessors',
|
||||||
|
'pa.unique_assessed': 'Unique Assessed',
|
||||||
|
'pa.competency_radar': 'Competency Radar',
|
||||||
|
'pa.average_score': 'Average',
|
||||||
|
'pa.student_results': 'Student Results',
|
||||||
|
'pa.no_results': 'No results available.',
|
||||||
|
'pa.assessments_received': 'assessments received',
|
||||||
|
'pa.more_comments': 'more comments',
|
||||||
|
'pa.self_vs_peer': 'Self vs Peer Assessment',
|
||||||
|
'pa.peer_avg': 'Peer Average',
|
||||||
|
'pa.self_avg': 'Self Assessment',
|
||||||
|
'pa.outliers_detected': 'Outliers Detected',
|
||||||
|
'pa.rated_by': 'Rated by',
|
||||||
|
'pa.ratings': 'ratings',
|
||||||
|
'pa.results_title': 'Results',
|
||||||
|
'pa.status_active': 'Active',
|
||||||
|
'pa.status_completed': 'Completed',
|
||||||
|
'pa.status_closed': 'Closed',
|
||||||
|
'pa.status_pending': 'Pending',
|
||||||
|
'pa.status_submitted': 'Submitted',
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
'nav.school-transport': 'School Transport',
|
||||||
|
|
||||||
|
// Transport
|
||||||
|
'transport.title': 'School Transport & Bus Routes',
|
||||||
|
'transport.subtitle': 'Manage student transport, routes and logistics',
|
||||||
|
'transport.total_students': 'Total Students',
|
||||||
|
'transport.active_routes': 'Active Routes',
|
||||||
|
'transport.total_capacity': 'Total Capacity',
|
||||||
|
'transport.avg_distance': 'Avg Distance',
|
||||||
|
'transport.over_capacity_warning': 'route(s) over capacity',
|
||||||
|
'transport.over_capacity_desc': 'One or more routes have more students assigned than available seats.',
|
||||||
|
'transport.type_distribution': 'Transport Type Distribution',
|
||||||
|
'transport.type_distribution_desc': 'Distribution of students by transport type',
|
||||||
|
'transport.route_capacity': 'Route Capacity',
|
||||||
|
'transport.route_capacity_desc': 'Occupancy vs. capacity of active routes',
|
||||||
|
'transport.assigned': 'Assigned',
|
||||||
|
'transport.capacity': 'Capacity',
|
||||||
|
'transport.type_summary': 'Transport Type Summary',
|
||||||
|
'transport.quick_actions': 'Quick Actions',
|
||||||
|
'transport.add_route': 'Add Route',
|
||||||
|
'transport.assign_transport': 'Assign Transport',
|
||||||
|
'transport.bulk_assign': 'Bulk Assign',
|
||||||
|
'transport.search_routes': 'Search routes...',
|
||||||
|
'transport.filter_type': 'Transport Type',
|
||||||
|
'transport.filter_status': 'Status',
|
||||||
|
'transport.all_types': 'All Types',
|
||||||
|
'transport.all_status': 'All Status',
|
||||||
|
'transport.active': 'Active',
|
||||||
|
'transport.inactive': 'Inactive',
|
||||||
|
'transport.no_routes': 'No routes found',
|
||||||
|
'transport.create_first_route': 'Create First Route',
|
||||||
|
'transport.stops_count': '{count} stops',
|
||||||
|
'transport.add_stop': 'Add Stop',
|
||||||
|
'transport.no_data': 'No data available yet',
|
||||||
|
'transport.no_routes_data': 'No routes with data available',
|
||||||
|
'transport.search_students': 'Search students...',
|
||||||
|
'transport.no_assignments': 'No transport assignments found',
|
||||||
|
'transport.student': 'Student',
|
||||||
|
'transport.type': 'Type',
|
||||||
|
'transport.route': 'Route',
|
||||||
|
'transport.stop': 'Stop',
|
||||||
|
'transport.pickup': 'Pickup',
|
||||||
|
'transport.dropoff': 'Dropoff',
|
||||||
|
'transport.distance': 'Distance',
|
||||||
|
'transport.actions': 'Actions',
|
||||||
|
'transport.no_schedule': 'No schedule available',
|
||||||
|
'transport.no_schedule_desc': 'Create routes with stops to see the schedule.',
|
||||||
|
'transport.my_transport': 'My Transport',
|
||||||
|
'transport.my_route': 'My Route',
|
||||||
|
'transport.no_personal_transport': 'No transport information available',
|
||||||
|
'transport.tab_overview': 'Overview',
|
||||||
|
'transport.tab_routes': 'Routes',
|
||||||
|
'transport.tab_assignments': 'Assignments',
|
||||||
|
'transport.tab_schedule': 'Schedule',
|
||||||
|
'transport.edit_route': 'Edit Route',
|
||||||
|
'transport.create_route': 'Create Route',
|
||||||
|
'transport.edit_route_desc': 'Edit the route details',
|
||||||
|
'transport.create_route_desc': 'Create a new transport route',
|
||||||
|
'transport.route_number': 'Route Number',
|
||||||
|
'transport.route_name': 'Route Name',
|
||||||
|
'transport.route_name_placeholder': 'e.g. North Route',
|
||||||
|
'transport.transport_type': 'Transport Type',
|
||||||
|
'transport.driver_name': 'Driver',
|
||||||
|
'transport.driver_name_placeholder': 'Driver name',
|
||||||
|
'transport.driver_phone': 'Driver Phone',
|
||||||
|
'transport.notes': 'Notes',
|
||||||
|
'transport.notes_placeholder': 'Additional notes...',
|
||||||
|
'transport.edit_assignment': 'Edit Assignment',
|
||||||
|
'transport.edit_assignment_desc': 'Edit the transport assignment',
|
||||||
|
'transport.assign_transport_desc': 'Assign a transport type to a student',
|
||||||
|
'transport.student_id': 'Student ID',
|
||||||
|
'transport.student_id_placeholder': 'Enter student ID',
|
||||||
|
'transport.assign_route': 'Assign Route',
|
||||||
|
'transport.no_route': 'No Route',
|
||||||
|
'transport.stop_name': 'Stop Name',
|
||||||
|
'transport.stop_name_placeholder': 'e.g. Central Station',
|
||||||
|
'transport.pickup_time': 'Pickup Time',
|
||||||
|
'transport.dropoff_time': 'Dropoff Time',
|
||||||
|
'transport.distance_km': 'Distance (km)',
|
||||||
|
'transport.add_stop_to': 'Add Stop to Route {route}',
|
||||||
|
'transport.add_stop_desc': 'Add a new stop to the route',
|
||||||
|
'transport.stop_order': 'Order',
|
||||||
|
'transport.stop_order_auto': 'Auto',
|
||||||
|
'transport.address': 'Address',
|
||||||
|
'transport.address_placeholder': 'Street, ZIP City',
|
||||||
|
'transport.route_detail_desc': 'Detailed route information',
|
||||||
|
'transport.driver_info': 'Driver Information',
|
||||||
|
'transport.utilization': 'Utilization',
|
||||||
|
'transport.stops_timeline': 'Stops Timeline',
|
||||||
|
'transport.assigned_students': 'Assigned Students',
|
||||||
|
'transport.confirm_delete': 'Confirm Delete',
|
||||||
|
'transport.confirm_delete_desc': 'Are you sure you want to delete this item? This action cannot be undone.',
|
||||||
|
'transport.bulk_assign_desc': 'Assign transport to multiple students at once',
|
||||||
|
'transport.student_ids': 'Student IDs',
|
||||||
|
'transport.student_ids_placeholder': 'One ID per line',
|
||||||
|
'transport.student_ids_help': 'Enter one student ID per line',
|
||||||
|
'transport.assign': 'Assign',
|
||||||
|
'transport.type_bus': 'Bus',
|
||||||
|
'transport.type_tram': 'Tram',
|
||||||
|
'transport.type_train': 'Train',
|
||||||
|
'transport.type_walk': 'Walk',
|
||||||
|
'transport.type_car': 'Car',
|
||||||
|
'transport.type_bike': 'Bike',
|
||||||
|
'transport.type_other': 'Other',
|
||||||
|
'transport.status': 'Status',
|
||||||
|
|
||||||
|
// ─── Grade Analytics ──────────────────────────────────────────────
|
||||||
|
'nav.grade-analytics': 'Grade Analytics',
|
||||||
|
'grade_analytics.title': 'Grade Analytics & Reporting',
|
||||||
|
'grade_analytics.subtitle': 'Comprehensive analysis of grade distribution, student performance, and class comparisons on the German grading scale (1-6).',
|
||||||
|
'grade_analytics.loading': 'Loading grade analytics...',
|
||||||
|
'grade_analytics.total_grades': 'Total Grades',
|
||||||
|
'grade_analytics.average_grade': 'Average Grade',
|
||||||
|
'grade_analytics.finalized': 'Finalized',
|
||||||
|
'grade_analytics.risk_students': 'Risk Students',
|
||||||
|
'grade_analytics.filters': 'Filters',
|
||||||
|
'grade_analytics.all_classes': 'All Classes',
|
||||||
|
'grade_analytics.all_subjects': 'All Subjects',
|
||||||
|
'grade_analytics.distribution': 'Distribution',
|
||||||
|
'grade_analytics.performance': 'Performance',
|
||||||
|
'grade_analytics.class_comparison': 'Class Comparison',
|
||||||
|
'grade_analytics.subject_analysis': 'Subject Analysis',
|
||||||
|
'grade_analytics.trends': 'Trends',
|
||||||
|
'grade_analytics.reports': 'Reports',
|
||||||
|
'grade_analytics.grade_distribution': 'Grade Distribution',
|
||||||
|
'grade_analytics.distribution_desc': 'Distribution of grades on the German scale',
|
||||||
|
'grade_analytics.scale': 'scale',
|
||||||
|
'grade_analytics.count': 'Count',
|
||||||
|
'grade_analytics.bell_curve': 'Bell Curve',
|
||||||
|
'grade_analytics.mean': 'Mean',
|
||||||
|
'grade_analytics.std_dev': 'Std Dev',
|
||||||
|
'grade_analytics.grade': 'Grade',
|
||||||
|
'grade_analytics.view_overall': 'Overall',
|
||||||
|
'grade_analytics.view_subject': 'By Subject',
|
||||||
|
'grade_analytics.view_class': 'By Class',
|
||||||
|
'grade_analytics.distribution_by_subject': 'Grade Distribution by Subject',
|
||||||
|
'grade_analytics.distribution_by_subject_desc': 'Comparison of grade distribution across subjects',
|
||||||
|
'grade_analytics.distribution_by_class': 'Grade Distribution by Class',
|
||||||
|
'grade_analytics.distribution_by_class_desc': 'Comparison of grade distribution across classes',
|
||||||
|
'grade_analytics.top_performers': 'Top Performers',
|
||||||
|
'grade_analytics.top_performers_desc': 'Students with the lowest grade average (best performance)',
|
||||||
|
'grade_analytics.bottom_performers': 'Bottom Performers',
|
||||||
|
'grade_analytics.bottom_performers_desc': 'Students with the highest grade average (weakest performance)',
|
||||||
|
'grade_analytics.grades': 'grades',
|
||||||
|
'grade_analytics.no_data': 'No data available',
|
||||||
|
'grade_analytics.risk_students_title': 'Risk Students',
|
||||||
|
'grade_analytics.risk_students_desc': 'Students with an average of 4.5 or worse (trending towards failing)',
|
||||||
|
'grade_analytics.at_risk': 'At Risk',
|
||||||
|
'grade_analytics.no_risk_students': 'No risk students detected',
|
||||||
|
'grade_analytics.improvement_detection': 'Improvement/Regression Detection',
|
||||||
|
'grade_analytics.improvement_detection_desc': 'Students with significant grade changes over time',
|
||||||
|
'grade_analytics.first_grade': 'First Grade',
|
||||||
|
'grade_analytics.last_grade': 'Last Grade',
|
||||||
|
'grade_analytics.improving': 'Improving',
|
||||||
|
'grade_analytics.regressing': 'Regressing',
|
||||||
|
'grade_analytics.percentile_ranking': 'Percentile Ranking',
|
||||||
|
'grade_analytics.percentile_ranking_desc': 'Student ranking by percentile based on grade average',
|
||||||
|
'grade_analytics.percentile': 'Percentile',
|
||||||
|
'grade_analytics.class_average_comparison': 'Class Average Comparison',
|
||||||
|
'grade_analytics.class_average_comparison_desc': 'Comparison of average grades between classes',
|
||||||
|
'grade_analytics.average': 'Average',
|
||||||
|
'grade_analytics.median': 'Median',
|
||||||
|
'grade_analytics.class_ranking': 'Class Ranking',
|
||||||
|
'grade_analytics.class_ranking_desc': 'Class ranking by grade average (lower = better)',
|
||||||
|
'grade_analytics.anonymized': 'Anonymized',
|
||||||
|
'grade_analytics.grade_level': 'Grade Level',
|
||||||
|
'grade_analytics.excellent': 'Excellent',
|
||||||
|
'grade_analytics.good': 'Good',
|
||||||
|
'grade_analytics.satisfactory': 'Satisfactory',
|
||||||
|
'grade_analytics.needs_improvement': 'Needs Improvement',
|
||||||
|
'grade_analytics.subject_comparison_across_classes': 'Subject Comparison Across Classes',
|
||||||
|
'grade_analytics.subject_comparison_across_classes_desc': 'Comparison of subject grades across different classes',
|
||||||
|
'grade_analytics.teacher_comparison': 'Teacher Comparison',
|
||||||
|
'grade_analytics.teacher_comparison_desc': 'Comparison of student performance by teacher (admin only)',
|
||||||
|
'grade_analytics.subject_difficulty_ranking': 'Subject Difficulty Ranking',
|
||||||
|
'grade_analytics.subject_difficulty_ranking_desc': 'Subject ranking by average grade (higher = harder)',
|
||||||
|
'grade_analytics.average_grade_short': 'Average Grade',
|
||||||
|
'grade_analytics.variance': 'Variance',
|
||||||
|
'grade_analytics.subject_performance_radar': 'Subject Performance Radar',
|
||||||
|
'grade_analytics.subject_performance_radar_desc': 'Radar chart of subject performance (higher = better)',
|
||||||
|
'grade_analytics.performance': 'Performance',
|
||||||
|
'grade_analytics.cross_subject_correlation': 'Cross-Subject Correlation',
|
||||||
|
'grade_analytics.cross_subject_correlation_desc': 'Correlation analysis between different subjects',
|
||||||
|
'grade_analytics.subject_recommendations': 'Subject Recommendations',
|
||||||
|
'grade_analytics.subject_recommendations_desc': 'Automatic recommendations based on subject analysis',
|
||||||
|
'grade_analytics.grade_trends_over_time': 'Grade Trends Over Time',
|
||||||
|
'grade_analytics.grade_trends_over_time_desc': 'Development of average grades over time',
|
||||||
|
'grade_analytics.assessment_count': 'Assessment Count',
|
||||||
|
'grade_analytics.no_trend_data': 'No trend data available',
|
||||||
|
'grade_analytics.student_trajectories': 'Student Trajectories',
|
||||||
|
'grade_analytics.student_trajectories_desc': 'Grade trajectory of individual students over time',
|
||||||
|
'grade_analytics.report_templates': 'Report Templates',
|
||||||
|
'grade_analytics.report_templates_desc': 'Choose a template for quick report generation',
|
||||||
|
'grade_analytics.quarterly': 'Quarterly',
|
||||||
|
'grade_analytics.quarterly_desc': 'Quarterly grade analysis',
|
||||||
|
'grade_analytics.semester': 'Semester',
|
||||||
|
'grade_analytics.semester_desc': 'Comprehensive semester analysis',
|
||||||
|
'grade_analytics.annual': 'Annual',
|
||||||
|
'grade_analytics.annual_desc': 'Full annual report',
|
||||||
|
'grade_analytics.custom': 'Custom',
|
||||||
|
'grade_analytics.custom_desc': 'Create a custom report',
|
||||||
|
'grade_analytics.generated_reports': 'Generated Reports',
|
||||||
|
'grade_analytics.generated_reports_desc': 'List of all generated reports',
|
||||||
|
'grade_analytics.no_reports': 'No reports generated yet',
|
||||||
|
'grade_analytics.generate_report': 'Generate Report',
|
||||||
|
'grade_analytics.report_builder': 'Report Builder',
|
||||||
|
'grade_analytics.report_title': 'Report Title',
|
||||||
|
'grade_analytics.report_title_placeholder': 'Enter report title...',
|
||||||
|
'grade_analytics.report_type': 'Report Type',
|
||||||
|
'grade_analytics.date_from': 'Date From',
|
||||||
|
'grade_analytics.date_to': 'Date To',
|
||||||
|
'grade_analytics.report_metrics': 'Report Metrics',
|
||||||
|
'grade_analytics.metric_distribution': 'Grade Distribution',
|
||||||
|
'grade_analytics.metric_averages': 'Averages',
|
||||||
|
'grade_analytics.metric_class_comparison': 'Class Comparison',
|
||||||
|
'grade_analytics.metric_subject_analysis': 'Subject Analysis',
|
||||||
|
'grade_analytics.metric_student_grades': 'Student Grades',
|
||||||
|
'grade_analytics.metric_trends': 'Trends',
|
||||||
|
'grade_analytics.generating': 'Generating...',
|
||||||
|
'grade_analytics.generate': 'Generate',
|
||||||
|
'grade_analytics.completed': 'Completed',
|
||||||
|
'grade_analytics.pending': 'Pending',
|
||||||
|
'grade_analytics.failed': 'Failed',
|
||||||
};
|
};
|
||||||
|
|
||||||
const translations: Record<string, TranslationDict> = { de, en };
|
const translations: Record<string, TranslationDict> = { de, en };
|
||||||
|
|||||||
+4
-1
@@ -53,7 +53,10 @@ export type ViewName =
|
|||||||
| 'school-events'
|
| 'school-events'
|
||||||
| 'substitute-teacher'
|
| 'substitute-teacher'
|
||||||
| 'student-career'
|
| 'student-career'
|
||||||
| 'school-newsletter';
|
| 'school-newsletter'
|
||||||
|
| 'school-transport'
|
||||||
|
| 'peer-assessment'
|
||||||
|
| 'grade-analytics';
|
||||||
|
|
||||||
export interface CurrentUser {
|
export interface CurrentUser {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
+309
@@ -1985,3 +1985,312 @@ Unresolved issues / Next phase priorities:
|
|||||||
- Substitute teacher view needs seeded data
|
- Substitute teacher view needs seeded data
|
||||||
- Newsletter view needs seeded data
|
- Newsletter view needs seeded data
|
||||||
- Career view needs seeded data
|
- Career view needs seeded data
|
||||||
|
|
||||||
|
## Task ID: 2-b
|
||||||
|
Agent: School Transport & Bus Routes Builder
|
||||||
|
Task: Build School Transport & Bus Routes View for managing student transport, bus routes, and logistics
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Added TransportRoute and TransportStop models to Prisma schema
|
||||||
|
- Added routeId field and route relation to StudentTransport model
|
||||||
|
- Added transportRoutes relation to School model
|
||||||
|
- Fixed pre-existing schema issues (PeerAssessmentSession and GradeReport named relations)
|
||||||
|
- Ran prisma db push to apply schema changes
|
||||||
|
- Created API route `/api/transport-routes/route.ts` with GET (list routes with filters) and POST (create route)
|
||||||
|
- Created API route `/api/transport-routes/[id]/route.ts` with GET (detail with assignments), PUT (update), DELETE (soft delete)
|
||||||
|
- Created API route `/api/transport-routes/[id]/stops/route.ts` with GET (list stops) and POST (add stop)
|
||||||
|
- Created API route `/api/student-transport/bulk/route.ts` for bulk assignment
|
||||||
|
- Extended existing `/api/student-transport/route.ts` and `/api/student-transport/[id]/route.ts` to include routeId and route relation
|
||||||
|
- Built comprehensive frontend component `school-transport-view.tsx` (1814 lines) with:
|
||||||
|
- Transport Overview Dashboard with animated stat cards (total students, active routes, total capacity, avg distance)
|
||||||
|
- Transport type distribution PieChart (bus, tram, train, walk, car, bike, other)
|
||||||
|
- Route capacity BarChart (assigned vs capacity)
|
||||||
|
- Transport type summary cards with clickable navigation
|
||||||
|
- Quick actions for admin (add route, assign transport, bulk assign)
|
||||||
|
- Bus Route Management with search/filter, route cards with capacity indicator, stop list
|
||||||
|
- Create/edit/delete routes with full form (route number, name, type, driver, capacity, active status, notes)
|
||||||
|
- Student Transport Assignment table with type badges, route info, stop, pickup/dropoff times, distance
|
||||||
|
- Transport Schedule view with timeline per route showing stops with pickup/dropoff times
|
||||||
|
- Student/Parent view showing personal transport info and schedule
|
||||||
|
- Route detail dialog with stops timeline, assigned students, driver info, capacity bar
|
||||||
|
- Add stop dialog with name, order, pickup/dropoff times, address
|
||||||
|
- Bulk assignment dialog for assigning transport to multiple students
|
||||||
|
- Delete confirmation dialog
|
||||||
|
- Over-capacity warning card
|
||||||
|
- Role-based access: ADMIN/VICE_PRINCIPAL full CRUD, TEACHER view class transport, STUDENT/PARENT personal view
|
||||||
|
- Gradient header banner with emerald/teal theme
|
||||||
|
- framer-motion animations, AnimatePresence, animated counters
|
||||||
|
- Responsive design with dark mode support
|
||||||
|
- Added 130+ i18n keys in both German and English (transport.*)
|
||||||
|
- Added 'school-transport' to ViewName in store.ts
|
||||||
|
- Added Bus icon import and navigation entry in app-layout.tsx for all three nav sections (main, student, parent)
|
||||||
|
- Added SchoolTransportView import and case rendering in app-layout.tsx
|
||||||
|
- All lint checks pass
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- Prisma schema: TransportRoute, TransportStop models added; StudentTransport updated with routeId
|
||||||
|
- API routes: 4 new routes created (transport-routes CRUD, stops, bulk assignment)
|
||||||
|
- Frontend: 1814-line component with 5 views (overview, routes, assignments, schedule, student/parent)
|
||||||
|
- i18n: 130+ keys added (65 German + 65 English)
|
||||||
|
- Store: school-transport ViewName added
|
||||||
|
- Navigation: Added to all three nav sections with Bus icon
|
||||||
|
- Transport view needs seeded data for routes and student transport assignments
|
||||||
|
|
||||||
|
## Task ID: 2-d
|
||||||
|
Agent: Styling Enhancer Round 26
|
||||||
|
Task: Professional styling enhancements for classes-view.tsx and attendance-view.tsx
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Added comprehensive CSS utility classes and keyframe animations to globals.css:
|
||||||
|
- `.attendance-gauge` - Circular SVG gauge with track/fill/label sub-components
|
||||||
|
- `.grade-indicator` - Color-coded grade badges (1-6 German scale) with dark mode
|
||||||
|
- `.card-grid-auto` - Auto-filling responsive grid for card layouts
|
||||||
|
- `.animate-count-up` - Number counting animation
|
||||||
|
- `.status-badge` - Status indicator badge with present/late/absent/excused variants
|
||||||
|
- `.glass-card-enhanced` - Enhanced glassmorphism card with hover lift and glow effects
|
||||||
|
- `.gradient-header-banner` - Gradient header banner with emerald-to-teal gradient
|
||||||
|
- `.mini-calendar-day` - Mini calendar date picker with selected/today/has-session states
|
||||||
|
- `.timeline-dot` - Attendance timeline dot with color-coded status
|
||||||
|
- `.quick-action-btn` - Quick action button with hover lift and glow
|
||||||
|
- Keyframe animations: `countUp`, `drawCircle`, `fadeInScale`
|
||||||
|
|
||||||
|
- Enhanced classes-view.tsx:
|
||||||
|
- Added gradient header banner with school branding and animated counts
|
||||||
|
- Added class statistics summary section (4 glass cards: classes, students, subjects, grades)
|
||||||
|
- Added class comparison mini-chart with animated progress bars and grade-level indicators
|
||||||
|
- Added glassmorphism card effects (glass-card-enhanced) on all class cards
|
||||||
|
- Added hover lift effects on class cards with framer-motion
|
||||||
|
- Added animated student count badges with motion.div
|
||||||
|
- Added color-coded grade level indicators (grade-indicator classes 1-6)
|
||||||
|
- Added "Quick Actions" section for each class (Details, Seating)
|
||||||
|
- Added AnimatedCount component for number counting animation
|
||||||
|
- Added AttendanceGauge SVG component for circular rate visualization
|
||||||
|
- Added grade level color mapping (gradeLevelColors, gradeLevelTextColors, gradeLevelBgColors)
|
||||||
|
- Added AnimatePresence import for smooth transitions
|
||||||
|
- Added Activity, Zap, TrendingUp, CircleDot icons from lucide-react
|
||||||
|
|
||||||
|
- Enhanced attendance-view.tsx:
|
||||||
|
- Added gradient header banner with attendance statistics
|
||||||
|
- Added "Today's Attendance" quick view in header with live rate display
|
||||||
|
- Added AttendanceGauge SVG component for circular rate visualization
|
||||||
|
- Added MiniCalendar date picker component with month navigation
|
||||||
|
- Added DailyTimeline component for daily attendance timeline visualization
|
||||||
|
- Added color-coded status indicators using status-badge classes (present=green, late=amber, absent=red, excused=amber)
|
||||||
|
- Added new "Today" tab with mini-calendar, daily timeline, and attendance gauge
|
||||||
|
- Added glassmorphism card effects on session cards, stats cards, and QR tab
|
||||||
|
- Added glass-card-enhanced on session list cards
|
||||||
|
- Replaced rate badge with status-badge variants for color-coded rates
|
||||||
|
- Added CalendarDays, Activity, CircleDot, ChevronLeft icons from lucide-react
|
||||||
|
- Enhanced StatsCards with attendance gauge and glass cards
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- globals.css: 10+ new utility classes, 3 keyframe animations added
|
||||||
|
- classes-view.tsx: Gradient header banner, statistics summary, class comparison mini-chart, glassmorphism cards, grade level indicators, quick actions, animated count badges
|
||||||
|
- attendance-view.tsx: Gradient header banner, attendance gauge SVG, mini-calendar date picker, daily timeline, today's attendance tab, color-coded status badges, glassmorphism cards
|
||||||
|
- All lint checks pass
|
||||||
|
|
||||||
|
## Task ID: 2-a
|
||||||
|
Agent: Grade Analytics & Reporting Builder
|
||||||
|
Task: Build Grade Analytics & Reporting View for CompetenceTrack
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Added GradeReport model to Prisma schema with fields: schoolId, generatedBy, title, type, dateRange, classIds, subjectIds, metrics, status, fileData, createdAt
|
||||||
|
- Added GradeReport relations to School and User models
|
||||||
|
- Fixed PeerAssessmentSession relation issues (added @relation names for ClassGroup and User)
|
||||||
|
- Ran `bunx prisma db push --accept-data-loss` successfully
|
||||||
|
- Created 5 API routes:
|
||||||
|
- `/api/grade-analytics/route.ts` - GET: Overview with total grades, average, distribution, top/bottom performers, risk students, subject difficulty, class averages, teacher comparison
|
||||||
|
- `/api/grade-analytics/distribution/route.ts` - GET: Grade distribution with groupBy (overall/subject/class), bell curve overlay, German 1-6 scale
|
||||||
|
- `/api/grade-analytics/trends/route.ts` - GET: Grade trends over time, student trajectories, improvement/regression detection, percentile calculation
|
||||||
|
- `/api/grade-analytics/comparison/route.ts` - GET: Class/subject/teacher comparison with statistics (mean, median, stdDev), cross-subject correlation matrix, subject recommendations
|
||||||
|
- `/api/grade-analytics/reports/route.ts` - GET: List reports, POST: Generate report with custom metrics, date range, class/subject filters
|
||||||
|
- Built comprehensive `grade-analytics-view.tsx` (1820 lines) with:
|
||||||
|
- Gradient header banner with emerald/teal theme and animated stat counters
|
||||||
|
- 6 tabs: Distribution, Performance, Class Comparison, Subject Analysis, Trends, Reports
|
||||||
|
- Distribution tab: Overall histogram with German 1-6 scale colors, bell curve overlay, by-subject stacked bar chart, by-class stacked bar chart
|
||||||
|
- Performance tab: Top/bottom performers with rankings, risk students with alerts, improvement/regression detection, percentile ranking
|
||||||
|
- Class Comparison tab: Class average bar chart, class ranking with anonymized labels, subject comparison across classes, teacher comparison (admin only)
|
||||||
|
- Subject Analysis tab: Subject difficulty ranking (horizontal bar chart), subject performance radar chart, cross-subject correlation analysis, automatic recommendations
|
||||||
|
- Trends tab: Grade trends over time (line chart), student trajectory multi-line chart
|
||||||
|
- Reports tab: Report templates (quarterly/semester/annual/custom), report builder dialog with metric selection, existing reports list with status badges
|
||||||
|
- Role-based views: ADMIN/VICE_PRINCIPAL see all analytics + teacher comparison, TEACHER sees own class analytics, STUDENT/PARENT see own grades with class average comparison
|
||||||
|
- CSV export, filter controls (class/subject), responsive design, dark mode support
|
||||||
|
- Grade colors: 1=emerald, 2=green, 3=yellow, 4=amber, 5=orange, 6=red
|
||||||
|
- Uses Recharts for all charts (BarChart, LineChart, AreaChart, RadarChart)
|
||||||
|
- Uses framer-motion for animations, shadcn/ui components, Lucide icons
|
||||||
|
- Added 'grade-analytics' to ViewName type in store.ts
|
||||||
|
- Added 115+ i18n keys in both German and English (230 total entries)
|
||||||
|
- Added navigation entry in app-layout.tsx for admin/teacher nav (analysis section), student nav, and parent nav with BarChart3 icon
|
||||||
|
- Added view rendering case in app-layout.tsx
|
||||||
|
- All lint checks pass
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- All files created/modified: 7 files
|
||||||
|
- API routes: 5
|
||||||
|
- Component: 1820 lines
|
||||||
|
- i18n keys: 115+ in both languages
|
||||||
|
- Prisma model: GradeReport added
|
||||||
|
|
||||||
|
## Task ID: 2-c
|
||||||
|
Agent: Peer Assessment View Builder
|
||||||
|
Task: Build Peer Assessment View for managing student peer assessments
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Added PeerAssessmentSession model to Prisma schema with fields: schoolId, teacherId, classGroupId, title, description, assessmentType, criteria (JSON), anonymityMode (anonymous/semi-anonymous/open), status (active/completed/closed), deadline, assignMode (manual/random/group-based), assignedPairs (JSON), isDemo
|
||||||
|
- Extended PeerAssessment model with new fields: sessionId, criteria (JSON), status (pending/submitted)
|
||||||
|
- Added PeerAssessmentSession relations to School, User, and ClassGroup models
|
||||||
|
- Ran db:push to sync schema changes
|
||||||
|
- Created API route `/api/peer-assessment-sessions/route.ts` with GET (list sessions with filters, student filtering) and POST (create session + auto-create PeerAssessment records)
|
||||||
|
- Created API route `/api/peer-assessment-sessions/[id]/route.ts` with GET (detail with peer assessments), PUT (update session), DELETE (soft delete session + assessments)
|
||||||
|
- Created API route `/api/peer-assessment-sessions/[id]/submit/route.ts` with POST (submit peer assessment with criteria scores, auto-complete session when all submitted)
|
||||||
|
- Created API route `/api/peer-assessment-sessions/[id]/results/route.ts` with GET (aggregated results: per-student averages, criteria breakdown, competency radar data, outlier detection, self vs peer comparison)
|
||||||
|
- Updated `/api/peer-assessments/route.ts` with new fields (sessionId, criteria, status) and session relation
|
||||||
|
- Updated `/api/peer-assessments/[id]/route.ts` with new fields support
|
||||||
|
- Built comprehensive frontend component `peer-assessment-view.tsx` (2151 lines) with:
|
||||||
|
- Dashboard tab: Quick stats cards (animated counters), sessions overview, recent activity timeline, average ratings by type
|
||||||
|
- Sessions tab: Searchable/filterable session cards with progress bars, status badges, anonymity indicators, action buttons
|
||||||
|
- Create tab: Full assessment creation wizard with criteria builder, anonymity settings, assignment mode, deadline
|
||||||
|
- Conduct tab: Session detail view with assessment list, criteria preview, rating interface
|
||||||
|
- Results tab: Aggregated results with RadarChart (competency radar), BarChart (self vs peer comparison), student results table, outlier detection
|
||||||
|
- Role-based views: Admin/VP full CRUD, Teacher create/manage, Student conduct assessments, Parent view results
|
||||||
|
- Anonymous mode indicator with visual feedback
|
||||||
|
- Rating sliders with color-coded visual feedback (emerald/yellow/orange/red)
|
||||||
|
- Gradient header banner with emerald/teal theme
|
||||||
|
- Smooth framer-motion animations throughout
|
||||||
|
- Create session dialog, Conduct assessment dialog, Results dialog
|
||||||
|
- Responsive design with dark mode support
|
||||||
|
- Added 103 i18n keys in both German and English (pa.* namespace)
|
||||||
|
- Added 'peer-assessment' to ViewName in store.ts
|
||||||
|
- Added navigation entry in app-layout.tsx for teacher, student, and parent nav sections with UsersRound icon
|
||||||
|
- All lint checks pass
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- Full peer assessment feature with session management, criteria builder, rating interface, and results analytics
|
||||||
|
- PeerAssessmentSession model groups individual PeerAssessment records
|
||||||
|
- API supports role-based CRUD with anonymous mode enforcement
|
||||||
|
- Results endpoint provides aggregated analytics with outlier detection
|
||||||
|
- Frontend supports 5 tabs: Dashboard, Sessions, Create, Conduct, Results
|
||||||
|
- Recharts RadarChart and BarChart for visual analysis
|
||||||
|
- Complete i18n support (DE + EN)
|
||||||
|
- Files created/modified: 9 files
|
||||||
|
- API routes: 6
|
||||||
|
- Component: 2151 lines
|
||||||
|
- i18n keys: 103+ in both languages
|
||||||
|
- Prisma model: PeerAssessmentSession added, PeerAssessment extended
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 2-a
|
||||||
|
Agent: Grade Analytics & Reporting Builder
|
||||||
|
Task: Build Grade Analytics & Reporting View
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Built comprehensive Grade Analytics & Reporting View (grade-analytics-view.tsx, 1820 lines)
|
||||||
|
- Features: Grade distribution, student performance analysis, class comparison, subject analysis, report generation
|
||||||
|
- Created API routes: /api/grade-analytics/route.ts, /api/grade-analytics/distribution/route.ts, /api/grade-analytics/trends/route.ts, /api/grade-analytics/comparison/route.ts, /api/grade-analytics/reports/route.ts
|
||||||
|
- Added GradeReport model to Prisma schema
|
||||||
|
- Added 'grade-analytics' to ViewName type in store.ts
|
||||||
|
- Added i18n keys (DE + EN) for navigation and view content
|
||||||
|
- Added navigation entry in app-layout.tsx with BarChart3 icon
|
||||||
|
- All lint checks pass
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- Grade Analytics view with distribution histogram, student trajectory, class comparison, subject analysis
|
||||||
|
- 5 API route files created
|
||||||
|
- 1 new Prisma model (GradeReport)
|
||||||
|
- German 1-6 scale with color-coded grade indicators
|
||||||
|
- Report generation with templates
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 2-b
|
||||||
|
Agent: School Transport & Bus Routes Builder
|
||||||
|
Task: Build School Transport & Bus Routes View
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Built comprehensive School Transport View (school-transport-view.tsx, 1814 lines)
|
||||||
|
- Features: Transport overview, route management, student transport assignment, transport schedule
|
||||||
|
- Created API routes: /api/transport-routes/route.ts, /api/transport-routes/[id]/route.ts, /api/transport-routes/[id]/stops/route.ts
|
||||||
|
- Added TransportRoute and TransportStop models to Prisma schema
|
||||||
|
- Extended StudentTransport model with routeId relation
|
||||||
|
- Added 'school-transport' to ViewName type in store.ts
|
||||||
|
- Added i18n keys (DE + EN) for navigation and view content
|
||||||
|
- Added navigation entry in app-layout.tsx with Bus icon
|
||||||
|
- Fixed duplicate variable names (formTransportType, formStopName) in the component
|
||||||
|
- All lint checks pass
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- School Transport view with route management, student assignment, schedule
|
||||||
|
- 3 API route files created
|
||||||
|
- 2 new Prisma models (TransportRoute, TransportStop)
|
||||||
|
- Extended StudentTransport with route relation
|
||||||
|
- Fixed duplicate variable name bugs
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 2-c
|
||||||
|
Agent: Peer Assessment View Builder
|
||||||
|
Task: Build Peer Assessment View
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Built comprehensive Peer Assessment View (peer-assessment-view.tsx, 2151 lines)
|
||||||
|
- Features: Assessment dashboard, creation wizard, conduct assessment, results & analysis
|
||||||
|
- Created/extended API routes: /api/peer-assessments/route.ts, /api/peer-assessments/[id]/route.ts, /api/peer-assessments/[id]/submit/route.ts, /api/peer-assessments/[id]/results/route.ts
|
||||||
|
- Extended PeerAssessment model with new fields
|
||||||
|
- Added 'peer-assessment' to ViewName type in store.ts
|
||||||
|
- Added i18n keys (DE + EN) for navigation and view content
|
||||||
|
- Added navigation entry in app-layout.tsx with UsersRound icon
|
||||||
|
- All lint checks pass
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- Peer Assessment view with creation wizard, rating sliders, results radar chart
|
||||||
|
- 4 API route files created/extended
|
||||||
|
- Extended PeerAssessment model
|
||||||
|
- Self vs peer comparison chart
|
||||||
|
- Anonymous mode support
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 2-d
|
||||||
|
Agent: Styling Enhancer Round 26
|
||||||
|
Task: Enhance styling across classes-view, attendance-view, and global CSS
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Enhanced classes-view.tsx with gradient header, glassmorphism cards, hover effects
|
||||||
|
- Enhanced attendance-view.tsx with gradient header, attendance rate gauge, status indicators
|
||||||
|
- Added global CSS utility classes and keyframe animations
|
||||||
|
- All changes pass ESLint validation
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- Classes view: gradient header, glassmorphism cards, animated student count badges
|
||||||
|
- Attendance view: gradient header, attendance rate gauge, color-coded status indicators
|
||||||
|
- Global CSS: attendance-gauge, grade-indicator, card-grid-auto, animate-count-up utilities
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: Round-26
|
||||||
|
Agent: main
|
||||||
|
Task: Round 26 — Grade Analytics, School Transport, Peer Assessment, Styling
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Built 3 new views: grade-analytics, school-transport, peer-assessment
|
||||||
|
- Integrated all views into app-layout.tsx navigation, store, and i18n
|
||||||
|
- Added 3 new Prisma models (GradeReport, TransportRoute, TransportStop)
|
||||||
|
- Extended PeerAssessment and StudentTransport models
|
||||||
|
- Enhanced classes-view and attendance-view styling
|
||||||
|
- Added global CSS utility classes and animations
|
||||||
|
- Fixed duplicate variable name bugs in school-transport-view.tsx
|
||||||
|
- All lint checks pass, dev server running, browser verified all views render correctly
|
||||||
|
- All new views accessible via sidebar navigation
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- 3 new views built (5,785 lines total)
|
||||||
|
- 12+ new API route files
|
||||||
|
- 3 new Prisma models
|
||||||
|
- Enhanced styling on classes and attendance views
|
||||||
|
- All views verified working in browser
|
||||||
|
- 300+ i18n keys added
|
||||||
|
|
||||||
|
Unresolved issues / Next phase priorities:
|
||||||
|
- WebSocket connection timeout errors (Caddy proxy)
|
||||||
|
- Need more schlaukopf.de content cloning
|
||||||
|
- Voice communication support in rooms
|
||||||
|
- More styling improvements across all views
|
||||||
|
- Many views need seeded demo data
|
||||||
|
- Continue schlaukopf.de exercises and questions
|
||||||
|
- Calendar reminders on tablet
|
||||||
Reference in new issue
Block a user