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
+4363 -862

No files matched your search

+68
View File
@@ -49,6 +49,8 @@ model School {
selfAssessments SelfAssessment[]
learningGoals LearningGoal[]
portfolioEntries PortfolioEntry[]
timetableSlots TimetableSlot[]
resources Resource[]
district SchoolDistrict? @relation(fields: [districtId], references: [id])
districtId String?
}
@@ -91,6 +93,8 @@ model User {
assignedInterventions BehaviorIntervention[] @relation("InterventionAssignee")
homeworks Homework[]
announcements Announcement[]
timetableSlots TimetableSlot[]
resources Resource[]
}
model SchoolYear {
@@ -140,6 +144,8 @@ model ClassGroup {
homeworks Homework[]
announcements Announcement[]
selfAssessments SelfAssessment[]
timetableSlots TimetableSlot[]
resources Resource[]
}
model ClassGroupTeacher {
@@ -221,6 +227,8 @@ model Subject {
calendarEvents CalendarEvent[]
curriculumStandards CurriculumStandard[]
homeworks Homework[]
timetableSlots TimetableSlot[]
resources Resource[]
}
model CompetencyTemplate {
@@ -1238,3 +1246,63 @@ model PortfolioEntry {
@@index([schoolId, studentId])
@@map("portfolio_entries")
}
// ─── Timetable ──────────────────────────────────────────────────────
model TimetableSlot {
id String @id @default(cuid())
schoolId String
classGroupId String
dayOfWeek Int // 0=Monday, 1=Tuesday, ..., 4=Friday
period Int // 1-8 (lesson period)
subjectId String?
teacherId String?
roomId String? // optional room
startTime String // "08:00"
endTime String // "08:45"
isBreak Boolean @default(false)
isDemo Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
school School @relation(fields: [schoolId], references: [id])
classGroup ClassGroup @relation(fields: [classGroupId], references: [id])
subject Subject? @relation(fields: [subjectId], references: [id])
teacher User? @relation(fields: [teacherId], references: [id])
@@unique([classGroupId, dayOfWeek, period])
@@index([schoolId, classGroupId])
@@map("timetable_slots")
}
// ─── Digital Resource Library ──────────────────────────────────────
model Resource {
id String @id @default(cuid())
schoolId String
authorId String
title String
description String?
resourceType String // "document", "worksheet", "presentation", "video_link", "image", "link", "audio"
url String? // external URL or file path
content String? // text content
subjectId String?
classGroupId String?
gradeLevel Int?
tags String? // JSON array of tags
isPublic Boolean @default(true)
downloadCount Int @default(0)
isDemo Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
school School @relation(fields: [schoolId], references: [id])
author User @relation(fields: [authorId], references: [id])
subject Subject? @relation(fields: [subjectId], references: [id])
classGroup ClassGroup? @relation(fields: [classGroupId], references: [id])
@@index([schoolId, resourceType])
@@map("resources")
}
+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;
+126 -32
View File
@@ -135,51 +135,81 @@ Stage Summary:
---
## Current Project Status (Round 14 — Lint Cleanup, ESLint Config Fix, Hook Violation Fix, WS Service Restart)
## Current Project Status (Round 14 — Self-Assessment, Learning Goals, Portfolio, Homework, Announcements)
**Status**: Stable, all features working, lint passes with 0 errors
**Version**: v14
**Commit**: ba55bd1
**Status**: Stable, all features working, pushed to GitHub
**Version**: v14 (0a7cfae)
### Completed Modifications (Round 14)
1. **ESLint Configuration Fix**
- Added `react-hooks/set-state-in-effect: "off"` — Disables overly strict React Compiler rule that flags `setState` calls inside `useEffect` (common and valid pattern in React apps)
- Added `react-hooks/preserve-manual-memoization: "off"` — Disables rule that flags `useCallback`/`useMemo` dependency mismatches with React Compiler's inference
- Added `react-hooks/immutability: "off"` — Disables rule that flags variable access before declaration in component scope
- These rules are from the React Compiler and are excessively strict for production React apps; they caused 56 lint errors across 20+ files
1. **Student Self-Assessment**
- Prisma model: SelfAssessment (schoolId, studentId, competencyId, classGroupId, selfLevel 1-6, confidence 1-5, reflection, evidence, goalId)
- API: GET/POST /api/self-assessments, GET/PUT/DELETE /api/self-assessments/[id]
- UI: Self-assessment section in student-detail-view with radar chart comparing self vs teacher assessment, competency selector, mastery level slider (1-6), confidence selector (1-5), reflection/evidence text areas, gap analysis badges, create/edit/delete dialogs
2. **Hook Violation Fix (rubric-library-view.tsx)**
- Fixed `useTemplate(template)` called inside an `onClick` handler — React hooks cannot be called inside callbacks
- Changed to `applyTemplate(template)` which is the correct function that applies the template to the form state
- This was a genuine bug: `useTemplate` was never defined as a hook, it was a misnamed function call
2. **Learning Goals**
- Prisma model: LearningGoal (schoolId, studentId, competencyId, title, description, targetLevel, currentLevel, deadline, status active/completed/abandoned, progress 0-100)
- API: GET/POST /api/learning-goals, GET/PUT/DELETE /api/learning-goals/[id]
- UI: Learning goals section in student-detail-view with progress bars, deadline countdown tracking, status tracking (active/completed/abandoned), celebration animation on completion, create/edit/delete dialogs, competency linking
3. **WebSocket Service Restart**
- Restarted ws-service on port 3003 (Socket.IO server)
- Confirmed health check returns OK
3. **Portfolio System**
- Prisma model: PortfolioEntry (schoolId, studentId, title, description, entryType artwork/writing/project/presentation/achievement/reflection, competencyId, content, mediaUrls, notebookPageId, drawingId, isPublic, tags)
- API: GET/POST /api/portfolio, GET/PUT/DELETE /api/portfolio/[id]
- UI: Portfolio View with grid and timeline views, type-based filtering (artwork/writing/project/presentation/achievement/reflection), tag filtering, search, public/private toggle, create/edit/delete dialogs
4. **Prisma Schema Push**
- Ran `bun run db:push` — database is already in sync with schema
- Prisma Client regenerated successfully
4. **Homework Management**
- Prisma models: Homework (schoolId, classGroupId, subjectId, teacherId, title, description, dueDate, homeworkType assignment/reading/project/practice/research, maxPoints, attachments, isPublished) + HomeworkSubmission (homeworkId, studentId, content, attachments, status pending/submitted/graded/late, score, feedback, submittedAt, gradedAt)
- API: GET/POST /api/homework, GET/PUT/DELETE /api/homework/[id], GET/POST /api/homework/[id]/submissions, GET/PUT/DELETE /api/homework/[id]/submissions/[submissionId]
- UI: Homework View with teacher mode (create, edit, delete, submissions, grading with score slider and feedback) and student mode (submit, view feedback), color-coded due date badges (overdue=rose, today=amber, upcoming=emerald), homework type badges with icons
5. **i18n Duplicate Key Check**
- Verified no duplicate keys in either `de` or `en` dictionaries
- All keys are unique
5. **Announcements**
- Prisma model: Announcement (schoolId, authorId, title, content, priority low/normal/high/urgent, targetAudience all/teachers/students/parents/class, classGroupId, isPinned, expiresAt)
- API: GET/POST /api/announcements, GET/PUT/DELETE /api/announcements/[id]
- UI: Announcement banner in app-layout with priority colors (urgent=rose, high=amber, normal=emerald, low=teal), dismiss button with localStorage persistence, dashboard announcements card with expand/collapse, create announcement dialog for admin/teacher roles
6. **Lint Results**
- Before fixes: 56 errors (54 from React Compiler rules, 1 hook violation, 1 immutability)
- After fixes: 0 errors, 0 warnings
6. **6 New Prisma Models**
- SelfAssessment, LearningGoal, PortfolioEntry, Homework, HomeworkSubmission, Announcement
- Relations added to School, User, ClassGroup, Student, Subject, Competency models
### Files Modified
- `eslint.config.mjs` — Added 3 new rule overrides for React Compiler rules
- `src/components/rubric-library-view.tsx` — Fixed `useTemplate` → `applyTemplate` in onClick handler
7. **10+ New API Route Directories**
- /api/self-assessments, /api/learning-goals, /api/portfolio, /api/homework, /api/homework/[id]/submissions, /api/announcements, and more
8. **2 New Views**
- portfolio-view.tsx — Grid/timeline portfolio with type filtering, tags, public/private toggle
- homework-view.tsx — Teacher/student homework management with grading interface
9. **2 New Navigation Items**
- Portfolio (Briefcase icon) — added to Teaching section and Student nav
- Homework (BookCheck icon) — added to teacher, student, and parent nav sections
10. **170+ New i18n Keys**
- Self-assessment, learning goals, portfolio, homework, and announcements keys in both DE and EN
- No duplicate keys, full DE/EN parity
11. **Styling Improvements**
- Priority colors for announcements (urgent=rose, high=amber, normal=emerald, low=teal)
- Due date colors for homework (overdue=rose, today=amber, upcoming=emerald)
- Announcement banner with dismiss animation (framer-motion slide up + fade out)
- 44px minimum touch targets for tablet compatibility
### Verification Results
- Lint: 0 errors, 0 warnings ✅
- Lint: 0 errors ✅
- Dev server: Running, no compilation errors ✅
- Prisma: Database in sync ✅
- Dev server: Running on port 3000 ✅
- WS service: Running on port 3003 ✅
- Commit: ba55bd1 pushed to GitHub ✅
- Pushed to GitHub ✅
### Unresolved Issues / Next Phase Priority
1. Timetable/Schedule management
2. Digital resource library
3. Emergency contacts management
4. Student transportation management
5. Peer assessment system
6. School events management
7. Advanced analytics dashboard
8. Email notification integration
9. Keyboard navigation improvements
10. More accessibility improvements
---
@@ -695,3 +725,67 @@ Stage Summary:
- Portfolio view as standalone page with grid/timeline views, type/tag filtering, public/private toggle
- Portfolio nav item with Briefcase icon added to Teaching section and Student nav
- 60+ new i18n keys in both DE and EN
---
Task ID: 17
Agent: timetable-resources
Task: Timetable Management + Digital Resource Library
Work Log:
- Added TimetableSlot model to Prisma schema (dayOfWeek, period, subjectId, teacherId, roomId, startTime, endTime, isBreak) with unique constraint on (classGroupId, dayOfWeek, period)
- Added Resource model to Prisma schema (title, description, resourceType, url, content, subjectId, classGroupId, gradeLevel, tags JSON, isPublic, downloadCount) with index on (schoolId, resourceType)
- Added timetableSlots and resources relations to School, User, ClassGroup, Subject models
- Ran db:push to sync database successfully
- Created /api/timetable/route.ts (GET list, POST single + batch create)
- Created /api/timetable/[id]/route.ts (GET, PUT, DELETE soft delete)
- Created /api/resources/route.ts (GET list with filters, POST create)
- Created /api/resources/[id]/route.ts (GET, PUT, DELETE soft delete, incrementDownload)
- Created timetable-view.tsx: weekly grid (Mon-Fri, Period 1-8), color-coded by subject, drag-and-drop to move lessons, click to add/edit, break rows, time display, print button, class selector
- Created resource-library-view.tsx: grid view with resource cards, 7 type filters, subject filter, search, create/edit dialog, preview panel, download counter, public/private toggle, favorite/bookmark (localStorage)
- Added ~70 i18n keys for both DE and EN covering timetable and resources
- Added nav.timetable and nav.resources i18n keys
- Updated store.ts ViewName type with 'timetable' and 'resources'
- Updated app-layout.tsx: added Clock/Library icons, imports, nav items, renderView cases
Stage Summary:
- 2 new Prisma models: TimetableSlot, Resource with 4 relation updates
- 4 new API route files with full CRUD
- 2 new major UI components: weekly timetable grid with drag-and-drop, resource library with filtering/favorites/preview
- 2 new navigation entries (Timetable with Clock icon, Resources with Library icon)
- ~70 new i18n keys for both DE and EN
- Lint passes clean
---
Task ID: 18
Agent: analytics-styling-accessibility
Task: Advanced Analytics + Styling Polish + Accessibility
Work Log:
- Enhanced analytics-view.tsx with tab-based navigation (School Overview, Student Performance, Class Comparison, Teacher Performance, Predictive, Export)
- Added School Overview: total students/classes/teachers/competencies stats, student-teacher ratio, competency coverage pie chart, subject mastery bar chart, activity heatmap
- Added Student Performance: mastery histogram, progress over time (area chart), subject comparison (horizontal bar), grade trend (composed chart), self-assessment vs teacher scatter plot, mastery distribution donut
- Added Class Comparison: radar chart comparing classes across 5 dimensions (mastery/attendance/engagement/progress/behavior), class comparison bar chart, competency coverage heatmap with progress bars, ranking table
- Added Teacher Performance: assessments/progress per teacher bar chart, teacher stats table with improvement rate and notebook usage
- Added Predictive Analytics: students at risk with risk summary cards and filterable table, students excelling with improvement trajectory, areas needing attention (bottom competencies)
- Added Export Options: PNG chart export, CSV data export, print report
- Enhanced analytics API route to return: totalClasses, totalTeachers, totalCompetencies, competencyCoveragePct, subjectMasteryAvg, teacherPerformance, selfVsTeacherAgg, gapAnalysis, excellingStudents, classRadarData
- Added new TypeScript types in api.ts: AnalyticsSubjectMasteryPoint, AnalyticsTeacherPerformancePoint, AnalyticsSelfVsTeacherAgg, AnalyticsGapAnalysisPoint, AnalyticsExcellingStudent, AnalyticsClassRadarPoint
- Added CSS classes to globals.css: chart-container, stat-card, heatmap-cell, analytics-dashboard, loading-skeleton-chart, predictive-badge (risk/excel/attention variants), analytics-tab, export-btn, chart-sr-desc
- Added accessibility features: skip-to-content link, keyboard shortcuts dialog (Alt+1-6), ARIA labels on all interactive elements, ARIA roles on tabs/tabpanels, live regions for dynamic content, screen reader descriptions for charts, table headers with scope attributes, 44px minimum touch targets, prefers-reduced-motion support via useReducedMotion hook
- Added count-up animation hook (useCountUp) that respects reduced motion
- Added StatCard component with gradient backgrounds and hover effects
- Added EmptyState component for consistent empty state illustrations
- Added ChartSkeleton component for loading states
- Added 70+ new i18n keys for both DE and EN covering analytics sections, accessibility, and export options
- Fixed API route bug: replaced nested await inside .then() callback with proper Promise chain
- Refactored teacher improvement calculation to batch DB queries instead of N+1 pattern
- Dark mode polish: all new chart containers, stat cards, and tabs have proper dark mode styles
- Lint passes clean
Stage Summary:
- Complete analytics dashboard with 6 tabbed sections and advanced visualizations
- New API data: school overview counts, subject mastery, teacher performance, self-assessment gap analysis, excelling students, class radar data
- 70+ new i18n keys (DE + EN) for analytics, accessibility, and exports
- Full accessibility: skip-to-content, keyboard shortcuts, ARIA labels, reduced motion, 44px touch targets
- New CSS utilities: chart-container, stat-card, heatmap-cell, predictive-badge, analytics-tab, export-btn
- Production-ready with dark mode support and responsive design