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
+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 });
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
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: {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
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,
|
||||
Newspaper,
|
||||
Compass,
|
||||
UsersRound,
|
||||
Bus,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -192,6 +194,9 @@ import SchoolEventsView from './school-events-view';
|
||||
import StudentCareerView from './student-career-view';
|
||||
import SubstituteTeacherView from './substitute-teacher-view';
|
||||
import SchoolNewsletterView from './school-newsletter-view';
|
||||
import SchoolTransportView from './school-transport-view';
|
||||
import PeerAssessmentView from './peer-assessment-view';
|
||||
import GradeAnalyticsView from './grade-analytics-view';
|
||||
|
||||
// Dynamic imports for heavy components with loading skeletons
|
||||
const AnalyticsView = dynamic(() => import('./analytics-view'), {
|
||||
@@ -223,6 +228,7 @@ const navSections: NavSection[] = [
|
||||
{ key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
|
||||
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
|
||||
{ key: 'analytics', icon: TrendingUp, labelKey: 'nav.analytics' },
|
||||
{ key: 'grade-analytics', icon: BarChart3, labelKey: 'nav.grade-analytics' },
|
||||
{ key: 'reports', icon: FileText, labelKey: 'nav.reports' },
|
||||
{ key: 'coverage', icon: Target, labelKey: 'nav.coverage' },
|
||||
],
|
||||
@@ -258,6 +264,7 @@ const navSections: NavSection[] = [
|
||||
{ key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' },
|
||||
{ key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' },
|
||||
{ key: 'exam-calendar', icon: CalendarClock, labelKey: 'nav.exam_calendar' },
|
||||
{ key: 'peer-assessment', icon: UsersRound, labelKey: 'nav.peer-assessment' },
|
||||
{ key: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' },
|
||||
{ key: 'seating-chart', icon: LayoutGridIcon, labelKey: 'nav.seating_chart' },
|
||||
{ key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' },
|
||||
@@ -267,6 +274,7 @@ const navSections: NavSection[] = [
|
||||
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
||||
{ key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' },
|
||||
{ key: 'school-newsletter', icon: Newspaper, labelKey: 'nav.school-newsletter' },
|
||||
{ key: 'school-transport', icon: Bus, labelKey: 'nav.school-transport' },
|
||||
{ key: 'substitute-teacher', icon: UserCheck, labelKey: 'nav.substitute-teacher' },
|
||||
{ key: 'data-import-export', icon: Database, labelKey: 'nav.data-import-export' },
|
||||
],
|
||||
@@ -293,6 +301,7 @@ const studentNavSections: NavSection[] = [
|
||||
{ key: 'notebooks', icon: BookOpen, labelKey: 'nav.student_notebooks' },
|
||||
{ key: 'flower', icon: Flower2, labelKey: 'nav.student_competencies' },
|
||||
{ key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' },
|
||||
{ key: 'grade-analytics', icon: BarChart3, labelKey: 'nav.grade-analytics' },
|
||||
{ key: 'homework', icon: BookCheck, labelKey: 'nav.homework' },
|
||||
{ key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' },
|
||||
{ key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' },
|
||||
@@ -305,6 +314,7 @@ const studentNavSections: NavSection[] = [
|
||||
{ key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' },
|
||||
{ key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' },
|
||||
{ key: 'exam-calendar', icon: CalendarClock, labelKey: 'nav.exam_calendar' },
|
||||
{ key: 'peer-assessment', icon: UsersRound, labelKey: 'nav.peer-assessment' },
|
||||
{ key: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' },
|
||||
{ key: 'seating-chart', icon: LayoutGridIcon, labelKey: 'nav.seating_chart' },
|
||||
{ key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' },
|
||||
@@ -315,6 +325,7 @@ const studentNavSections: NavSection[] = [
|
||||
{ key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' },
|
||||
{ key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' },
|
||||
{ key: 'school-newsletter', icon: Newspaper, labelKey: 'nav.school-newsletter' },
|
||||
{ key: 'school-transport', icon: Bus, labelKey: 'nav.school-transport' },
|
||||
{ key: 'substitute-teacher', icon: UserCheck, labelKey: 'nav.substitute-teacher' },
|
||||
{ key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
|
||||
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
||||
@@ -333,6 +344,7 @@ const parentNavSections: NavSection[] = [
|
||||
{ key: 'parent-portal', icon: Heart, labelKey: 'parent_portal.title' },
|
||||
{ key: 'parents', icon: Mail, labelKey: 'parent.messages' },
|
||||
{ key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' },
|
||||
{ key: 'grade-analytics', icon: BarChart3, labelKey: 'nav.grade-analytics' },
|
||||
{ key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' },
|
||||
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
|
||||
{ key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' },
|
||||
@@ -341,10 +353,12 @@ const parentNavSections: NavSection[] = [
|
||||
{ key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' },
|
||||
{ key: 'student-study-planner', icon: Timer, labelKey: 'study_planner.title' },
|
||||
{ key: 'student-career', icon: Compass, labelKey: 'nav.student-career' },
|
||||
{ key: 'peer-assessment', icon: UsersRound, labelKey: 'nav.peer-assessment' },
|
||||
{ key: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' },
|
||||
{ key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' },
|
||||
{ key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' },
|
||||
{ key: 'school-newsletter', icon: Newspaper, labelKey: 'nav.school-newsletter' },
|
||||
{ key: 'school-transport', icon: Bus, labelKey: 'nav.school-transport' },
|
||||
{ key: 'substitute-teacher', icon: UserCheck, labelKey: 'nav.substitute-teacher' },
|
||||
{ key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
|
||||
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
||||
@@ -491,6 +505,8 @@ function renderView(view: ViewName) {
|
||||
case 'announcements': return <SchoolAnnouncementsView />;
|
||||
case 'tablet-grading': return <TabletGradingView />;
|
||||
case 'exam-calendar': return <ExamCalendarView />;
|
||||
case 'peer-assessment': return <PeerAssessmentView />;
|
||||
case 'grade-analytics': return <GradeAnalyticsView />;
|
||||
case 'student-achievements': return <StudentAchievementsView />;
|
||||
case 'student-study-planner': return <StudentStudyPlannerView />;
|
||||
case 'seating-chart': return <SeatingChartView />;
|
||||
@@ -502,6 +518,7 @@ function renderView(view: ViewName) {
|
||||
case 'school-events': return <SchoolEventsView />;
|
||||
case 'substitute-teacher': return <SubstituteTeacherView />;
|
||||
case 'school-newsletter': return <SchoolNewsletterView />;
|
||||
case 'school-transport': return <SchoolTransportView />;
|
||||
default: return <DashboardView />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Save,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Loader2,
|
||||
Users,
|
||||
TrendingUp,
|
||||
@@ -31,6 +32,9 @@ import {
|
||||
FileDown,
|
||||
RefreshCw,
|
||||
Heart,
|
||||
Activity,
|
||||
CalendarDays,
|
||||
CircleDot,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
LineChart,
|
||||
@@ -362,6 +366,197 @@ function MonthlyHeatmap({ sessions }: { sessions: AttendanceSession[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Attendance Gauge SVG Component ─────────────────────────────────── */
|
||||
|
||||
function AttendanceGauge({ rate, size = 100 }: { rate: number; size?: number }) {
|
||||
const strokeWidth = 8;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (rate / 100) * circumference;
|
||||
const rateClass = rate >= 90 ? 'rate-high' : rate >= 70 ? 'rate-medium' : 'rate-low';
|
||||
|
||||
return (
|
||||
<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 ───────────────────────────────────────────────────── */
|
||||
|
||||
function StatsCards({ sessions }: { sessions: AttendanceSession[] }) {
|
||||
@@ -426,28 +621,41 @@ function StatsCards({ sessions }: { sessions: AttendanceSession[] }) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{cards.map((card, idx) => (
|
||||
<motion.div
|
||||
key={card.label}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: idx * 0.06 }}
|
||||
whileHover={{ y: -2 }}
|
||||
>
|
||||
<Card className={`card-hover-lift border-2 ${card.border} shadow-md hover:shadow-lg transition-shadow rounded-xl overflow-hidden`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<card.icon className="w-4 h-4" />
|
||||
<span className="text-xs font-medium text-muted-foreground">{card.label}</span>
|
||||
</div>
|
||||
<div className={`text-2xl font-bold bg-gradient-to-r ${card.gradient} bg-clip-text text-transparent`}>
|
||||
{card.value}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
<div className="space-y-4">
|
||||
{/* Attendance Gauge + Summary */}
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
<AttendanceGauge rate={stats.rate} size={100} />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 flex-1">
|
||||
<div className="glass-card-enhanced rounded-xl p-3 text-center">
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<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>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-emerald-600 dark:text-emerald-400">{stats.present}</span>
|
||||
</div>
|
||||
<div className="glass-card-enhanced rounded-xl p-3 text-center">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1300,19 +1508,48 @@ export default function AttendanceView() {
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
{/* Gradient Header Banner */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
initial={{ opacity: 0, y: -12 }}
|
||||
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>
|
||||
<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">
|
||||
<CalendarCheck className="h-8 w-8 text-emerald-500" />
|
||||
{t('attendance.title')}
|
||||
</h1>
|
||||
<p className="text-emerald-600/60 dark:text-emerald-400/40 mt-1">{t('attendance.subtitle')}</p>
|
||||
</div>
|
||||
<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>
|
||||
<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('attendance.title')}
|
||||
</h1>
|
||||
<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 className="flex items-center gap-3">
|
||||
{/* Class selector */}
|
||||
@@ -1412,6 +1649,7 @@ export default function AttendanceView() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* No class selected */}
|
||||
@@ -1430,7 +1668,7 @@ export default function AttendanceView() {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<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">
|
||||
@@ -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">
|
||||
{t('attendance.calendar')}
|
||||
</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">
|
||||
<QrCode className="h-4 w-4 mr-1" />
|
||||
{t('qr.attendance')}
|
||||
@@ -1571,16 +1813,14 @@ export default function AttendanceView() {
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
>
|
||||
<Card
|
||||
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
|
||||
<Card className="cursor-pointer glass-card-enhanced transition-all
|
||||
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)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<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 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">
|
||||
{sessionDate.getDate()}
|
||||
</span>
|
||||
@@ -1614,7 +1854,7 @@ export default function AttendanceView() {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<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" />
|
||||
<span className="text-xs font-medium text-emerald-700 dark:text-emerald-300">{presentCount}</span>
|
||||
</div>
|
||||
@@ -1625,7 +1865,7 @@ export default function AttendanceView() {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<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" />
|
||||
<span className="text-xs font-medium text-rose-700 dark:text-rose-300">{absentCount}</span>
|
||||
</div>
|
||||
@@ -1636,7 +1876,7 @@ export default function AttendanceView() {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<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" />
|
||||
<span className="text-xs font-medium text-amber-700 dark:text-amber-300">{excusedCount}</span>
|
||||
</div>
|
||||
@@ -1647,7 +1887,7 @@ export default function AttendanceView() {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<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" />
|
||||
<span className="text-xs font-medium text-orange-700 dark:text-orange-300">{lateCount}</span>
|
||||
</div>
|
||||
@@ -1659,13 +1899,7 @@ export default function AttendanceView() {
|
||||
|
||||
{/* Rate badge */}
|
||||
<div
|
||||
className={`text-sm font-bold px-2 py-1 rounded-lg ${
|
||||
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'
|
||||
}`}
|
||||
className={`status-badge ${rate >= 90 ? 'status-badge-present' : rate >= 70 ? 'status-badge-late' : 'status-badge-absent'}`}
|
||||
>
|
||||
{rate}%
|
||||
</div>
|
||||
@@ -1710,9 +1944,100 @@ export default function AttendanceView() {
|
||||
<MonthlyHeatmap sessions={sessions} />
|
||||
</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 */}
|
||||
<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">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
|
||||
+296
-64
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Users, Plus, Search, BookOpen, UserPlus, Grid3X3, School,
|
||||
Download, Upload, FileText, CheckCircle2, AlertTriangle, FileUp,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Shuffle, Printer, Eraser, Columns3, Rows3, Move,
|
||||
Camera,
|
||||
UserCheck, Shield, Eye, MessageSquare, CalendarDays, AlertCircle,
|
||||
Activity, Zap, TrendingUp, CircleDot,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -143,6 +144,86 @@ const masteryBadgeClass = (level: number) => {
|
||||
return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300';
|
||||
};
|
||||
|
||||
// Grade level color mapping (German school system 1-13)
|
||||
const gradeLevelColors: Record<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() {
|
||||
const currentUser = useAppStore((s) => s.currentUser);
|
||||
const currentClassId = useAppStore((s) => s.currentClassId);
|
||||
@@ -844,9 +925,134 @@ export default function ClassesView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<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">
|
||||
{/* 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">
|
||||
<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">
|
||||
@@ -897,68 +1103,93 @@ export default function ClassesView() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredClasses.map((cls) => (
|
||||
<motion.button
|
||||
key={cls.id}
|
||||
whileHover={{ scale: 1.02, y: -3 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
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 ${
|
||||
selectedClass?.id === cls.id
|
||||
? 'shadow-lg ring-2 ring-emerald-300/60 dark:ring-emerald-700/50'
|
||||
: 'hover:shadow-lg hover:shadow-emerald-200/30 dark:hover:shadow-emerald-900/20'
|
||||
}`}
|
||||
>
|
||||
{/* Gradient overlay strip at top */}
|
||||
<div className={`h-1.5 rounded-t-xl ${schoolTypeAccentBg[cls.schoolType] ?? 'bg-emerald-500'} opacity-60`} />
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
<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" />}
|
||||
{t(`school_type.${cls.schoolType.toLowerCase()}`)}
|
||||
</Badge>
|
||||
{cls.responsibleTeacher && (
|
||||
<Badge className="bg-gradient-to-r from-emerald-500 to-teal-500 text-white text-[10px] font-semibold rounded-md px-1.5 py-0.5 flex items-center gap-1 shadow-sm">
|
||||
<UserCheck className="w-3 h-3" />
|
||||
{cls.responsibleTeacher.firstName} {cls.responsibleTeacher.lastName}
|
||||
{filteredClasses.map((cls) => {
|
||||
const gradeKey = String(cls.gradeLevel ?? 1);
|
||||
return (
|
||||
<motion.button
|
||||
key={cls.id}
|
||||
whileHover={{ scale: 1.02, y: -3 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={() => handleSelectClass(cls)}
|
||||
className={`w-full text-left glass-card-enhanced rounded-xl transition-all duration-200 border-l-3 ${schoolTypeAccent[cls.schoolType] ?? 'border-l-emerald-500'} ${
|
||||
selectedClass?.id === cls.id
|
||||
? 'shadow-lg ring-2 ring-emerald-300/60 dark:ring-emerald-700/50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{/* Gradient overlay strip at top */}
|
||||
<div className={`h-1.5 rounded-t-xl ${schoolTypeAccentBg[cls.schoolType] ?? 'bg-emerald-500'} opacity-60`} />
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
{/* 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`}>
|
||||
{schoolTypeIcon[cls.schoolType] ?? <Backpack className="w-3 h-3" />}
|
||||
{t(`school_type.${cls.schoolType.toLowerCase()}`)}
|
||||
</Badge>
|
||||
)}
|
||||
{cls.responsibleTeacher && (
|
||||
<Badge className="bg-gradient-to-r from-emerald-500 to-teal-500 text-white text-[10px] font-semibold rounded-md px-1.5 py-0.5 flex items-center gap-1 shadow-sm">
|
||||
<UserCheck className="w-3 h-3" />
|
||||
{cls.responsibleTeacher.firstName} {cls.responsibleTeacher.lastName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('label.grade')} {cls.gradeLevel} · {cls.schoolYear?.label}
|
||||
</p>
|
||||
{/* Student count + subject count with icons */}
|
||||
<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">
|
||||
<Users className="h-3 w-3" />
|
||||
<AnimatedCount value={cls.studentCount ?? 0} duration={400} /> {t('label.student_count').toLowerCase()}
|
||||
</span>
|
||||
<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" />
|
||||
{cls._count?.competencyAssignments ?? 0} {locale === 'de' ? 'Faecher' : 'subjects'}
|
||||
</span>
|
||||
</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>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('label.grade')} {cls.gradeLevel} · {cls.schoolYear?.label}
|
||||
</p>
|
||||
{/* Student count + subject count with icons */}
|
||||
<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">
|
||||
<Users className="h-3 w-3" />
|
||||
{cls.studentCount ?? 0} {t('label.student_count').toLowerCase()}
|
||||
</span>
|
||||
<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" />
|
||||
{cls._count?.competencyAssignments ?? 0} {locale === 'de' ? 'Faecher' : 'subjects'}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* Small progress indicator */}
|
||||
<div className="w-16 h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<div
|
||||
className="progress-bar-animated-fill h-full rounded-full bg-gradient-to-r from-emerald-400 to-teal-400 transition-all"
|
||||
style={{ width: `${progressPercent(cls)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{/* Animated student count badge */}
|
||||
<motion.div
|
||||
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 className="flex items-center gap-2 shrink-0">
|
||||
{/* Small progress indicator */}
|
||||
<div className="w-16 h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<div
|
||||
className="progress-bar-animated-fill h-full rounded-full bg-gradient-to-r from-emerald-400 to-teal-400 transition-all"
|
||||
style={{ width: `${progressPercent(cls)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs font-medium">
|
||||
{cls.studentCount ?? 0} <Users className="h-3 w-3 ml-1" />
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -968,7 +1199,7 @@ export default function ClassesView() {
|
||||
<div className="md:col-span-1 lg:col-span-2 space-y-6">
|
||||
{!selectedClass ? (
|
||||
<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">
|
||||
<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" />
|
||||
@@ -985,7 +1216,7 @@ export default function ClassesView() {
|
||||
</Card>
|
||||
|
||||
{/* 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">
|
||||
<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">
|
||||
@@ -1061,7 +1292,7 @@ export default function ClassesView() {
|
||||
) : (
|
||||
<>
|
||||
{/* 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">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -1282,7 +1513,7 @@ export default function ClassesView() {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
@@ -1873,6 +2104,7 @@ export default function ClassesView() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add student dialog */}
|
||||
<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.recent_performance': 'Aktuelle Performance',
|
||||
'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 = {
|
||||
@@ -12638,6 +12968,336 @@ const en: TranslationDict = {
|
||||
'newsletter.engagement_distribution': 'Engagement Distribution',
|
||||
'newsletter.recent_performance': 'Recent Performance',
|
||||
'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 };
|
||||
|
||||
+4
-1
@@ -53,7 +53,10 @@ export type ViewName =
|
||||
| 'school-events'
|
||||
| 'substitute-teacher'
|
||||
| 'student-career'
|
||||
| 'school-newsletter';
|
||||
| 'school-newsletter'
|
||||
| 'school-transport'
|
||||
| 'peer-assessment'
|
||||
| 'grade-analytics';
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
|
||||
Reference in new issue
Block a user