Round 15: Timetable, Resource Library, Advanced Analytics, Accessibility

This commit is contained in:
Mustafa Cetin committed 2026-07-29 11:16:28 +00:00
1 parent 0a7cfae311
commit a94f900ae0
15 files changed
+4580 -1079

No files matched your search

+261
View File
@@ -419,6 +419,256 @@ export async function GET(request: Request) {
) / 100
: 0;
// ── Advanced: School Overview counts ──
const classGroupWhere: Record<string, unknown> = {};
if (schoolId) classGroupWhere.schoolId = schoolId;
if (schoolYearId) classGroupWhere.schoolYearId = schoolYearId;
if (classGroupId) classGroupWhere.id = classGroupId;
const [totalClasses, totalTeachers, totalCompetencies] = await Promise.all([
db.classGroup.count({ where: classGroupWhere }).catch(() => 0),
db.classGroupTeacher.findMany({
where: Object.keys(classGroupWhere).length > 0
? { classGroup: classGroupWhere }
: {},
select: { userId: true },
}).then((rows) => new Set(rows.map((r) => r.userId)).size).catch(() => 0),
db.competency.count().catch(() => 0),
]);
// ── Advanced: Subject Mastery Avg ──
const subjectAgg = new Map<string, { subjectName: string; total: number; count: number }>();
for (const e of entries) {
const cat = e.competency?.category;
if (!cat) continue;
const sid = cat.id;
const cur = subjectAgg.get(sid) ?? { subjectName: cat.name, total: 0, count: 0 };
cur.total += e.masteryLevelValue;
cur.count += 1;
subjectAgg.set(sid, cur);
}
const subjectMasteryAvg = Array.from(subjectAgg.entries())
.map(([categoryId, v]) => ({
categoryId,
categoryName: v.subjectName,
avgMastery: v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0,
entryCount: v.count,
}))
.sort((a, b) => b.avgMastery - a.avgMastery);
// ── Advanced: Teacher Performance ──
const teacherAssessments = await db.assessment.findMany({
where: {
...(classGroupId ? { classGroupId } : {}),
...(schoolYearId ? { classGroup: { schoolYearId } } : {}),
...(schoolId ? { classGroup: { schoolId } } : {}),
},
select: {
teacherId: true,
teacher: { select: { firstName: true, lastName: true } },
},
}).catch(() => []);
const teacherProgressEntries = await db.learningProgressEntry.findMany({
where,
select: {
classGroupId: true,
classGroup: {
select: {
teachers: { select: { userId: true, user: { select: { firstName: true, lastName: true } } } },
},
},
},
}).catch(() => []);
// Build teacher stats
const teacherStatsMap = new Map<string, { name: string; assessmentCount: number; progressCount: number }>();
for (const a of teacherAssessments) {
const name = `${a.teacher?.firstName ?? ''} ${a.teacher?.lastName ?? ''}`.trim();
const cur = teacherStatsMap.get(a.teacherId) ?? { name: name || a.teacherId, assessmentCount: 0, progressCount: 0 };
cur.assessmentCount += 1;
teacherStatsMap.set(a.teacherId, cur);
}
for (const p of teacherProgressEntries) {
for (const t of p.classGroup?.teachers ?? []) {
const name = `${t.user?.firstName ?? ''} ${t.user?.lastName ?? ''}`.trim();
const cur = teacherStatsMap.get(t.userId) ?? { name: name || t.userId, assessmentCount: 0, progressCount: 0 };
cur.progressCount += 1;
teacherStatsMap.set(t.userId, cur);
}
}
// Compute student improvement rate per teacher
// Batch-fetch all class teachers for students in scope
const classGroupIds = new Set(enrollments.map((enr) => enr.classGroup?.id).filter(Boolean) as string[]);
const allClassTeachers = classGroupIds.size > 0
? await db.classGroupTeacher.findMany({
where: { classGroupId: { in: Array.from(classGroupIds) } },
select: { classGroupId: true, userId: true },
}).catch(() => [])
: [];
const classTeacherMap = new Map<string, string[]>();
for (const ct of allClassTeachers) {
const arr = classTeacherMap.get(ct.classGroupId) ?? [];
arr.push(ct.userId);
classTeacherMap.set(ct.classGroupId, arr);
}
const teacherImprovementMap = new Map<string, { improved: number; total: number }>();
for (const [studentId, eArr] of studentEntries) {
if (eArr.length < 2) continue;
const sorted = eArr.sort((a, b) => a.date.getTime() - b.date.getTime());
const firstHalf = sorted.slice(0, Math.floor(sorted.length / 2));
const secondHalf = sorted.slice(Math.floor(sorted.length / 2));
const firstAvg = firstHalf.reduce((s, e) => s + e.masteryLevelValue, 0) / firstHalf.length;
const secondAvg = secondHalf.reduce((s, e) => s + e.masteryLevelValue, 0) / secondHalf.length;
const improved = secondAvg > firstAvg;
// Find the student's class teacher(s) for attribution
const studentEnr = enrollments.find((enr) => enr.studentId === studentId);
const teacherIds = studentEnr?.classGroup?.id ? (classTeacherMap.get(studentEnr.classGroup.id) ?? []) : [];
for (const tid of teacherIds) {
const cur = teacherImprovementMap.get(tid) ?? { improved: 0, total: 0 };
cur.total += 1;
if (improved) cur.improved += 1;
teacherImprovementMap.set(tid, cur);
}
}
// Notebook usage per teacher
const teacherNotebookCounts = await db.notebook.groupBy({
by: ['ownerId'],
where: {
ownerType: 'TEACHER',
...(schoolId ? { schoolId } : {}),
deletedAt: null,
},
_count: { id: true },
}).catch(() => []);
const teacherPerformance = Array.from(teacherStatsMap.entries())
.map(([teacherId, v]) => {
const imp = teacherImprovementMap.get(teacherId);
const notebook = teacherNotebookCounts.find((n) => n.ownerId === teacherId);
return {
teacherId,
teacherName: v.name,
assessmentCount: v.assessmentCount,
progressCount: v.progressCount,
improvementRate: imp && imp.total > 0 ? Math.round((imp.improved / imp.total) * 1000) / 10 : 0,
notebookCount: notebook?._count.id ?? 0,
};
})
.sort((a, b) => b.assessmentCount - a.assessmentCount);
// ── Advanced: Self-Assessment vs Teacher Gap Analysis ──
const selfAssessmentData = await db.selfAssessment.findMany({
where: {
...(schoolId ? { schoolId } : {}),
...(classGroupId ? { classGroupId } : {}),
deletedAt: null,
},
select: {
studentId: true,
competencyId: true,
selfLevel: true,
createdAt: true,
student: { select: { firstName: true, lastName: true } },
},
}).catch(() => []);
// For each self-assessment, find the latest teacher progress entry for the same student+competency
const gapAnalysis: Array<{
studentId: string;
studentName: string;
competencyId: string;
selfLevel: number;
teacherLevel: number;
gap: number;
}> = [];
for (const sa of selfAssessmentData) {
const teacherEntries = studentEntries.get(sa.studentId)
?.filter((e) => e.masteryLevelValue > 0)
?? [];
if (teacherEntries.length === 0) continue;
const latestTeacher = teacherEntries.sort((a, b) => b.date.getTime() - a.date.getTime())[0];
const gap = sa.selfLevel - latestTeacher.masteryLevelValue;
gapAnalysis.push({
studentId: sa.studentId,
studentName: `${sa.student?.firstName ?? ''} ${sa.student?.lastName ?? ''}`.trim() || sa.studentId,
competencyId: sa.competencyId,
selfLevel: sa.selfLevel,
teacherLevel: Math.round(latestTeacher.masteryLevelValue * 100) / 100,
gap: Math.round(gap * 100) / 100,
});
}
// Aggregate gap analysis for chart
const selfVsTeacherAgg = gapAnalysis.length > 0 ? {
avgSelfLevel: Math.round((gapAnalysis.reduce((s, g) => s + g.selfLevel, 0) / gapAnalysis.length) * 100) / 100,
avgTeacherLevel: Math.round((gapAnalysis.reduce((s, g) => s + g.teacherLevel, 0) / gapAnalysis.length) * 100) / 100,
avgGap: Math.round((gapAnalysis.reduce((s, g) => s + g.gap, 0) / gapAnalysis.length) * 100) / 100,
totalComparisons: gapAnalysis.length,
} : null;
// ── Advanced: Competency Coverage ──
const assessedCompetencyIds = new Set(entries.map((e) => e.competencyId));
const competencyCoveragePct = totalCompetencies > 0
? Math.round((assessedCompetencyIds.size / totalCompetencies) * 1000) / 10
: 0;
// ── Advanced: Students Excelling (improvement trajectory) ──
const excellingStudents: Array<{
studentId: string;
studentName: string;
className: string;
latestMastery: number;
improvement: number;
}> = [];
for (const [studentId, eArr] of studentEntries) {
if (eArr.length < 4) continue;
const sorted = eArr.sort((a, b) => a.date.getTime() - b.date.getTime());
const first2 = sorted.slice(0, 2);
const last2 = sorted.slice(-2);
const firstAvg = first2.reduce((s, e) => s + e.masteryLevelValue, 0) / 2;
const lastAvg = last2.reduce((s, e) => s + e.masteryLevelValue, 0) / 2;
const improvement = Math.round((lastAvg - firstAvg) * 100) / 100;
if (improvement > 0.5) {
const meta = studentMeta.get(studentId);
if (meta) {
excellingStudents.push({
studentId,
studentName: meta.studentName,
className: meta.className,
latestMastery: Math.round(lastAvg * 100) / 100,
improvement,
});
}
}
}
excellingStudents.sort((a, b) => b.improvement - a.improvement);
// ── Advanced: Class Radar Data ──
// For each class, compute multi-dimensional scores
const classRadarData = Array.from(classAgg.entries()).map(([classId, v]) => {
const classStudents = Array.from(v.students);
const classAttendance = classStudents.reduce((sum, sid) => {
const att = attendanceMap.get(sid);
return sum + (att && att.total > 0 ? (att.present / att.total) * 100 : 0);
}, 0) / (classStudents.length || 1);
const engagement = Math.min(100, (v.count / Math.max(1, v.students.size)) * 20);
const progress = v.count > 0 ? (v.total / v.count / 4) * 100 : 0;
return {
classId,
className: v.className,
mastery: Math.round((v.count > 0 ? (v.total / v.count / 4) * 100 : 0) * 10) / 10,
attendance: Math.round(classAttendance * 10) / 10,
engagement: Math.round(engagement * 10) / 10,
progress: Math.round(progress * 10) / 10,
behavior: Math.round(((v.count > 0 ? (v.total / v.count / 4) * 100 : 0) * 0.6 + classAttendance * 0.4) * 10) / 10,
};
});
return NextResponse.json({
masteryTrend,
classComparison,
@@ -431,6 +681,17 @@ export async function GET(request: Request) {
totalEntries,
totalStudents,
overallAvgMastery,
// New fields
totalClasses,
totalTeachers,
totalCompetencies,
competencyCoveragePct,
subjectMasteryAvg,
teacherPerformance,
selfVsTeacherAgg,
gapAnalysis: gapAnalysis.slice(0, 50),
excellingStudents: excellingStudents.slice(0, 10),
classRadarData,
});
} catch (error) {
console.error('Analytics GET error:', error);
+169
View File
@@ -0,0 +1,169 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const resourceTypeEnum = z.enum([
'document', 'worksheet', 'presentation', 'video_link', 'image', 'link', 'audio',
]);
const updateResourceSchema = z.object({
title: z.string().min(1).max(300).optional(),
description: z.string().max(5000).optional().nullable(),
resourceType: resourceTypeEnum.optional(),
url: z.string().optional().nullable(),
content: z.string().optional().nullable(),
subjectId: z.string().optional().nullable(),
classGroupId: z.string().optional().nullable(),
gradeLevel: z.number().int().min(1).max(13).optional().nullable(),
tags: z.array(z.string()).optional().nullable(),
isPublic: z.boolean().optional(),
incrementDownload: z.boolean().optional(),
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const resource = await db.resource.findUnique({
where: { id, deletedAt: null },
include: {
author: { select: { id: true, firstName: true, lastName: true } },
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
},
});
if (!resource) {
return NextResponse.json({ error: 'Resource not found' }, { status: 404 });
}
// Students can only see public resources
if (session.user?.role === 'STUDENT' && !resource.isPublic) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json(resource);
} catch (error) {
console.error('Resources GET [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateResourceSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const existing = await db.resource.findUnique({ where: { id, deletedAt: null } });
if (!existing) {
return NextResponse.json({ error: 'Resource not found' }, { status: 404 });
}
// Only the author or admins can edit
if (session.user?.role === 'TEACHER' && existing.authorId !== session.userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Handle download increment separately
if (parsed.data.incrementDownload) {
await db.resource.update({
where: { id },
data: { downloadCount: { increment: 1 } },
});
return NextResponse.json({ success: true });
}
const updateData: Record<string, unknown> = {};
if (parsed.data.title !== undefined) updateData.title = parsed.data.title;
if (parsed.data.description !== undefined) updateData.description = parsed.data.description;
if (parsed.data.resourceType !== undefined) updateData.resourceType = parsed.data.resourceType;
if (parsed.data.url !== undefined) updateData.url = parsed.data.url;
if (parsed.data.content !== undefined) updateData.content = parsed.data.content;
if (parsed.data.subjectId !== undefined) updateData.subjectId = parsed.data.subjectId || null;
if (parsed.data.classGroupId !== undefined) updateData.classGroupId = parsed.data.classGroupId || null;
if (parsed.data.gradeLevel !== undefined) updateData.gradeLevel = parsed.data.gradeLevel;
if (parsed.data.tags !== undefined) updateData.tags = parsed.data.tags ? JSON.stringify(parsed.data.tags) : null;
if (parsed.data.isPublic !== undefined) updateData.isPublic = parsed.data.isPublic;
const resource = await db.resource.update({
where: { id },
data: updateData,
include: {
author: { select: { id: true, firstName: true, lastName: true } },
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
},
});
return NextResponse.json(resource);
} catch (error) {
console.error('Resources PUT [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const existing = await db.resource.findUnique({ where: { id, deletedAt: null } });
if (!existing) {
return NextResponse.json({ error: 'Resource not found' }, { status: 404 });
}
// Only the author or admins can delete
if (session.user?.role === 'TEACHER' && existing.authorId !== session.userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
await db.resource.update({
where: { id },
data: { deletedAt: new Date() },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Resources DELETE [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+164
View File
@@ -0,0 +1,164 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const resourceTypeEnum = z.enum([
'document', 'worksheet', 'presentation', 'video_link', 'image', 'link', 'audio',
]);
const createResourceSchema = z.object({
schoolId: z.string().min(1),
title: z.string().min(1).max(300),
description: z.string().max(5000).optional().nullable(),
resourceType: resourceTypeEnum,
url: z.string().optional().nullable(),
content: z.string().optional().nullable(),
subjectId: z.string().optional().nullable(),
classGroupId: z.string().optional().nullable(),
gradeLevel: z.number().int().min(1).max(13).optional().nullable(),
tags: z.array(z.string()).optional().nullable(),
isPublic: z.boolean().optional().default(true),
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
}
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const schoolIdParam = searchParams.get('schoolId');
const resourceType = searchParams.get('resourceType');
const subjectId = searchParams.get('subjectId');
const classGroupId = searchParams.get('classGroupId');
const search = searchParams.get('search');
const isPublic = searchParams.get('isPublic');
let schoolId: string | undefined;
if (session.user?.role === 'SCHOOL_ADMIN') {
schoolId = session.user.schoolId ?? undefined;
} else {
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
}
if (!schoolId) {
return NextResponse.json([]);
}
const where: Record<string, unknown> = { schoolId, deletedAt: null };
// Students can only see public resources
if (session.user?.role === 'STUDENT') {
where.isPublic = true;
}
if (resourceType && resourceType !== 'all') where.resourceType = resourceType;
if (subjectId && subjectId !== 'all') where.subjectId = subjectId;
if (classGroupId && classGroupId !== 'all') where.classGroupId = classGroupId;
if (isPublic === 'true') where.isPublic = true;
if (isPublic === 'false') where.isPublic = false;
if (search) {
where.OR = [
{ title: { contains: search } },
{ description: { contains: search } },
];
}
const resources = await db.resource.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
author: { select: { id: true, firstName: true, lastName: true } },
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
},
});
return NextResponse.json(resources);
} catch (error) {
console.error('Resources GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createResourceSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { schoolId, subjectId, classGroupId, tags, ...rest } = parsed.data;
// Verify school
const school = await db.school.findUnique({ where: { id: schoolId } });
if (!school) {
return NextResponse.json({ error: 'School not found' }, { status: 404 });
}
if (session.user?.role === 'SCHOOL_ADMIN' && session.user.schoolId && school.id !== session.user.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Verify subject if provided
if (subjectId) {
const subject = await db.subject.findUnique({ where: { id: subjectId } });
if (!subject) {
return NextResponse.json({ error: 'Subject not found' }, { status: 404 });
}
}
// Verify classGroup if provided
if (classGroupId) {
const classGroup = await db.classGroup.findUnique({ where: { id: classGroupId } });
if (!classGroup || classGroup.schoolId !== schoolId) {
return NextResponse.json({ error: 'Class group not found in this school' }, { status: 404 });
}
}
const resource = await db.resource.create({
data: {
schoolId,
authorId: session.userId,
title: rest.title,
description: rest.description ?? null,
resourceType: rest.resourceType,
url: rest.url ?? null,
content: rest.content ?? null,
subjectId: subjectId || null,
classGroupId: classGroupId || null,
gradeLevel: rest.gradeLevel ?? null,
tags: tags ? JSON.stringify(tags) : null,
isPublic: rest.isPublic,
},
include: {
author: { select: { id: true, firstName: true, lastName: true } },
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
},
});
return NextResponse.json(resource, { status: 201 });
} catch (error) {
console.error('Resources POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+136
View File
@@ -0,0 +1,136 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const updateSlotSchema = z.object({
subjectId: z.string().optional().nullable(),
teacherId: z.string().optional().nullable(),
roomId: z.string().optional().nullable(),
startTime: z.string().optional(),
endTime: z.string().optional(),
dayOfWeek: z.number().int().min(0).max(4).optional(),
period: z.number().int().min(1).max(8).optional(),
isBreak: z.boolean().optional(),
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const slot = await db.timetableSlot.findUnique({
where: { id, deletedAt: null },
include: {
classGroup: { select: { id: true, name: true, gradeLevel: true } },
subject: { select: { id: true, name: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
},
});
if (!slot) {
return NextResponse.json({ error: 'Timetable slot not found' }, { status: 404 });
}
return NextResponse.json(slot);
} catch (error) {
console.error('Timetable GET [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateSlotSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const existing = await db.timetableSlot.findUnique({ where: { id, deletedAt: null } });
if (!existing) {
return NextResponse.json({ error: 'Timetable slot not found' }, { status: 404 });
}
const updateData: Record<string, unknown> = {};
if (parsed.data.subjectId !== undefined) updateData.subjectId = parsed.data.subjectId || null;
if (parsed.data.teacherId !== undefined) updateData.teacherId = parsed.data.teacherId || null;
if (parsed.data.roomId !== undefined) updateData.roomId = parsed.data.roomId || null;
if (parsed.data.startTime !== undefined) updateData.startTime = parsed.data.startTime;
if (parsed.data.endTime !== undefined) updateData.endTime = parsed.data.endTime;
if (parsed.data.dayOfWeek !== undefined) updateData.dayOfWeek = parsed.data.dayOfWeek;
if (parsed.data.period !== undefined) updateData.period = parsed.data.period;
if (parsed.data.isBreak !== undefined) updateData.isBreak = parsed.data.isBreak;
const slot = await db.timetableSlot.update({
where: { id },
data: updateData,
include: {
classGroup: { select: { id: true, name: true, gradeLevel: true } },
subject: { select: { id: true, name: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
},
});
return NextResponse.json(slot);
} catch (error) {
console.error('Timetable PUT [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const existing = await db.timetableSlot.findUnique({ where: { id, deletedAt: null } });
if (!existing) {
return NextResponse.json({ error: 'Timetable slot not found' }, { status: 404 });
}
await db.timetableSlot.update({
where: { id },
data: { deletedAt: new Date() },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Timetable DELETE [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+196
View File
@@ -0,0 +1,196 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const createSlotSchema = z.object({
schoolId: z.string().min(1),
classGroupId: z.string().min(1),
dayOfWeek: z.number().int().min(0).max(4),
period: z.number().int().min(1).max(8),
subjectId: z.string().optional().nullable(),
teacherId: z.string().optional().nullable(),
roomId: z.string().optional().nullable(),
startTime: z.string().min(1),
endTime: z.string().min(1),
isBreak: z.boolean().optional().default(false),
});
const batchCreateSchema = z.object({
schoolId: z.string().min(1),
classGroupId: z.string().min(1),
slots: z.array(createSlotSchema.omit({ schoolId: true, classGroupId: true })),
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
}
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const schoolIdParam = searchParams.get('schoolId');
const classGroupId = searchParams.get('classGroupId');
let schoolId: string | undefined;
if (session.user?.role === 'SCHOOL_ADMIN') {
schoolId = session.user.schoolId ?? undefined;
} else {
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
}
if (!schoolId) {
return NextResponse.json([]);
}
const where: Record<string, unknown> = { schoolId, deletedAt: null };
if (classGroupId && classGroupId !== 'all') where.classGroupId = classGroupId;
const slots = await db.timetableSlot.findMany({
where,
orderBy: [{ dayOfWeek: 'asc' }, { period: 'asc' }],
include: {
classGroup: { select: { id: true, name: true, gradeLevel: true } },
subject: { select: { id: true, name: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
},
});
return NextResponse.json(slots);
} catch (error) {
console.error('Timetable GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (!isTeacherOrAdmin(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
// Check if this is a batch create request
if (Array.isArray(body.slots)) {
const parsed = batchCreateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { schoolId, classGroupId, slots } = parsed.data;
// Verify school
const school = await db.school.findUnique({ where: { id: schoolId } });
if (!school) {
return NextResponse.json({ error: 'School not found' }, { status: 404 });
}
if (session.user?.role === 'SCHOOL_ADMIN' && session.user.schoolId && school.id !== session.user.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Verify classGroup
const classGroup = await db.classGroup.findUnique({ where: { id: classGroupId } });
if (!classGroup || classGroup.schoolId !== schoolId) {
return NextResponse.json({ error: 'Class group not found in this school' }, { status: 404 });
}
const created = await db.timetableSlot.createMany({
data: slots.map((slot) => ({
schoolId,
classGroupId,
dayOfWeek: slot.dayOfWeek,
period: slot.period,
subjectId: slot.subjectId || null,
teacherId: slot.teacherId || null,
roomId: slot.roomId || null,
startTime: slot.startTime,
endTime: slot.endTime,
isBreak: slot.isBreak,
})),
skipDuplicates: true,
});
return NextResponse.json({ count: created.count }, { status: 201 });
}
// Single slot create
const parsed = createSlotSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { schoolId, classGroupId, subjectId, teacherId, ...rest } = parsed.data;
// Verify school
const school = await db.school.findUnique({ where: { id: schoolId } });
if (!school) {
return NextResponse.json({ error: 'School not found' }, { status: 404 });
}
if (session.user?.role === 'SCHOOL_ADMIN' && session.user.schoolId && school.id !== session.user.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Verify classGroup
const classGroup = await db.classGroup.findUnique({ where: { id: classGroupId } });
if (!classGroup || classGroup.schoolId !== schoolId) {
return NextResponse.json({ error: 'Class group not found in this school' }, { status: 404 });
}
// Verify subject if provided
if (subjectId) {
const subject = await db.subject.findUnique({ where: { id: subjectId } });
if (!subject) {
return NextResponse.json({ error: 'Subject not found' }, { status: 404 });
}
}
// Verify teacher if provided
if (teacherId) {
const teacher = await db.user.findUnique({ where: { id: teacherId, deletedAt: null } });
if (!teacher) {
return NextResponse.json({ error: 'Teacher not found' }, { status: 404 });
}
}
const slot = await db.timetableSlot.create({
data: {
schoolId,
classGroupId,
subjectId: subjectId || null,
teacherId: teacherId || null,
roomId: rest.roomId || null,
dayOfWeek: rest.dayOfWeek,
period: rest.period,
startTime: rest.startTime,
endTime: rest.endTime,
isBreak: rest.isBreak,
},
include: {
classGroup: { select: { id: true, name: true, gradeLevel: true } },
subject: { select: { id: true, name: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
},
});
return NextResponse.json(slot, { status: 201 });
} catch (error) {
console.error('Timetable POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+228
View File
@@ -1748,6 +1748,234 @@
background: linear-gradient(to right, transparent, oklch(0.65 0.17 155 / 0.3), transparent);
}
/* ─── Advanced Analytics Styles ──────────────────────────────────── */
/* Chart container with rounded corners and shadow */
.chart-container {
background: oklch(1 0 0);
border-radius: 1rem;
box-shadow: 0 1px 3px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.04);
border: 1px solid oklch(0.93 0.005 155);
overflow: hidden;
}
.dark .chart-container {
background: oklch(0.18 0.015 260);
box-shadow: 0 1px 3px oklch(0 0 0 / 0.3), 0 0 0 1px oklch(1 0 0 / 6%);
border-color: oklch(1 0 0 / 8%);
}
/* Stat card with gradient */
.stat-card {
position: relative;
border-radius: 1rem;
padding: 1.25rem;
overflow: hidden;
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
}
.stat-card::before {
content: '';
position: absolute;
inset: 0;
opacity: 0.06;
background: linear-gradient(135deg, oklch(0.55 0.17 155), oklch(0.55 0.14 175));
pointer-events: none;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px -6px oklch(0.55 0.17 155 / 0.15);
}
.dark .stat-card:hover {
box-shadow: 0 8px 24px -6px oklch(0.65 0.17 155 / 0.25);
}
/* Heatmap cell styling */
.heatmap-cell {
border-radius: 3px;
transition: all 0.15s ease;
min-width: 14px;
min-height: 14px;
}
.heatmap-cell:hover {
transform: scale(1.3);
box-shadow: 0 0 0 2px oklch(0.55 0.17 155 / 0.3);
z-index: 1;
}
/* Analytics dashboard grid layout */
.analytics-dashboard {
display: grid;
gap: 1.5rem;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.analytics-dashboard {
grid-template-columns: repeat(2, 1fr);
}
}
.analytics-dashboard-full {
grid-column: 1 / -1;
}
/* Chart loading skeleton */
.loading-skeleton-chart {
position: relative;
overflow: hidden;
border-radius: 1rem;
background: oklch(0.94 0.005 155 / 0.5);
min-height: 200px;
}
.dark .loading-skeleton-chart {
background: oklch(0.22 0.015 260 / 0.5);
}
.loading-skeleton-chart::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
oklch(0.97 0.01 155 / 0.4) 50%,
transparent 100%
);
animation: skeleton-slide 1.5s ease-in-out infinite;
}
.dark .loading-skeleton-chart::after {
background: linear-gradient(
90deg,
transparent 0%,
oklch(0.30 0.01 260 / 0.3) 50%,
transparent 100%
);
}
/* Predictive badge — risk / excelling */
.predictive-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.02em;
line-height: 1.25rem;
}
.predictive-badge-risk {
background: oklch(0.55 0.22 25 / 0.1);
color: oklch(0.55 0.22 25);
border: 1px solid oklch(0.55 0.22 25 / 0.2);
}
.dark .predictive-badge-risk {
background: oklch(0.55 0.22 25 / 0.15);
color: oklch(0.70 0.19 22);
border-color: oklch(0.55 0.22 25 / 0.3);
}
.predictive-badge-excel {
background: oklch(0.55 0.17 155 / 0.1);
color: oklch(0.55 0.17 155);
border: 1px solid oklch(0.55 0.17 155 / 0.2);
}
.dark .predictive-badge-excel {
background: oklch(0.55 0.17 155 / 0.15);
color: oklch(0.70 0.17 155);
border-color: oklch(0.65 0.17 155 / 0.3);
}
.predictive-badge-attention {
background: oklch(0.72 0.19 85 / 0.1);
color: oklch(0.60 0.18 50);
border: 1px solid oklch(0.72 0.19 85 / 0.2);
}
.dark .predictive-badge-attention {
background: oklch(0.72 0.19 85 / 0.15);
color: oklch(0.77 0.19 85);
border-color: oklch(0.72 0.19 85 / 0.3);
}
/* Analytics section tab navigation */
.analytics-tab {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
color: oklch(0.55 0.02 260);
transition: all 0.2s ease;
cursor: pointer;
min-height: 44px;
display: flex;
align-items: center;
gap: 0.375rem;
border: 1px solid transparent;
}
.analytics-tab:hover {
background: oklch(0.55 0.17 155 / 0.06);
color: oklch(0.55 0.17 155);
}
.analytics-tab.active {
background: oklch(0.55 0.17 155 / 0.1);
color: oklch(0.55 0.17 155);
border-color: oklch(0.55 0.17 155 / 0.2);
font-weight: 600;
}
.dark .analytics-tab:hover {
background: oklch(0.55 0.17 155 / 0.1);
color: oklch(0.65 0.17 155);
}
.dark .analytics-tab.active {
background: oklch(0.55 0.17 155 / 0.15);
color: oklch(0.65 0.17 155);
border-color: oklch(0.65 0.17 155 / 0.3);
}
/* Export button group */
.export-btn-group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.export-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
min-height: 44px;
border: 1px solid oklch(0.93 0.005 155);
background: oklch(1 0 0);
color: oklch(0.35 0.02 260);
transition: all 0.2s ease;
cursor: pointer;
}
.export-btn:hover {
background: oklch(0.55 0.17 155 / 0.06);
border-color: oklch(0.55 0.17 155 / 0.2);
color: oklch(0.55 0.17 155);
}
.dark .export-btn {
background: oklch(0.18 0.015 260);
border-color: oklch(1 0 0 / 8%);
color: oklch(0.70 0.02 260);
}
.dark .export-btn:hover {
background: oklch(0.55 0.17 155 / 0.1);
border-color: oklch(0.65 0.17 155 / 0.3);
color: oklch(0.65 0.17 155);
}
/* Chart description for screen readers */
.chart-sr-desc {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* ─── Offline / PWA Styles ──────────────────────────────────────── */
/* Offline indicator bar */
File diff suppressed because it is too large. Load diff
+8
View File
@@ -55,6 +55,8 @@ import {
BookCheck,
Megaphone,
Briefcase,
Clock as ClockIcon,
Library as LibraryIcon,
} from 'lucide-react';
import {
Sidebar,
@@ -145,6 +147,8 @@ import NotebooksView from './notebooks-view';
import DrawingView from './drawing-view';
import HomeworkView from './homework-view';
import PortfolioView from './portfolio-view';
import TimetableView from './timetable-view';
import ResourceLibraryView from './resource-library-view';
type NavItem = { key: ViewName; icon: React.ElementType; labelKey: string };
type NavSection = { id: string; labelKey: string; items: NavItem[] };
@@ -181,6 +185,8 @@ const navSections: NavSection[] = [
{ key: 'notebooks', icon: BookOpen, labelKey: 'nav.notebooks' },
{ key: 'drawing', icon: Palette, labelKey: 'nav.drawing' },
{ key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' },
{ key: 'timetable', icon: ClockIcon, labelKey: 'nav.timetable' },
{ key: 'resources', icon: LibraryIcon, labelKey: 'nav.resources' },
],
},
{
@@ -348,6 +354,8 @@ function renderView(view: ViewName) {
case 'drawing': return <DrawingView />;
case 'homework': return <HomeworkView />;
case 'portfolio': return <PortfolioView />;
case 'timetable': return <TimetableView />;
case 'resources': return <ResourceLibraryView />;
default: return <DashboardView />;
}
}
+811
View File
@@ -0,0 +1,811 @@
'use client';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Library,
Plus,
Search,
FileText,
ClipboardList,
Presentation,
Video,
Link2,
Image,
Headphones,
Download,
Eye,
Star,
Trash2,
Edit3,
X,
Globe,
Lock,
Filter,
ChevronDown,
Tag,
BookOpen,
} from 'lucide-react';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
// ── Types ──────────────────────────────────────────────────────────────
interface ResourceItem {
id: string;
schoolId: string;
authorId: string;
title: string;
description: string | null;
resourceType: string;
url: string | null;
content: string | null;
subjectId: string | null;
classGroupId: string | null;
gradeLevel: number | null;
tags: string | null; // JSON array
isPublic: boolean;
downloadCount: number;
createdAt: string;
author: { id: string; firstName: string; lastName: string };
subject: { id: string; name: string } | null;
classGroup: { id: string; name: string; gradeLevel: number } | null;
}
interface SubjectOption {
id: string;
name: string;
}
interface ClassOption {
id: string;
name: string;
gradeLevel: number;
}
type ResourceType = 'document' | 'worksheet' | 'presentation' | 'video_link' | 'image' | 'link' | 'audio';
const RESOURCE_TYPES: ResourceType[] = ['document', 'worksheet', 'presentation', 'video_link', 'image', 'link', 'audio'];
function getTypeIcon(type: string) {
switch (type) {
case 'document': return FileText;
case 'worksheet': return ClipboardList;
case 'presentation': return Presentation;
case 'video_link': return Video;
case 'image': return Image;
case 'link': return Link2;
case 'audio': return Headphones;
default: return FileText;
}
}
function getTypeColor(type: string): string {
switch (type) {
case 'document': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300';
case 'worksheet': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300';
case 'presentation': return 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300';
case 'video_link': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300';
case 'image': return 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300';
case 'link': return 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300';
case 'audio': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300';
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/40 dark:text-gray-300';
}
}
function getTypeLabel(type: string): string {
const keyMap: Record<string, string> = {
document: 'resources.document',
worksheet: 'resources.worksheet',
presentation: 'resources.presentation',
video_link: 'resources.video',
image: 'resources.image',
link: 'resources.link',
audio: 'resources.audio',
};
return t(keyMap[type] ?? 'resources.type');
}
// ── Resource Create/Edit Dialog ────────────────────────────────────────
function ResourceDialog({
open,
onClose,
resource,
subjects,
classes,
schoolId,
onSave,
}: {
open: boolean;
onClose: () => void;
resource: ResourceItem | null;
subjects: SubjectOption[];
classes: ClassOption[];
schoolId: string;
onSave: (data: Record<string, unknown>) => void;
}) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [resourceType, setResourceType] = useState<ResourceType>('document');
const [url, setUrl] = useState('');
const [content, setContent] = useState('');
const [subjectId, setSubjectId] = useState<string>('none');
const [classGroupId, setClassGroupId] = useState<string>('none');
const [gradeLevel, setGradeLevel] = useState('');
const [tagsInput, setTagsInput] = useState('');
const [isPublic, setIsPublic] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (resource) {
setTitle(resource.title);
setDescription(resource.description ?? '');
setResourceType(resource.resourceType as ResourceType);
setUrl(resource.url ?? '');
setContent(resource.content ?? '');
setSubjectId(resource.subjectId ?? 'none');
setClassGroupId(resource.classGroupId ?? 'none');
setGradeLevel(resource.gradeLevel?.toString() ?? '');
try {
const parsedTags = resource.tags ? JSON.parse(resource.tags) : [];
setTagsInput(Array.isArray(parsedTags) ? parsedTags.join(', ') : '');
} catch {
setTagsInput('');
}
setIsPublic(resource.isPublic);
} else {
setTitle('');
setDescription('');
setResourceType('document');
setUrl('');
setContent('');
setSubjectId('none');
setClassGroupId('none');
setGradeLevel('');
setTagsInput('');
setIsPublic(true);
}
}, [resource, open]);
const handleSave = async () => {
if (!title.trim()) return;
setSaving(true);
try {
const tags = tagsInput
.split(',')
.map((t) => t.trim())
.filter(Boolean);
const data: Record<string, unknown> = {
schoolId,
title: title.trim(),
description: description.trim() || null,
resourceType,
url: url.trim() || null,
content: content.trim() || null,
subjectId: subjectId === 'none' ? null : subjectId,
classGroupId: classGroupId === 'none' ? null : classGroupId,
gradeLevel: gradeLevel ? parseInt(gradeLevel, 10) : null,
tags: tags.length > 0 ? tags : null,
isPublic,
};
if (resource) {
data.id = resource.id;
}
onSave(data);
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{resource ? t('resources.edit') : t('resources.create')}
</DialogTitle>
<DialogDescription>
{resource ? t('resources.edit') : t('resources.create')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="res-title">{t('resources.title_field')}</Label>
<Input id="res-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title..." className="min-h-[44px]" />
</div>
<div className="space-y-2">
<Label htmlFor="res-type">{t('resources.type')}</Label>
<Select value={resourceType} onValueChange={(v) => setResourceType(v as ResourceType)}>
<SelectTrigger id="res-type" className="min-h-[44px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{RESOURCE_TYPES.map((rt) => (
<SelectItem key={rt} value={rt}>{getTypeLabel(rt)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="res-desc">{t('resources.description_field')}</Label>
<Textarea id="res-desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description..." rows={3} />
</div>
<div className="space-y-2">
<Label htmlFor="res-url">{t('resources.url_field')}</Label>
<Input id="res-url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." className="min-h-[44px]" />
</div>
{(resourceType === 'document' || resourceType === 'worksheet') && (
<div className="space-y-2">
<Label htmlFor="res-content">{t('resources.content_field')}</Label>
<Textarea id="res-content" value={content} onChange={(e) => setContent(e.target.value)} placeholder="Text content..." rows={4} />
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="res-subject">{t('resources.subject_field')}</Label>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger id="res-subject" className="min-h-[44px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('resources.all_subjects')}</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="res-class">{t('resources.class_field')}</Label>
<Select value={classGroupId} onValueChange={setClassGroupId}>
<SelectTrigger id="res-class" className="min-h-[44px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">—</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="res-grade">{t('resources.grade_field')}</Label>
<Input id="res-grade" type="number" min={1} max={13} value={gradeLevel} onChange={(e) => setGradeLevel(e.target.value)} className="min-h-[44px]" />
</div>
<div className="space-y-2">
<Label htmlFor="res-tags">{t('resources.tags_field')}</Label>
<Input id="res-tags" value={tagsInput} onChange={(e) => setTagsInput(e.target.value)} placeholder="tag1, tag2..." className="min-h-[44px]" />
</div>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="res-public" className="flex items-center gap-2 min-h-[44px]">
{isPublic ? <Globe className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
{isPublic ? t('resources.public') : t('resources.private')}
</Label>
<Switch id="res-public" checked={isPublic} onCheckedChange={setIsPublic} />
</div>
</div>
<div className="flex items-center gap-2 justify-end">
<Button variant="outline" size="sm" onClick={onClose} className="min-h-[44px]">
{t('action.cancel')}
</Button>
<Button size="sm" onClick={handleSave} disabled={saving || !title.trim()} className="min-h-[44px]">
{saving ? '...' : t('action.save')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
// ── Resource Preview Dialog ────────────────────────────────────────────
function PreviewDialog({
open,
onClose,
resource,
}: {
open: boolean;
onClose: () => void;
resource: ResourceItem | null;
}) {
if (!resource) return null;
const typeIcon = getTypeIcon(resource.resourceType);
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{React.createElement(typeIcon, { className: 'h-5 w-5' })}
{resource.title}
</DialogTitle>
<DialogDescription>
{resource.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-2 flex-wrap">
<Badge className={getTypeColor(resource.resourceType)}>
{getTypeLabel(resource.resourceType)}
</Badge>
{resource.subject && (
<Badge variant="outline">{resource.subject.name}</Badge>
)}
{resource.isPublic ? (
<Badge variant="outline" className="gap-1"><Globe className="h-3 w-3" /> {t('resources.public')}</Badge>
) : (
<Badge variant="outline" className="gap-1"><Lock className="h-3 w-3" /> {t('resources.private')}</Badge>
)}
</div>
{resource.content && (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 text-sm whitespace-pre-wrap max-h-64 overflow-y-auto">
{resource.content}
</div>
)}
{resource.url && (
<div className="space-y-2">
<Label>{t('resources.url_field')}</Label>
<a
href={resource.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-emerald-600 dark:text-emerald-400 hover:underline break-all"
>
{resource.url}
</a>
</div>
)}
{resource.tags && (() => {
try {
const parsed = JSON.parse(resource.tags);
if (Array.isArray(parsed) && parsed.length > 0) {
return (
<div className="flex items-center gap-2 flex-wrap">
<Tag className="h-3 w-3 text-muted-foreground" />
{parsed.map((tag: string, i: number) => (
<Badge key={i} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>
);
}
} catch { /* ignore */ }
return null;
})()}
<div className="text-xs text-muted-foreground flex items-center gap-4">
<span className="flex items-center gap-1">
<Download className="h-3 w-3" /> {resource.downloadCount} {t('resources.downloads')}
</span>
<span>
{resource.author.firstName} {resource.author.lastName}
</span>
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ── Main View ──────────────────────────────────────────────────────────
export default function ResourceLibraryView() {
const currentUser = useAppStore((s) => s.currentUser);
const locale = useAppStore((s) => s.locale);
const [resources, setResources] = useState<ResourceItem[]>([]);
const [subjects, setSubjects] = useState<SubjectOption[]>([]);
const [classes, setClasses] = useState<ClassOption[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [filterType, setFilterType] = useState<string>('all');
const [filterSubject, setFilterSubject] = useState<string>('all');
const [dialogOpen, setDialogOpen] = useState(false);
const [editResource, setEditResource] = useState<ResourceItem | null>(null);
const [previewResource, setPreviewResource] = useState<ResourceItem | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [favorites, setFavorites] = useState<Set<string>>(new Set());
const schoolId = currentUser?.schoolId ?? '';
// Load favorites from localStorage
useEffect(() => {
try {
const stored = localStorage.getItem('ct_resource_favorites');
if (stored) setFavorites(new Set(JSON.parse(stored)));
} catch { /* ignore */ }
}, []);
// Load subjects
useEffect(() => {
if (!schoolId) return;
apiGet<SubjectOption[]>(`/api/subjects?schoolId=${schoolId}`)
.then((data) => setSubjects(data))
.catch(() => {});
}, [schoolId]);
// Load classes
useEffect(() => {
if (!schoolId) return;
apiGet<ClassOption[]>(`/api/classes?schoolId=${schoolId}`)
.then((data) => setClasses(data))
.catch(() => {});
}, [schoolId]);
// Load resources
const loadResources = useCallback(async () => {
if (!schoolId) {
setResources([]);
setLoading(false);
return;
}
setLoading(true);
try {
const params = new URLSearchParams({ schoolId });
if (filterType && filterType !== 'all') params.set('resourceType', filterType);
if (filterSubject && filterSubject !== 'all') params.set('subjectId', filterSubject);
if (searchQuery) params.set('search', searchQuery);
const data = await apiGet<ResourceItem[]>(`/api/resources?${params.toString()}`);
setResources(data);
} catch {
setResources([]);
}
setLoading(false);
}, [schoolId, filterType, filterSubject, searchQuery]);
useEffect(() => {
loadResources();
}, [loadResources]);
// Handlers
const handleSaveResource = async (data: Record<string, unknown>) => {
try {
if (data.id) {
await apiPut(`/api/resources/${data.id}`, data);
} else {
await apiPost('/api/resources', data);
}
await loadResources();
setDialogOpen(false);
setEditResource(null);
} catch (err) {
console.error('Error saving resource:', err);
}
};
const handleDeleteResource = async (id: string) => {
try {
await apiDelete(`/api/resources/${id}`);
await loadResources();
} catch (err) {
console.error('Error deleting resource:', err);
}
};
const handleDownload = async (resource: ResourceItem) => {
try {
await apiPut(`/api/resources/${resource.id}`, { incrementDownload: true });
if (resource.url) {
window.open(resource.url, '_blank');
}
await loadResources();
} catch (err) {
console.error('Error downloading resource:', err);
}
};
const toggleFavorite = (id: string) => {
const newFavs = new Set(favorites);
if (newFavs.has(id)) {
newFavs.delete(id);
} else {
newFavs.add(id);
}
setFavorites(newFavs);
try {
localStorage.setItem('ct_resource_favorites', JSON.stringify([...newFavs]));
} catch { /* ignore */ }
};
const togglePublic = async (resource: ResourceItem) => {
try {
await apiPut(`/api/resources/${resource.id}`, { isPublic: !resource.isPublic });
await loadResources();
} catch (err) {
console.error('Error toggling public:', err);
}
};
// Sort: favorites first, then by date
const sortedResources = useMemo(() => {
return [...resources].sort((a, b) => {
const aFav = favorites.has(a.id) ? 1 : 0;
const bFav = favorites.has(b.id) ? 1 : 0;
if (bFav !== aFav) return bFav - aFav;
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
}, [resources, favorites]);
// ── Render ────────────────────────────────────────────────────────
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<Library className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
<h1 className="text-2xl font-bold">{t('resources.library')}</h1>
</div>
<Button
className="min-h-[44px]"
onClick={() => {
setEditResource(null);
setDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-1" />
{t('resources.create')}
</Button>
</div>
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="pl-9 min-h-[44px]"
placeholder={t('resources.search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="w-[160px] min-h-[44px]">
<SelectValue placeholder={t('resources.filter_type')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('resources.all_types')}</SelectItem>
{RESOURCE_TYPES.map((rt) => (
<SelectItem key={rt} value={rt}>{getTypeLabel(rt)}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterSubject} onValueChange={setFilterSubject}>
<SelectTrigger className="w-[160px] min-h-[44px]">
<SelectValue placeholder={t('resources.filter_subject')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('resources.all_subjects')}</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Resource Grid */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
) : sortedResources.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Library className="h-8 w-8 mx-auto mb-2 opacity-30" />
{t('resources.no_resources')}
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<AnimatePresence>
{sortedResources.map((resource) => {
const TypeIcon = getTypeIcon(resource.resourceType);
const isFav = favorites.has(resource.id);
return (
<motion.div
key={resource.id}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
<Card className="h-full flex flex-col hover:shadow-md transition-shadow">
<CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className={`p-1.5 rounded-md ${getTypeColor(resource.resourceType)}`}>
<TypeIcon className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold truncate" title={resource.title}>
{resource.title}
</CardTitle>
</div>
<button
className="shrink-0 p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-h-[44px] min-w-[44px] flex items-center justify-center"
onClick={() => toggleFavorite(resource.id)}
aria-label={t('resources.favorite')}
>
<Star className={`h-4 w-4 ${isFav ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground'}`} />
</button>
</div>
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
{resource.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
{resource.description}
</p>
)}
<div className="flex items-center gap-2 flex-wrap">
<Badge className={`text-xs ${getTypeColor(resource.resourceType)}`}>
{getTypeLabel(resource.resourceType)}
</Badge>
{resource.subject && (
<Badge variant="outline" className="text-xs">{resource.subject.name}</Badge>
)}
</div>
{resource.tags && (() => {
try {
const parsed = JSON.parse(resource.tags);
if (Array.isArray(parsed) && parsed.length > 0) {
return (
<div className="flex items-center gap-1 mt-2 flex-wrap">
{parsed.slice(0, 3).map((tag: string, i: number) => (
<Badge key={i} variant="secondary" className="text-xs">{tag}</Badge>
))}
{parsed.length > 3 && (
<span className="text-xs text-muted-foreground">+{parsed.length - 3}</span>
)}
</div>
);
}
} catch { /* ignore */ }
return null;
})()}
</CardContent>
<CardFooter className="p-4 pt-0 flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Download className="h-3 w-3" />
{resource.downloadCount}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 min-h-[44px] min-w-[44px]"
onClick={() => { setPreviewResource(resource); setPreviewOpen(true); }}
title={t('resources.preview')}
>
<Eye className="h-4 w-4" />
</Button>
{resource.url && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 min-h-[44px] min-w-[44px]"
onClick={() => handleDownload(resource)}
title={t('resources.download')}
>
<Download className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 min-h-[44px] min-w-[44px]"
onClick={() => togglePublic(resource)}
title={t('resources.toggle_public')}
>
{resource.isPublic ? <Globe className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
</Button>
{currentUser?.id === resource.authorId && (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 min-h-[44px] min-w-[44px]"
onClick={() => { setEditResource(resource); setDialogOpen(true); }}
title={t('action.edit')}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 min-h-[44px] min-w-[44px] text-destructive"
onClick={() => handleDeleteResource(resource.id)}
title={t('action.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
</CardFooter>
</Card>
</motion.div>
);
})}
</AnimatePresence>
</div>
)}
{/* Resource Create/Edit Dialog */}
<ResourceDialog
open={dialogOpen}
onClose={() => { setDialogOpen(false); setEditResource(null); }}
resource={editResource}
subjects={subjects}
classes={classes}
schoolId={schoolId}
onSave={handleSaveResource}
/>
{/* Preview Dialog */}
<PreviewDialog
open={previewOpen}
onClose={() => { setPreviewOpen(false); setPreviewResource(null); }}
resource={previewResource}
/>
</div>
);
}
+668
View File
@@ -0,0 +1,668 @@
'use client';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Clock,
Printer,
Plus,
Trash2,
GripVertical,
Edit3,
X,
ChevronDown,
ChevronUp,
Coffee,
BookOpen,
} from 'lucide-react';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
// ── Subject Color Palette ──────────────────────────────────────────────
const SUBJECT_COLORS: Record<string, string> = {
'Deutsch': 'bg-rose-100 text-rose-800 border-rose-300 dark:bg-rose-900/40 dark:text-rose-200 dark:border-rose-800',
'Mathematik': 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/40 dark:text-blue-200 dark:border-blue-800',
'Englisch': 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-900/40 dark:text-violet-200 dark:border-violet-800',
'Sachunterricht': 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-900/40 dark:text-emerald-200 dark:border-emerald-800',
'Musik': 'bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-900/40 dark:text-orange-200 dark:border-orange-800',
'Sport': 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-900/40 dark:text-teal-200 dark:border-teal-800',
'Religion': 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/40 dark:text-amber-200 dark:border-amber-800',
'Kunst': 'bg-fuchsia-100 text-fuchsia-800 border-fuchsia-300 dark:bg-fuchsia-900/40 dark:text-fuchsia-200 dark:border-fuchsia-800',
'German': 'bg-rose-100 text-rose-800 border-rose-300 dark:bg-rose-900/40 dark:text-rose-200 dark:border-rose-800',
'Mathematics': 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/40 dark:text-blue-200 dark:border-blue-800',
'English': 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-900/40 dark:text-violet-200 dark:border-violet-800',
'Science': 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-900/40 dark:text-emerald-200 dark:border-emerald-800',
'Music': 'bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-900/40 dark:text-orange-200 dark:border-orange-800',
'Physical Education': 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-900/40 dark:text-teal-200 dark:border-teal-800',
'Religion/Ethics': 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/40 dark:text-amber-200 dark:border-amber-800',
'Art': 'bg-fuchsia-100 text-fuchsia-800 border-fuchsia-300 dark:bg-fuchsia-900/40 dark:text-fuchsia-200 dark:border-fuchsia-800',
};
const FALLBACK_COLOR = 'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-900/40 dark:text-gray-200 dark:border-gray-800';
const BREAK_COLOR = 'bg-gray-50 text-gray-500 border-gray-200 dark:bg-gray-800/30 dark:text-gray-400 dark:border-gray-700';
function getSubjectColor(subjectName: string | null): string {
if (!subjectName) return FALLBACK_COLOR;
return SUBJECT_COLORS[subjectName] ?? FALLBACK_COLOR;
}
// ── Types ──────────────────────────────────────────────────────────────
interface TimetableSlot {
id: string;
schoolId: string;
classGroupId: string;
dayOfWeek: number;
period: number;
subjectId: string | null;
teacherId: string | null;
roomId: string | null;
startTime: string;
endTime: string;
isBreak: boolean;
classGroup: { id: string; name: string; gradeLevel: number };
subject: { id: string; name: string } | null;
teacher: { id: string; firstName: string; lastName: string } | null;
}
interface SubjectOption {
id: string;
name: string;
}
interface TeacherOption {
id: string;
firstName: string;
lastName: string;
}
interface ClassOption {
id: string;
name: string;
gradeLevel: number;
}
const DAYS = [0, 1, 2, 3, 4]; // Mon-Fri
const PERIODS = [1, 2, 3, 4, 5, 6, 7, 8];
const DEFAULT_TIMES: Record<number, { start: string; end: string }> = {
1: { start: '08:00', end: '08:45' },
2: { start: '08:45', end: '09:30' },
3: { start: '09:30', end: '10:15' },
4: { start: '10:15', end: '11:00' },
5: { start: '11:00', end: '11:45' },
6: { start: '11:45', end: '12:30' },
7: { start: '13:00', end: '13:45' },
8: { start: '13:45', end: '14:30' },
};
function getDayLabel(dayOfWeek: number): string {
const keys = ['timetable.monday', 'timetable.tuesday', 'timetable.wednesday', 'timetable.thursday', 'timetable.friday'];
return t(keys[dayOfWeek] ?? 'timetable.day');
}
// ── Slot Dialog ────────────────────────────────────────────────────────
function SlotDialog({
open,
onClose,
slot,
subjects,
teachers,
schoolId,
classGroupId,
dayOfWeek,
period,
onSave,
onDelete,
}: {
open: boolean;
onClose: () => void;
slot: TimetableSlot | null;
subjects: SubjectOption[];
teachers: TeacherOption[];
schoolId: string;
classGroupId: string;
dayOfWeek: number;
period: number;
onSave: (data: Record<string, unknown>) => void;
onDelete: (id: string) => void;
}) {
const [subjectId, setSubjectId] = useState<string>('none');
const [teacherId, setTeacherId] = useState<string>('none');
const [roomId, setRoomId] = useState('');
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('');
const [isBreak, setIsBreak] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (slot) {
setSubjectId(slot.subjectId ?? 'none');
setTeacherId(slot.teacherId ?? 'none');
setRoomId(slot.roomId ?? '');
setStartTime(slot.startTime);
setEndTime(slot.endTime);
setIsBreak(slot.isBreak);
} else {
setSubjectId('none');
setTeacherId('none');
setRoomId('');
const defaultTime = DEFAULT_TIMES[period];
setStartTime(defaultTime?.start ?? '08:00');
setEndTime(defaultTime?.end ?? '08:45');
setIsBreak(false);
}
}, [slot, period, open]);
const handleSave = async () => {
setSaving(true);
try {
const data: Record<string, unknown> = {
schoolId,
classGroupId,
dayOfWeek,
period,
subjectId: subjectId === 'none' ? null : subjectId,
teacherId: teacherId === 'none' ? null : teacherId,
roomId: roomId || null,
startTime,
endTime,
isBreak,
};
if (slot) {
data.id = slot.id;
}
onSave(data);
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{slot ? t('timetable.edit_slot') : t('timetable.add_slot')}
</DialogTitle>
<DialogDescription>
{getDayLabel(dayOfWeek)} — {t('timetable.period_num', { n: period })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-3">
<Label htmlFor="slot-break" className="flex items-center gap-2 min-h-[44px]">
<Coffee className="h-4 w-4" />
{t('timetable.break')}
</Label>
<Switch id="slot-break" checked={isBreak} onCheckedChange={setIsBreak} />
</div>
{!isBreak && (
<>
<div className="space-y-2">
<Label htmlFor="slot-subject">{t('timetable.subject')}</Label>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger id="slot-subject" className="min-h-[44px]">
<SelectValue placeholder={t('timetable.no_subject')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('timetable.no_subject')}</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="slot-teacher">{t('timetable.teacher')}</Label>
<Select value={teacherId} onValueChange={setTeacherId}>
<SelectTrigger id="slot-teacher" className="min-h-[44px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">—</SelectItem>
{teachers.map((tch) => (
<SelectItem key={tch.id} value={tch.id}>
{tch.firstName} {tch.lastName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="slot-room">{t('timetable.room')}</Label>
<Input id="slot-room" value={roomId} onChange={(e) => setRoomId(e.target.value)} placeholder="Room..." className="min-h-[44px]" />
</div>
</>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="slot-start">{t('timetable.start_time')}</Label>
<Input id="slot-start" type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} className="min-h-[44px]" />
</div>
<div className="space-y-2">
<Label htmlFor="slot-end">{t('timetable.end_time')}</Label>
<Input id="slot-end" type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} className="min-h-[44px]" />
</div>
</div>
</div>
<div className="flex items-center gap-2 justify-end">
{slot && (
<Button
variant="destructive"
size="sm"
className="min-h-[44px]"
onClick={() => { onDelete(slot.id); onClose(); }}
>
<Trash2 className="h-4 w-4 mr-1" />
{t('timetable.delete_slot')}
</Button>
)}
<Button variant="outline" size="sm" onClick={onClose} className="min-h-[44px]">
{t('action.cancel')}
</Button>
<Button size="sm" onClick={handleSave} disabled={saving} className="min-h-[44px]">
{saving ? '...' : t('action.save')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
// ── Main View ──────────────────────────────────────────────────────────
export default function TimetableView() {
const currentUser = useAppStore((s) => s.currentUser);
const locale = useAppStore((s) => s.locale);
const [slots, setSlots] = useState<TimetableSlot[]>([]);
const [classes, setClasses] = useState<ClassOption[]>([]);
const [subjects, setSubjects] = useState<SubjectOption[]>([]);
const [teachers, setTeachers] = useState<TeacherOption[]>([]);
const [selectedClassId, setSelectedClassId] = useState<string>('');
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editSlot, setEditSlot] = useState<TimetableSlot | null>(null);
const [dialogDay, setDialogDay] = useState(0);
const [dialogPeriod, setDialogPeriod] = useState(1);
const [dragSlot, setDragSlot] = useState<string | null>(null);
const [dragOverCell, setDragOverCell] = useState<{ day: number; period: number } | null>(null);
const schoolId = currentUser?.schoolId ?? '';
// Load classes
useEffect(() => {
if (!schoolId) return;
apiGet<ClassOption[]>(`/api/classes?schoolId=${schoolId}`)
.then((data) => {
setClasses(data);
if (data.length > 0 && !selectedClassId) {
setSelectedClassId(data[0].id);
}
})
.catch(() => {});
}, [schoolId]);
// Load subjects
useEffect(() => {
if (!schoolId) return;
apiGet<SubjectOption[]>(`/api/subjects?schoolId=${schoolId}`)
.then((data) => setSubjects(data))
.catch(() => {});
}, [schoolId]);
// Load teachers
useEffect(() => {
if (!schoolId) return;
apiGet<{ id: string; firstName: string; lastName: string; role: string }[]>(`/api/users?schoolId=${schoolId}`)
.then((data) => {
const teacherList = data.filter((u) => u.role === 'TEACHER' || u.role === 'SCHOOL_ADMIN');
setTeachers(teacherList);
})
.catch(() => {});
}, [schoolId]);
// Load timetable slots
const loadSlots = useCallback(async () => {
if (!schoolId || !selectedClassId) {
setSlots([]);
setLoading(false);
return;
}
setLoading(true);
try {
const data = await apiGet<TimetableSlot[]>(`/api/timetable?schoolId=${schoolId}&classGroupId=${selectedClassId}`);
setSlots(data);
} catch {
setSlots([]);
}
setLoading(false);
}, [schoolId, selectedClassId]);
useEffect(() => {
loadSlots();
}, [loadSlots]);
// Build grid lookup
const gridMap = useMemo(() => {
const map: Record<string, TimetableSlot> = {};
for (const slot of slots) {
map[`${slot.dayOfWeek}-${slot.period}`] = slot;
}
return map;
}, [slots]);
// Handlers
const handleSaveSlot = async (data: Record<string, unknown>) => {
try {
if (data.id) {
// Update existing
await apiPut<TimetableSlot>(`/api/timetable/${data.id}`, {
subjectId: data.subjectId,
teacherId: data.teacherId,
roomId: data.roomId,
startTime: data.startTime,
endTime: data.endTime,
isBreak: data.isBreak,
});
} else {
// Create new
await apiPost<TimetableSlot>('/api/timetable', data);
}
await loadSlots();
setDialogOpen(false);
setEditSlot(null);
} catch (err) {
console.error('Error saving slot:', err);
}
};
const handleDeleteSlot = async (id: string) => {
try {
await apiDelete(`/api/timetable/${id}`);
await loadSlots();
} catch (err) {
console.error('Error deleting slot:', err);
}
};
const handleDragStart = (slotId: string) => {
setDragSlot(slotId);
};
const handleDragEnd = () => {
setDragSlot(null);
setDragOverCell(null);
};
const handleDragOver = (day: number, period: number) => {
setDragOverCell({ day, period });
};
const handleDrop = async (targetDay: number, targetPeriod: number) => {
if (!dragSlot) return;
try {
await apiPut(`/api/timetable/${dragSlot}`, {
dayOfWeek: targetDay,
period: targetPeriod,
});
await loadSlots();
} catch (err) {
console.error('Error moving slot:', err);
}
setDragSlot(null);
setDragOverCell(null);
};
const handlePrint = () => {
window.print();
};
// ── Render ────────────────────────────────────────────────────────
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<Clock className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
<h1 className="text-2xl font-bold">{t('timetable.title')}</h1>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Select value={selectedClassId} onValueChange={setSelectedClassId}>
<SelectTrigger className="w-[180px] min-h-[44px]">
<SelectValue placeholder={t('timetable.select_class')} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name} (Grade {c.gradeLevel})</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
className="min-h-[44px]"
onClick={handlePrint}
>
<Printer className="h-4 w-4 mr-1" />
{t('timetable.print')}
</Button>
</div>
</div>
{/* Timetable Grid */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
) : !selectedClassId ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
{t('timetable.select_class')}
</CardContent>
</Card>
) : (
<Card className="print:shadow-none print:border-0">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full border-collapse min-w-[640px]">
{/* Header row: Day names */}
<thead>
<tr className="bg-emerald-50 dark:bg-emerald-950/30">
<th className="p-2 text-sm font-semibold text-left w-[80px] border border-gray-200 dark:border-gray-700">
{t('timetable.period')}
</th>
<th className="p-2 text-sm font-semibold text-center border border-gray-200 dark:border-gray-700">
{t('timetable.start_time')} / {t('timetable.end_time')}
</th>
{DAYS.map((day) => (
<th key={day} className="p-2 text-sm font-semibold text-center border border-gray-200 dark:border-gray-700 min-w-[120px]">
{getDayLabel(day)}
</th>
))}
</tr>
</thead>
<tbody>
{PERIODS.map((period) => {
const defaultTime = DEFAULT_TIMES[period];
const slotInRow = slots.find((s) => s.period === period);
const timeDisplay = slotInRow
? `${slotInRow.startTime}–${slotInRow.endTime}`
: `${defaultTime?.start ?? ''}–${defaultTime?.end ?? ''}`;
// Check if this is a break row (common convention: period 3 is break)
const isBreakRow = PERIODS.indexOf(period) === 2; // between period 3 and 4
return (
<React.Fragment key={period}>
<tr className="hover:bg-gray-50/50 dark:hover:bg-gray-800/20">
<td className="p-2 text-sm font-medium text-center border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/30">
{period}
</td>
<td className="p-2 text-xs text-muted-foreground text-center border border-gray-200 dark:border-gray-700">
{timeDisplay}
</td>
{DAYS.map((day) => {
const cellSlot = gridMap[`${day}-${period}`];
const isDragOver = dragOverCell?.day === day && dragOverCell?.period === period;
const isDragTarget = dragSlot && !cellSlot;
return (
<td
key={`${day}-${period}`}
className={`p-1 border border-gray-200 dark:border-gray-700 transition-colors ${
isDragOver && isDragTarget ? 'bg-emerald-50 dark:bg-emerald-950/40' : ''
} ${cellSlot?.isBreak ? BREAK_COLOR.split(' ')[0] : ''}`}
onDragOver={(e) => { e.preventDefault(); handleDragOver(day, period); }}
onDragLeave={() => setDragOverCell(null)}
onDrop={() => handleDrop(day, period)}
>
{cellSlot ? (
<motion.div
layout
draggable
onDragStart={() => handleDragStart(cellSlot.id)}
onDragEnd={handleDragEnd}
className={`rounded-md p-2 cursor-pointer min-h-[44px] flex flex-col gap-0.5 border ${cellSlot.isBreak ? BREAK_COLOR : getSubjectColor(cellSlot.subject?.name)}`}
onClick={() => {
setEditSlot(cellSlot);
setDialogDay(day);
setDialogPeriod(period);
setDialogOpen(true);
}}
>
<div className="flex items-center justify-between">
<span className="font-medium text-xs truncate">
{cellSlot.isBreak
? t('timetable.break_period')
: cellSlot.subject?.name ?? t('timetable.no_subject')}
</span>
<GripVertical className="h-3 w-3 opacity-40 shrink-0" />
</div>
{!cellSlot.isBreak && cellSlot.teacher && (
<span className="text-xs opacity-70 truncate">
{cellSlot.teacher.firstName} {cellSlot.teacher.lastName}
</span>
)}
{cellSlot.roomId && (
<span className="text-xs opacity-60 truncate">
R{cellSlot.roomId}
</span>
)}
</motion.div>
) : (
<button
className="w-full h-full min-h-[44px] flex items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-gray-800/30 transition-colors cursor-pointer"
onClick={() => {
setEditSlot(null);
setDialogDay(day);
setDialogPeriod(period);
setDialogOpen(true);
}}
aria-label={`${t('timetable.add_slot')} - ${getDayLabel(day)} Period ${period}`}
>
<Plus className="h-4 w-4 text-muted-foreground opacity-30" />
</button>
)}
</td>
);
})}
</tr>
{/* Break row between period 3 and 4 */}
{isBreakRow && (
<tr className="bg-gray-100 dark:bg-gray-800/20">
<td className="p-2 text-xs text-center border border-gray-200 dark:border-gray-700">
<Coffee className="h-3 w-3 mx-auto text-gray-400" />
</td>
<td className="p-2 text-xs text-muted-foreground text-center border border-gray-200 dark:border-gray-700">
10:15–10:30
</td>
{DAYS.map((day) => (
<td
key={`break-${day}`}
className="p-1 border border-gray-200 dark:border-gray-700"
>
<div className={`rounded-md p-2 min-h-[28px] flex items-center justify-center border ${BREAK_COLOR}`}>
<span className="text-xs font-medium">{t('timetable.break_period')}</span>
</div>
</td>
))}
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
{slots.length === 0 && (
<div className="py-8 text-center text-muted-foreground">
<BookOpen className="h-8 w-8 mx-auto mb-2 opacity-30" />
{t('timetable.no_slots')}
</div>
)}
</CardContent>
</Card>
)}
{/* Drag hint */}
{slots.length > 0 && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<GripVertical className="h-3 w-3" />
{t('timetable.drag_hint')}
</p>
)}
{/* Slot Dialog */}
<SlotDialog
open={dialogOpen}
onClose={() => { setDialogOpen(false); setEditSlot(null); }}
slot={editSlot}
subjects={subjects}
teachers={teachers}
schoolId={schoolId}
classGroupId={selectedClassId}
dayOfWeek={dialogDay}
period={dialogPeriod}
onSave={handleSaveSlot}
onDelete={handleDeleteSlot}
/>
</div>
);
}
+61
View File
@@ -1175,6 +1175,56 @@ export interface AnalyticsAtRiskStudent {
latestEntryDate: string;
}
export interface AnalyticsSubjectMasteryPoint {
categoryId: string;
categoryName: string;
avgMastery: number;
entryCount: number;
}
export interface AnalyticsTeacherPerformancePoint {
teacherId: string;
teacherName: string;
assessmentCount: number;
progressCount: number;
improvementRate: number;
notebookCount: number;
}
export interface AnalyticsSelfVsTeacherAgg {
avgSelfLevel: number;
avgTeacherLevel: number;
avgGap: number;
totalComparisons: number;
}
export interface AnalyticsGapAnalysisPoint {
studentId: string;
studentName: string;
competencyId: string;
selfLevel: number;
teacherLevel: number;
gap: number;
}
export interface AnalyticsExcellingStudent {
studentId: string;
studentName: string;
className: string;
latestMastery: number;
improvement: number;
}
export interface AnalyticsClassRadarPoint {
classId: string;
className: string;
mastery: number;
attendance: number;
engagement: number;
progress: number;
behavior: number;
}
export interface AnalyticsData {
masteryTrend: AnalyticsMasteryTrendPoint[];
classComparison: AnalyticsClassComparisonPoint[];
@@ -1187,6 +1237,17 @@ export interface AnalyticsData {
totalEntries: number;
totalStudents: number;
overallAvgMastery: number;
// New fields
totalClasses: number;
totalTeachers: number;
totalCompetencies: number;
competencyCoveragePct: number;
subjectMasteryAvg: AnalyticsSubjectMasteryPoint[];
teacherPerformance: AnalyticsTeacherPerformancePoint[];
selfVsTeacherAgg: AnalyticsSelfVsTeacherAgg | null;
gapAnalysis: AnalyticsGapAnalysisPoint[];
excellingStudents: AnalyticsExcellingStudent[];
classRadarData: AnalyticsClassRadarPoint[];
}
export function fetchAnalytics(params: {
+278
View File
@@ -2591,6 +2591,8 @@ const de: TranslationDict = {
'portfolio.all_types': 'Alle Arten',
'portfolio.add_new': 'Neuer Eintrag',
'nav.portfolio': 'Portfolio',
'nav.timetable': 'Stundenplan',
'nav.resources': 'Mediathek',
// Navigation - Homework
'nav.homework': 'Hausaufgaben',
@@ -2703,6 +2705,143 @@ const de: TranslationDict = {
'announcement.target_parents': 'Eltern',
'announcement.no_announcements': 'Keine Ankündigungen vorhanden',
'announcement.dismiss': 'Ausblenden',
// ─── Advanced Analytics ──────────────────────────────────────────
'analytics.school_overview': 'Schulübersicht',
'analytics.student_performance': 'Schülerleistung',
'analytics.teacher_performance': 'Lehrerleistung',
'analytics.predictive': 'Prognose',
'analytics.at_risk': 'Gefährdet',
'analytics.excelling': 'Hervorragend',
'analytics.needs_attention': 'Aufmerksamkeit erforderlich',
'analytics.export_chart': 'Diagramm exportieren',
'analytics.export_csv': 'CSV exportieren',
'analytics.print_report': 'Bericht drucken',
'analytics.student_teacher_ratio': 'Schüler-Lehrer-Ratio',
'analytics.competency_coverage': 'Kompetenzabdeckung',
'analytics.avg_mastery_subject': 'Ø Kompetenz je Fach',
'analytics.attendance_trend': 'Anwesenheitsverlauf',
'analytics.behavior_trend': 'Verhaltensverlauf',
'analytics.mastery_histogram': 'Kompetenzverteilung (Histogram)',
'analytics.progress_over_time': 'Verlauf über Zeit',
'analytics.subject_comparison': 'Fachvergleich',
'analytics.assessment_distribution': 'Bewertungsverteilung',
'analytics.self_vs_teacher': 'Selbst- vs. Lehrerbewertung',
'analytics.gap_analysis': 'Gap-Analyse',
'analytics.self_assessment': 'Selbstbewertung',
'analytics.teacher_assessment': 'Lehrerbewertung',
'analytics.radar_comparison': 'Radarvergleich',
'analytics.heatmap_coverage': 'Kompetenzabdeckung (Heatmap)',
'analytics.ranking_table': 'Ranking-Tabelle',
'analytics.assessments_per_teacher': 'Bewertungen pro Lehrkraft',
'analytics.progress_per_teacher': 'Fortschritt pro Lehrkraft',
'analytics.improvement_rate': 'Verbesserungsrate',
'analytics.notebook_usage': 'Notizbuchnutzung',
'analytics.students_at_risk': 'Gefährdete Schüler:innen',
'analytics.students_excelling': 'Hervorragende Schüler:innen',
'analytics.areas_needing_attention': 'Bereiche mit Förderbedarf',
'analytics.risk_trend': 'Risikoentwicklung',
'analytics.excelling_trend': 'Entwicklung hervorragender Schüler:innen',
'analytics.attendance_rate': 'Anwesenheitsrate',
'analytics.behavior_incidents': 'Verhaltensvorfälle',
'analytics.total_classes': 'Klassen',
'analytics.total_teachers': 'Lehrkräfte',
'analytics.total_competencies': 'Kompetenzen',
'analytics.coverage_pct': 'Abgedeckt',
'analytics.uncovered_pct': 'Offen',
'analytics.score': 'Punktzahl',
'analytics.gap': 'Differenz',
'analytics.rank': 'Rang',
'analytics.entries': 'Einträge',
'analytics.improvement': 'Verbesserung',
'analytics.export_png': 'PNG exportieren',
'analytics.no_export_data': 'Keine Daten zum Exportieren',
'analytics.class_dim_mastery': 'Kompetenz',
'analytics.class_dim_attendance': 'Anwesenheit',
'analytics.class_dim_engagement': 'Engagement',
'analytics.class_dim_progress': 'Fortschritt',
'analytics.class_dim_behavior': 'Verhalten',
'analytics.self_level': 'Selbstniveau',
'analytics.teacher_level': 'Lehrerniveau',
'analytics.section_school': 'Schulübersicht',
'analytics.section_performance': 'Leistung',
'analytics.section_class': 'Klassen',
'analytics.section_teacher': 'Lehrkräfte',
'analytics.section_predictive': 'Prognose',
'analytics.section_export': 'Export',
// ─── Accessibility ───────────────────────────────────────────────
'accessibility.skip_to_content': 'Zum Inhalt springen',
'accessibility.high_contrast': 'Hoher Kontrast',
'accessibility.reduced_motion': 'Reduzierte Bewegung',
'accessibility.keyboard_shortcuts': 'Tastaturkurzbefehle',
'accessibility.shortcut_desc': 'Tastaturkurzbefehle für die Navigation',
// Timetable
'timetable.title': 'Stundenplan',
'timetable.monday': 'Montag',
'timetable.tuesday': 'Dienstag',
'timetable.wednesday': 'Mittwoch',
'timetable.thursday': 'Donnerstag',
'timetable.friday': 'Freitag',
'timetable.period': 'Stunde',
'timetable.break': 'Pause',
'timetable.add_slot': 'Stunde hinzufügen',
'timetable.edit_slot': 'Stunde bearbeiten',
'timetable.no_subject': 'Kein Fach',
'timetable.print': 'Stundenplan drucken',
'timetable.class_view': 'Klassensicht',
'timetable.room': 'Raum',
'timetable.start_time': 'Beginn',
'timetable.end_time': 'Ende',
'timetable.subject': 'Fach',
'timetable.teacher': 'Lehrkraft',
'timetable.day': 'Tag',
'timetable.period_num': 'Stunde {n}',
'timetable.no_slots': 'Keine Stunden vorhanden',
'timetable.select_class': 'Klasse auswählen',
'timetable.batch_create': 'Stundenplan erstellen',
'timetable.delete_slot': 'Stunde löschen',
'timetable.break_period': 'Pause',
'timetable.drag_hint': 'Ziehen zum Verschieben',
// Resources
'resources.title': 'Ressourcen',
'resources.library': 'Mediathek',
'resources.upload': 'Hochladen',
'resources.type': 'Typ',
'resources.document': 'Dokument',
'resources.worksheet': 'Arbeitsblatt',
'resources.presentation': 'Präsentation',
'resources.video': 'Video',
'resources.link': 'Link',
'resources.image': 'Bild',
'resources.audio': 'Audio',
'resources.download': 'Herunterladen',
'resources.downloads': 'Downloads',
'resources.preview': 'Vorschau',
'resources.favorite': 'Favorit',
'resources.create': 'Ressource erstellen',
'resources.edit': 'Ressource bearbeiten',
'resources.search': 'Ressourcen suchen',
'resources.filter_type': 'Nach Typ filtern',
'resources.filter_subject': 'Nach Fach filtern',
'resources.all_types': 'Alle Typen',
'resources.all_subjects': 'Alle Fächer',
'resources.public': 'Öffentlich',
'resources.private': 'Privat',
'resources.toggle_public': 'Öffentlich/Privat',
'resources.no_resources': 'Keine Ressourcen vorhanden',
'resources.title_field': 'Titel',
'resources.description_field': 'Beschreibung',
'resources.url_field': 'URL',
'resources.content_field': 'Inhalt',
'resources.subject_field': 'Fach',
'resources.class_field': 'Klasse',
'resources.grade_field': 'Klassenstufe',
'resources.tags_field': 'Schlagwörter',
'resources.add_tag': 'Schlagwort hinzufügen',
'resources.delete_confirm': 'Ressource wirklich löschen?',
};
const en: TranslationDict = {
@@ -5293,6 +5432,8 @@ const en: TranslationDict = {
'portfolio.all_types': 'All Types',
'portfolio.add_new': 'New Entry',
'nav.portfolio': 'Portfolio',
'nav.timetable': 'Timetable',
'nav.resources': 'Resources',
// Navigation - Homework
'nav.homework': 'Homework',
@@ -5405,6 +5546,143 @@ const en: TranslationDict = {
'announcement.target_parents': 'Parents',
'announcement.no_announcements': 'No announcements available',
'announcement.dismiss': 'Dismiss',
// ─── Advanced Analytics ──────────────────────────────────────────
'analytics.school_overview': 'School Overview',
'analytics.student_performance': 'Student Performance',
'analytics.teacher_performance': 'Teacher Performance',
'analytics.predictive': 'Predictive',
'analytics.at_risk': 'At Risk',
'analytics.excelling': 'Excelling',
'analytics.needs_attention': 'Needs Attention',
'analytics.export_chart': 'Export Chart',
'analytics.export_csv': 'Export CSV',
'analytics.print_report': 'Print Report',
'analytics.student_teacher_ratio': 'Student-to-Teacher Ratio',
'analytics.competency_coverage': 'Competency Coverage',
'analytics.avg_mastery_subject': 'Avg Mastery per Subject',
'analytics.attendance_trend': 'Attendance Trend',
'analytics.behavior_trend': 'Behavior Trend',
'analytics.mastery_histogram': 'Mastery Distribution (Histogram)',
'analytics.progress_over_time': 'Progress Over Time',
'analytics.subject_comparison': 'Subject Comparison',
'analytics.assessment_distribution': 'Assessment Score Distribution',
'analytics.self_vs_teacher': 'Self-Assessment vs Teacher Assessment',
'analytics.gap_analysis': 'Gap Analysis',
'analytics.self_assessment': 'Self-Assessment',
'analytics.teacher_assessment': 'Teacher Assessment',
'analytics.radar_comparison': 'Radar Comparison',
'analytics.heatmap_coverage': 'Competency Coverage (Heatmap)',
'analytics.ranking_table': 'Ranking Table',
'analytics.assessments_per_teacher': 'Assessments per Teacher',
'analytics.progress_per_teacher': 'Progress per Teacher',
'analytics.improvement_rate': 'Improvement Rate',
'analytics.notebook_usage': 'Notebook Usage',
'analytics.students_at_risk': 'Students at Risk',
'analytics.students_excelling': 'Students Excelling',
'analytics.areas_needing_attention': 'Areas Needing Attention',
'analytics.risk_trend': 'Risk Trend',
'analytics.excelling_trend': 'Excelling Students Trend',
'analytics.attendance_rate': 'Attendance Rate',
'analytics.behavior_incidents': 'Behavior Incidents',
'analytics.total_classes': 'Classes',
'analytics.total_teachers': 'Teachers',
'analytics.total_competencies': 'Competencies',
'analytics.coverage_pct': 'Covered',
'analytics.uncovered_pct': 'Uncovered',
'analytics.score': 'Score',
'analytics.gap': 'Gap',
'analytics.rank': 'Rank',
'analytics.entries': 'Entries',
'analytics.improvement': 'Improvement',
'analytics.export_png': 'Export PNG',
'analytics.no_export_data': 'No data to export',
'analytics.class_dim_mastery': 'Mastery',
'analytics.class_dim_attendance': 'Attendance',
'analytics.class_dim_engagement': 'Engagement',
'analytics.class_dim_progress': 'Progress',
'analytics.class_dim_behavior': 'Behavior',
'analytics.self_level': 'Self Level',
'analytics.teacher_level': 'Teacher Level',
'analytics.section_school': 'School Overview',
'analytics.section_performance': 'Performance',
'analytics.section_class': 'Classes',
'analytics.section_teacher': 'Teachers',
'analytics.section_predictive': 'Predictive',
'analytics.section_export': 'Export',
// ─── Accessibility ───────────────────────────────────────────────
'accessibility.skip_to_content': 'Skip to content',
'accessibility.high_contrast': 'High Contrast',
'accessibility.reduced_motion': 'Reduced Motion',
'accessibility.keyboard_shortcuts': 'Keyboard Shortcuts',
'accessibility.shortcut_desc': 'Keyboard shortcuts for navigation',
// Timetable
'timetable.title': 'Timetable',
'timetable.monday': 'Monday',
'timetable.tuesday': 'Tuesday',
'timetable.wednesday': 'Wednesday',
'timetable.thursday': 'Thursday',
'timetable.friday': 'Friday',
'timetable.period': 'Period',
'timetable.break': 'Break',
'timetable.add_slot': 'Add Slot',
'timetable.edit_slot': 'Edit Slot',
'timetable.no_subject': 'No Subject',
'timetable.print': 'Print Timetable',
'timetable.class_view': 'Class View',
'timetable.room': 'Room',
'timetable.start_time': 'Start Time',
'timetable.end_time': 'End Time',
'timetable.subject': 'Subject',
'timetable.teacher': 'Teacher',
'timetable.day': 'Day',
'timetable.period_num': 'Period {n}',
'timetable.no_slots': 'No slots available',
'timetable.select_class': 'Select Class',
'timetable.batch_create': 'Create Timetable',
'timetable.delete_slot': 'Delete Slot',
'timetable.break_period': 'Break',
'timetable.drag_hint': 'Drag to move',
// Resources
'resources.title': 'Resources',
'resources.library': 'Resource Library',
'resources.upload': 'Upload',
'resources.type': 'Type',
'resources.document': 'Document',
'resources.worksheet': 'Worksheet',
'resources.presentation': 'Presentation',
'resources.video': 'Video',
'resources.link': 'Link',
'resources.image': 'Image',
'resources.audio': 'Audio',
'resources.download': 'Download',
'resources.downloads': 'Downloads',
'resources.preview': 'Preview',
'resources.favorite': 'Favorite',
'resources.create': 'Create Resource',
'resources.edit': 'Edit Resource',
'resources.search': 'Search Resources',
'resources.filter_type': 'Filter by Type',
'resources.filter_subject': 'Filter by Subject',
'resources.all_types': 'All Types',
'resources.all_subjects': 'All Subjects',
'resources.public': 'Public',
'resources.private': 'Private',
'resources.toggle_public': 'Public/Private',
'resources.no_resources': 'No resources available',
'resources.title_field': 'Title',
'resources.description_field': 'Description',
'resources.url_field': 'URL',
'resources.content_field': 'Content',
'resources.subject_field': 'Subject',
'resources.class_field': 'Class',
'resources.grade_field': 'Grade Level',
'resources.tags_field': 'Tags',
'resources.add_tag': 'Add Tag',
'resources.delete_confirm': 'Are you sure you want to delete this resource?',
};
const translations: Record<string, TranslationDict> = { de, en };
+3 -1
View File
@@ -26,7 +26,9 @@ export type ViewName =
| 'notebooks'
| 'drawing'
| 'homework'
| 'portfolio';
| 'portfolio'
| 'timetable'
| 'resources';
export interface CurrentUser {
id: string;