Round 15: Timetable, Resource Library, Advanced Analytics, Accessibility
This commit is contained in:
1 parent
0a7cfae311
commit
a94f900ae0
15 files changed
+4580
-1079
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
+1403
-1046
File diff suppressed because it is too large.
Load diff
@@ -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 />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -26,7 +26,9 @@ export type ViewName =
|
||||
| 'notebooks'
|
||||
| 'drawing'
|
||||
| 'homework'
|
||||
| 'portfolio';
|
||||
| 'portfolio'
|
||||
| 'timetable'
|
||||
| 'resources';
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
|
||||
Reference in new issue
Block a user