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