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:
Mustafa Cetin committed 2026-07-30 07:20:05 +00:00
1 parent 3d0d518bb9
commit 7f0810c88f
28 files changed
+10264 -127

No files matched your search

@@ -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 });
}
}
+250
View File
@@ -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 });
}
}
+215
View File
@@ -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: {
select: { id: true, title: true },
},
session: {
select: { id: true, title: true, anonymityMode: true },
},
},
});
@@ -81,6 +84,8 @@ export async function PUT(
classGroupId,
rubricId,
isAnonymous,
criteria,
status,
} = body;
if (level !== undefined && level !== null && (level < 1 || level > 6)) {
@@ -97,6 +102,8 @@ export async function PUT(
...(classGroupId !== undefined && { classGroupId: classGroupId || null }),
...(rubricId !== undefined && { rubricId: rubricId || null }),
...(isAnonymous !== undefined && { isAnonymous }),
...(criteria !== undefined && { criteria: JSON.stringify(criteria) }),
...(status !== undefined && { status }),
},
include: {
assessor: {
+12
View File
@@ -16,6 +16,8 @@ export async function GET(request: Request) {
const competencyId = searchParams.get('competencyId');
const classGroupId = searchParams.get('classGroupId');
const assessmentType = searchParams.get('assessmentType');
const sessionId = searchParams.get('sessionId');
const status = searchParams.get('status');
if (!schoolId) {
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
@@ -35,6 +37,8 @@ export async function GET(request: Request) {
if (competencyId) where.competencyId = competencyId;
if (classGroupId) where.classGroupId = classGroupId;
if (assessmentType) where.assessmentType = assessmentType;
if (sessionId) where.sessionId = sessionId;
if (status) where.status = status;
const assessments = await db.peerAssessment.findMany({
where,
@@ -55,6 +59,9 @@ export async function GET(request: Request) {
rubric: {
select: { id: true, title: true },
},
session: {
select: { id: true, title: true, anonymityMode: true },
},
},
});
@@ -80,10 +87,12 @@ export async function POST(request: Request) {
competencyId,
classGroupId,
assessmentType,
criteria,
level,
comment,
rubricId,
isAnonymous,
sessionId,
isDemo,
} = body;
@@ -113,10 +122,13 @@ export async function POST(request: Request) {
competencyId: competencyId || null,
classGroupId: classGroupId || null,
assessmentType,
criteria: criteria ? JSON.stringify(criteria) : null,
level: level ?? null,
comment: comment || null,
rubricId: rubricId || null,
isAnonymous: isAnonymous ?? false,
sessionId: sessionId || null,
status: 'pending',
isDemo: isDemo ?? false,
},
include: {
@@ -19,6 +19,9 @@ export async function GET(
student: {
select: { id: true, firstName: true, lastName: true },
},
route: {
select: { id: true, routeNumber: true, routeName: true, transportType: true },
},
},
});
@@ -70,6 +73,7 @@ export async function PUT(
driverName,
driverPhone,
distanceKm,
routeId,
notes,
} = body;
@@ -84,12 +88,16 @@ export async function PUT(
...(driverName !== undefined && { driverName: driverName || null }),
...(driverPhone !== undefined && { driverPhone: driverPhone || null }),
...(distanceKm !== undefined && { distanceKm: distanceKm != null ? distanceKm : null }),
...(routeId !== undefined && { routeId: routeId || null }),
...(notes !== undefined && { notes }),
},
include: {
student: {
select: { id: true, firstName: true, lastName: true },
},
route: {
select: { id: true, routeNumber: true, routeName: true, transportType: true },
},
},
});
@@ -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 });
}
}
+8
View File
@@ -35,6 +35,9 @@ export async function GET(request: Request) {
student: {
select: { id: true, firstName: true, lastName: true },
},
route: {
select: { id: true, routeNumber: true, routeName: true, transportType: true },
},
},
});
@@ -64,6 +67,7 @@ export async function POST(request: Request) {
driverName,
driverPhone,
distanceKm,
routeId,
notes,
isDemo,
} = body;
@@ -91,6 +95,7 @@ export async function POST(request: Request) {
driverName: driverName || null,
driverPhone: driverPhone || null,
distanceKm: distanceKm != null ? distanceKm : null,
routeId: routeId || null,
notes: notes || null,
isDemo: isDemo ?? false,
},
@@ -98,6 +103,9 @@ export async function POST(request: Request) {
student: {
select: { id: true, firstName: true, lastName: true },
},
route: {
select: { id: true, routeNumber: true, routeName: true, transportType: true },
},
},
});
+145
View File
@@ -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 });
}
}
+108
View File
@@ -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 });
}
}
+355
View File
@@ -3595,4 +3595,359 @@
background: rgba(24, 24, 27, 0.7);
border: 1px solid rgba(255, 255, 255, 0.06);
}
/* ─── Round 26: Attendance & Classes Enhancement Utilities ─── */
/* Attendance Gauge - Circular SVG gauge */
.attendance-gauge {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.attendance-gauge svg {
transform: rotate(-90deg);
}
.attendance-gauge-track {
fill: none;
stroke: oklch(0.93 0.005 155);
stroke-width: 6;
}
.dark .attendance-gauge-track {
stroke: oklch(0.24 0.02 260);
}
.attendance-gauge-fill {
fill: none;
stroke-width: 6;
stroke-linecap: round;
animation: drawCircle 1.2s ease-out forwards;
transition: stroke-dashoffset 0.6s ease-out;
}
.attendance-gauge-fill.rate-high {
stroke: oklch(0.55 0.17 155);
}
.attendance-gauge-fill.rate-medium {
stroke: oklch(0.72 0.19 85);
}
.attendance-gauge-fill.rate-low {
stroke: oklch(0.577 0.245 27.325);
}
.attendance-gauge-label {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transform: rotate(0deg);
}
/* Grade Indicator - Color-coded grade badge (1-6 German scale) */
.grade-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
height: 1.75rem;
padding: 0 0.375rem;
border-radius: 0.5rem;
font-size: 0.75rem;
font-weight: 700;
line-height: 1;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.grade-indicator:hover {
transform: scale(1.08);
}
.grade-indicator.grade-1 {
background: oklch(0.55 0.17 155 / 0.15);
color: oklch(0.45 0.17 155);
border: 1px solid oklch(0.55 0.17 155 / 0.2);
}
.dark .grade-indicator.grade-1 {
background: oklch(0.65 0.17 155 / 0.15);
color: oklch(0.75 0.17 155);
border-color: oklch(0.65 0.17 155 / 0.25);
}
.grade-indicator.grade-2 {
background: oklch(0.53 0.14 175 / 0.15);
color: oklch(0.43 0.14 175);
border: 1px solid oklch(0.53 0.14 175 / 0.2);
}
.dark .grade-indicator.grade-2 {
background: oklch(0.63 0.14 175 / 0.15);
color: oklch(0.73 0.14 175);
border-color: oklch(0.63 0.14 175 / 0.25);
}
.grade-indicator.grade-3 {
background: oklch(0.72 0.19 85 / 0.15);
color: oklch(0.62 0.19 85);
border: 1px solid oklch(0.72 0.19 85 / 0.2);
}
.dark .grade-indicator.grade-3 {
background: oklch(0.82 0.19 85 / 0.15);
color: oklch(0.82 0.15 85);
border-color: oklch(0.82 0.19 85 / 0.25);
}
.grade-indicator.grade-4 {
background: oklch(0.75 0.15 60 / 0.15);
color: oklch(0.65 0.15 60);
border: 1px solid oklch(0.75 0.15 60 / 0.2);
}
.dark .grade-indicator.grade-4 {
background: oklch(0.85 0.15 60 / 0.15);
color: oklch(0.85 0.12 60);
border-color: oklch(0.85 0.15 60 / 0.25);
}
.grade-indicator.grade-5 {
background: oklch(0.65 0.2 40 / 0.15);
color: oklch(0.55 0.2 40);
border: 1px solid oklch(0.65 0.2 40 / 0.2);
}
.dark .grade-indicator.grade-5 {
background: oklch(0.75 0.2 40 / 0.15);
color: oklch(0.75 0.17 40);
border-color: oklch(0.75 0.2 40 / 0.25);
}
.grade-indicator.grade-6 {
background: oklch(0.577 0.245 27.325 / 0.15);
color: oklch(0.477 0.245 27.325);
border: 1px solid oklch(0.577 0.245 27.325 / 0.2);
}
.dark .grade-indicator.grade-6 {
background: oklch(0.677 0.245 27.325 / 0.15);
color: oklch(0.777 0.2 27.325);
border-color: oklch(0.677 0.245 27.325 / 0.25);
}
/* Card Grid Auto - Auto-filling responsive grid for card layouts */
.card-grid-auto {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
@media (max-width: 640px) {
.card-grid-auto {
grid-template-columns: 1fr;
}
}
/* Animate Count Up - Number counting animation */
.animate-count-up {
animation: countUp 0.8s ease-out forwards;
}
/* Status Badge - Status indicator badge with variants */
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
border: 1px solid transparent;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.status-badge:hover {
transform: scale(1.05);
}
.status-badge-present {
background: oklch(0.55 0.17 155 / 0.12);
color: oklch(0.45 0.17 155);
border-color: oklch(0.55 0.17 155 / 0.2);
}
.dark .status-badge-present {
background: oklch(0.65 0.17 155 / 0.15);
color: oklch(0.75 0.17 155);
border-color: oklch(0.65 0.17 155 / 0.25);
}
.status-badge-late {
background: oklch(0.72 0.19 85 / 0.12);
color: oklch(0.62 0.19 85);
border-color: oklch(0.72 0.19 85 / 0.2);
}
.dark .status-badge-late {
background: oklch(0.82 0.19 85 / 0.15);
color: oklch(0.82 0.15 85);
border-color: oklch(0.82 0.19 85 / 0.25);
}
.status-badge-absent {
background: oklch(0.577 0.245 27.325 / 0.12);
color: oklch(0.477 0.245 27.325);
border-color: oklch(0.577 0.245 27.325 / 0.2);
}
.dark .status-badge-absent {
background: oklch(0.677 0.245 27.325 / 0.15);
color: oklch(0.777 0.2 27.325);
border-color: oklch(0.677 0.245 27.325 / 0.25);
}
.status-badge-excused {
background: oklch(0.72 0.15 85 / 0.12);
color: oklch(0.62 0.15 85);
border-color: oklch(0.72 0.15 85 / 0.2);
}
.dark .status-badge-excused {
background: oklch(0.82 0.15 85 / 0.15);
color: oklch(0.82 0.12 85);
border-color: oklch(0.82 0.15 85 / 0.25);
}
/* Keyframe: countUp - Number counting up */
@keyframes countUp {
0% { opacity: 0; transform: translateY(8px); }
60% { opacity: 1; }
100% { opacity: 1; transform: translateY(0); }
}
/* Keyframe: drawCircle - SVG circle stroke animation */
@keyframes drawCircle {
from { stroke-dashoffset: var(--circumference, 251); }
to { stroke-dashoffset: var(--offset, 0); }
}
/* Keyframe: fadeInScale - Fade in with scale */
@keyframes fadeInScale {
from { opacity: 0; transform: scale(0.92); }
to { opacity: 1; transform: scale(1); }
}
/* Glassmorphism enhanced card */
.glass-card-enhanced {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.4);
box-shadow: 0 4px 24px -4px rgba(16, 185, 129, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
}
.glass-card-enhanced:hover {
transform: translateY(-3px);
box-shadow: 0 12px 32px -8px rgba(16, 185, 129, 0.15), 0 2px 4px rgba(0, 0, 0, 0.06);
border-color: rgba(16, 185, 129, 0.3);
}
.dark .glass-card-enhanced {
background: rgba(24, 24, 27, 0.6);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 4px 24px -4px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
}
.dark .glass-card-enhanced:hover {
transform: translateY(-3px);
box-shadow: 0 12px 32px -8px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(16, 185, 129, 0.12);
border-color: rgba(16, 185, 129, 0.15);
}
/* Gradient header banner */
.gradient-header-banner {
background: linear-gradient(135deg, oklch(0.55 0.17 155 / 0.08) 0%, oklch(0.53 0.14 175 / 0.06) 50%, oklch(0.55 0.17 155 / 0.03) 100%);
border-bottom: 1px solid oklch(0.55 0.17 155 / 0.1);
}
.dark .gradient-header-banner {
background: linear-gradient(135deg, oklch(0.65 0.17 155 / 0.12) 0%, oklch(0.63 0.14 175 / 0.08) 50%, oklch(0.65 0.17 155 / 0.04) 100%);
border-bottom-color: oklch(0.65 0.17 155 / 0.15);
}
/* Mini calendar date picker */
.mini-calendar-day {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.mini-calendar-day:hover {
background: oklch(0.55 0.17 155 / 0.1);
color: oklch(0.35 0.17 155);
}
.dark .mini-calendar-day:hover {
background: oklch(0.65 0.17 155 / 0.15);
color: oklch(0.85 0.17 155);
}
.mini-calendar-day.selected {
background: oklch(0.55 0.17 155);
color: white;
box-shadow: 0 2px 8px -2px oklch(0.55 0.17 155 / 0.4);
}
.dark .mini-calendar-day.selected {
background: oklch(0.65 0.17 155);
}
.mini-calendar-day.today {
border: 1.5px solid oklch(0.55 0.17 155);
font-weight: 700;
}
.dark .mini-calendar-day.today {
border-color: oklch(0.65 0.17 155);
}
.mini-calendar-day.has-session {
position: relative;
}
.mini-calendar-day.has-session::after {
content: '';
position: absolute;
bottom: 2px;
width: 4px;
height: 4px;
border-radius: 50%;
background: oklch(0.55 0.17 155);
}
.dark .mini-calendar-day.has-session::after {
background: oklch(0.65 0.17 155);
}
/* Attendance timeline dot */
.timeline-dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.timeline-dot:hover {
transform: scale(1.3);
}
.dark .timeline-dot {
border-color: rgba(24, 24, 27, 0.8);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1);
}
/* Quick action button */
.quick-action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.75rem;
font-weight: 600;
border: 1px solid oklch(0.93 0.005 155);
background: oklch(0.99 0.002 155);
color: oklch(0.35 0.01 260);
transition: all 0.2s ease;
cursor: pointer;
}
.quick-action-btn:hover {
background: oklch(0.55 0.17 155 / 0.08);
border-color: oklch(0.55 0.17 155 / 0.2);
color: oklch(0.45 0.17 155);
transform: translateY(-1px);
box-shadow: 0 2px 8px -2px oklch(0.55 0.17 155 / 0.2);
}
.dark .quick-action-btn {
border-color: oklch(0.24 0.02 260);
background: oklch(0.18 0.015 260);
color: oklch(0.75 0.005 260);
}
.dark .quick-action-btn:hover {
background: oklch(0.65 0.17 155 / 0.12);
border-color: oklch(0.65 0.17 155 / 0.25);
color: oklch(0.85 0.17 155);
box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.3);
}
}