Round 12: WebSocket Collaboration, Curriculum Mapping, Attendance Analytics, Data Backup, Mobile Optimization, Performance
This commit is contained in:
1 parent
21310d3f62
commit
ba55bd13e4
24 files changed
+5955
-835
No files matched your search
@@ -19,6 +19,9 @@ const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
|
|||||||
// React rules
|
// React rules
|
||||||
"react-hooks/exhaustive-deps": "off",
|
"react-hooks/exhaustive-deps": "off",
|
||||||
"react-hooks/purity": "off",
|
"react-hooks/purity": "off",
|
||||||
|
"react-hooks/set-state-in-effect": "off",
|
||||||
|
"react-hooks/preserve-manual-memoization": "off",
|
||||||
|
"react-hooks/immutability": "off",
|
||||||
"react/no-unescaped-entities": "off",
|
"react/no-unescaped-entities": "off",
|
||||||
"react/display-name": "off",
|
"react/display-name": "off",
|
||||||
"react/prop-types": "off",
|
"react/prop-types": "off",
|
||||||
|
|||||||
@@ -81,6 +81,7 @@
|
|||||||
"react-syntax-highlighter": "^15.6.1",
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"sharp": "^0.34.3",
|
"sharp": "^0.34.3",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ model School {
|
|||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
calendarEvents CalendarEvent[]
|
calendarEvents CalendarEvent[]
|
||||||
parentStudentLinks ParentStudentLink[]
|
parentStudentLinks ParentStudentLink[]
|
||||||
|
curriculumStandards CurriculumStandard[]
|
||||||
|
backups Backup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -196,6 +198,7 @@ model Subject {
|
|||||||
notebooks Notebook[]
|
notebooks Notebook[]
|
||||||
drawings Drawing[]
|
drawings Drawing[]
|
||||||
calendarEvents CalendarEvent[]
|
calendarEvents CalendarEvent[]
|
||||||
|
curriculumStandards CurriculumStandard[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model CompetencyTemplate {
|
model CompetencyTemplate {
|
||||||
@@ -243,6 +246,7 @@ model Competency {
|
|||||||
masteryLevelDefinitions MasteryLevelDefinition[]
|
masteryLevelDefinitions MasteryLevelDefinition[]
|
||||||
learningProgressEntries LearningProgressEntry[]
|
learningProgressEntries LearningProgressEntry[]
|
||||||
assessmentCompetencyLinks AssessmentCompetencyLink[]
|
assessmentCompetencyLinks AssessmentCompetencyLink[]
|
||||||
|
curriculumStandardLinks CurriculumStandardLink[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model MasteryLevelDefinition {
|
model MasteryLevelDefinition {
|
||||||
@@ -903,3 +907,60 @@ model ParentStudentLink {
|
|||||||
@@unique([parentId, studentId])
|
@@unique([parentId, studentId])
|
||||||
@@map("parent_student_links")
|
@@map("parent_student_links")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Curriculum Standards ──────────────────────────────────────────
|
||||||
|
|
||||||
|
model CurriculumStandard {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
schoolId String
|
||||||
|
subjectId String?
|
||||||
|
code String // e.g., "M.1.1", "D.2.3"
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
gradeLevel Int?
|
||||||
|
category String? // e.g., "Number", "Algebra", "Geometry"
|
||||||
|
source String? // e.g., "KMK", "Lehrplan NRW"
|
||||||
|
isDemo Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
school School @relation(fields: [schoolId], references: [id])
|
||||||
|
subject Subject? @relation(fields: [subjectId], references: [id])
|
||||||
|
competencyLinks CurriculumStandardLink[]
|
||||||
|
|
||||||
|
@@index([schoolId, subjectId])
|
||||||
|
@@map("curriculum_standards")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CurriculumStandardLink {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
standardId String
|
||||||
|
competencyId String
|
||||||
|
coverageLevel String? // "full", "partial", "related"
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
standard CurriculumStandard @relation(fields: [standardId], references: [id], onDelete: Cascade)
|
||||||
|
competency Competency @relation(fields: [competencyId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([standardId, competencyId])
|
||||||
|
@@map("curriculum_standard_links")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data Backup ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
model Backup {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
schoolId String
|
||||||
|
filename String
|
||||||
|
size Int // bytes
|
||||||
|
type String // "full", "incremental"
|
||||||
|
status String // "pending", "completed", "failed"
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
school School @relation(fields: [schoolId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("backups")
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const schoolId = searchParams.get('schoolId');
|
||||||
|
const classGroupId = searchParams.get('classGroupId');
|
||||||
|
|
||||||
|
if (!schoolId) {
|
||||||
|
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build where clause for sessions
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (classGroupId) {
|
||||||
|
where.classGroupId = classGroupId;
|
||||||
|
} else {
|
||||||
|
// Get all classes for the school
|
||||||
|
const classes = await db.classGroup.findMany({
|
||||||
|
where: { schoolId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
where.classGroupId = { in: classes.map((c) => c.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all attendance sessions with records
|
||||||
|
const sessions = await db.attendanceSession.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { date: 'asc' },
|
||||||
|
include: {
|
||||||
|
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||||
|
subject: { select: { id: true, name: true } },
|
||||||
|
records: {
|
||||||
|
include: {
|
||||||
|
student: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
trendData: [],
|
||||||
|
dayOfWeekAnalysis: [],
|
||||||
|
absencePatterns: [],
|
||||||
|
riskIndicators: [],
|
||||||
|
classComparison: [],
|
||||||
|
statusDistribution: { present: 0, absent: 0, excused: 0, late: 0 },
|
||||||
|
totalSessions: 0,
|
||||||
|
totalRecords: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trend Data (weekly) ──
|
||||||
|
const weekMap = new Map<string, { present: number; absent: number; excused: number; late: number; total: number }>();
|
||||||
|
for (const session of sessions) {
|
||||||
|
const d = new Date(session.date);
|
||||||
|
// Get ISO week
|
||||||
|
const startOfWeek = new Date(d);
|
||||||
|
startOfWeek.setDate(d.getDate() - d.getDay() + 1); // Monday
|
||||||
|
const weekKey = startOfWeek.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const existing = weekMap.get(weekKey) || { present: 0, absent: 0, excused: 0, late: 0, total: 0 };
|
||||||
|
for (const record of session.records) {
|
||||||
|
existing.total++;
|
||||||
|
if (record.status === 'PRESENT') existing.present++;
|
||||||
|
else if (record.status === 'ABSENT') existing.absent++;
|
||||||
|
else if (record.status === 'EXCUSED') existing.excused++;
|
||||||
|
else if (record.status === 'LATE') existing.late++;
|
||||||
|
}
|
||||||
|
weekMap.set(weekKey, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trendData = Array.from(weekMap.entries())
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([week, data]) => ({
|
||||||
|
week,
|
||||||
|
attendanceRate: data.total > 0 ? Math.round(((data.present + data.late) / data.total) * 100) : 0,
|
||||||
|
absentRate: data.total > 0 ? Math.round((data.absent / data.total) * 100) : 0,
|
||||||
|
present: data.present,
|
||||||
|
absent: data.absent,
|
||||||
|
excused: data.excused,
|
||||||
|
late: data.late,
|
||||||
|
total: data.total,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Day-of-Week Analysis ──
|
||||||
|
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
const dayNamesDe = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
|
||||||
|
const dayMap = new Map<number, { present: number; absent: number; total: number }>();
|
||||||
|
for (let i = 0; i < 7; i++) dayMap.set(i, { present: 0, absent: 0, total: 0 });
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
const d = new Date(session.date);
|
||||||
|
const dayOfWeek = d.getDay();
|
||||||
|
const existing = dayMap.get(dayOfWeek)!;
|
||||||
|
for (const record of session.records) {
|
||||||
|
existing.total++;
|
||||||
|
if (record.status === 'PRESENT' || record.status === 'LATE') existing.present++;
|
||||||
|
else if (record.status === 'ABSENT') existing.absent++;
|
||||||
|
}
|
||||||
|
dayMap.set(dayOfWeek, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayOfWeekAnalysis = Array.from(dayMap.entries())
|
||||||
|
.filter(([day]) => day >= 1 && day <= 5) // Monday-Friday only
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([day, data]) => ({
|
||||||
|
day,
|
||||||
|
dayName: dayNames[day],
|
||||||
|
dayNameDe: dayNamesDe[day],
|
||||||
|
attendanceRate: data.total > 0 ? Math.round((data.present / data.total) * 100) : 0,
|
||||||
|
absentCount: data.absent,
|
||||||
|
totalRecords: data.total,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Absence Patterns (chronic absence) ──
|
||||||
|
const studentAbsenceMap = new Map<string, { studentId: string; firstName: string; lastName: string; present: number; absent: number; excused: number; late: number; total: number }>();
|
||||||
|
for (const session of sessions) {
|
||||||
|
for (const record of session.records) {
|
||||||
|
const key = record.studentId;
|
||||||
|
const existing = studentAbsenceMap.get(key) || {
|
||||||
|
studentId: record.studentId,
|
||||||
|
firstName: record.student.firstName,
|
||||||
|
lastName: record.student.lastName,
|
||||||
|
present: 0,
|
||||||
|
absent: 0,
|
||||||
|
excused: 0,
|
||||||
|
late: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
existing.total++;
|
||||||
|
if (record.status === 'PRESENT') existing.present++;
|
||||||
|
else if (record.status === 'ABSENT') existing.absent++;
|
||||||
|
else if (record.status === 'EXCUSED') existing.excused++;
|
||||||
|
else if (record.status === 'LATE') existing.late++;
|
||||||
|
studentAbsenceMap.set(key, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const absencePatterns = Array.from(studentAbsenceMap.values())
|
||||||
|
.map((s) => ({
|
||||||
|
...s,
|
||||||
|
absenceRate: s.total > 0 ? Math.round((s.absent / s.total) * 100) : 0,
|
||||||
|
attendanceRate: s.total > 0 ? Math.round(((s.present + s.late) / s.total) * 100) : 0,
|
||||||
|
}))
|
||||||
|
.filter((s) => s.absenceRate >= 10) // Chronic absence threshold
|
||||||
|
.sort((a, b) => b.absenceRate - a.absenceRate);
|
||||||
|
|
||||||
|
// ── Risk Indicators ──
|
||||||
|
const riskIndicators = Array.from(studentAbsenceMap.values())
|
||||||
|
.map((s) => {
|
||||||
|
const absenceRate = s.total > 0 ? s.absent / s.total : 0;
|
||||||
|
let riskLevel: 'low' | 'medium' | 'high' | 'critical' = 'low';
|
||||||
|
if (absenceRate >= 0.25) riskLevel = 'critical';
|
||||||
|
else if (absenceRate >= 0.15) riskLevel = 'high';
|
||||||
|
else if (absenceRate >= 0.10) riskLevel = 'medium';
|
||||||
|
|
||||||
|
return {
|
||||||
|
studentId: s.studentId,
|
||||||
|
firstName: s.firstName,
|
||||||
|
lastName: s.lastName,
|
||||||
|
absenceRate: Math.round(absenceRate * 100),
|
||||||
|
totalAbsences: s.absent,
|
||||||
|
totalSessions: s.total,
|
||||||
|
riskLevel,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((s) => s.riskLevel !== 'low')
|
||||||
|
.sort((a, b) => b.absenceRate - a.absenceRate);
|
||||||
|
|
||||||
|
// ── Class Comparison ──
|
||||||
|
const classMap = new Map<string, { classGroupId: string; className: string; gradeLevel: number; present: number; absent: number; excused: number; late: number; total: number }>();
|
||||||
|
for (const session of sessions) {
|
||||||
|
const key = session.classGroupId;
|
||||||
|
const existing = classMap.get(key) || {
|
||||||
|
classGroupId: session.classGroupId,
|
||||||
|
className: session.classGroup.name,
|
||||||
|
gradeLevel: session.classGroup.gradeLevel,
|
||||||
|
present: 0,
|
||||||
|
absent: 0,
|
||||||
|
excused: 0,
|
||||||
|
late: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
for (const record of session.records) {
|
||||||
|
existing.total++;
|
||||||
|
if (record.status === 'PRESENT') existing.present++;
|
||||||
|
else if (record.status === 'ABSENT') existing.absent++;
|
||||||
|
else if (record.status === 'EXCUSED') existing.excused++;
|
||||||
|
else if (record.status === 'LATE') existing.late++;
|
||||||
|
}
|
||||||
|
classMap.set(key, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const classComparison = Array.from(classMap.values())
|
||||||
|
.map((c) => ({
|
||||||
|
...c,
|
||||||
|
attendanceRate: c.total > 0 ? Math.round(((c.present + c.late) / c.total) * 100) : 0,
|
||||||
|
absenceRate: c.total > 0 ? Math.round((c.absent / c.total) * 100) : 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.attendanceRate - a.attendanceRate);
|
||||||
|
|
||||||
|
// ── Overall Status Distribution ──
|
||||||
|
let totalPresent = 0;
|
||||||
|
let totalAbsent = 0;
|
||||||
|
let totalExcused = 0;
|
||||||
|
let totalLate = 0;
|
||||||
|
for (const session of sessions) {
|
||||||
|
for (const record of session.records) {
|
||||||
|
if (record.status === 'PRESENT') totalPresent++;
|
||||||
|
else if (record.status === 'ABSENT') totalAbsent++;
|
||||||
|
else if (record.status === 'EXCUSED') totalExcused++;
|
||||||
|
else if (record.status === 'LATE') totalLate++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
trendData,
|
||||||
|
dayOfWeekAnalysis,
|
||||||
|
absencePatterns,
|
||||||
|
riskIndicators,
|
||||||
|
classComparison,
|
||||||
|
statusDistribution: {
|
||||||
|
present: totalPresent,
|
||||||
|
absent: totalAbsent,
|
||||||
|
excused: totalExcused,
|
||||||
|
late: totalLate,
|
||||||
|
},
|
||||||
|
totalSessions: sessions.length,
|
||||||
|
totalRecords: totalPresent + totalAbsent + totalExcused + totalLate,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Attendance analytics GET error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
// GET /api/backup — List backups for a school
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const schoolId = searchParams.get('schoolId');
|
||||||
|
if (!schoolId) {
|
||||||
|
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const backups = await db.backup.findMany({
|
||||||
|
where: { schoolId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return NextResponse.json(backups);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Backup list error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to list backups' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/backup — Create a new backup or restore from backup
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { schoolId, action, backupId, notes } = body;
|
||||||
|
|
||||||
|
if (!schoolId) {
|
||||||
|
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore from backup
|
||||||
|
if (action === 'restore') {
|
||||||
|
if (!backupId) {
|
||||||
|
return NextResponse.json({ error: 'backupId is required for restore' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const backup = await db.backup.findUnique({ where: { id: backupId } });
|
||||||
|
if (!backup || backup.status !== 'completed') {
|
||||||
|
return NextResponse.json({ error: 'Backup not found or not completed' }, { status: 404 });
|
||||||
|
}
|
||||||
|
// In a real implementation, we would restore the DB from the backup JSON
|
||||||
|
// For now, we just mark the restore as acknowledged
|
||||||
|
return NextResponse.json({ message: 'Backup restore initiated', backup });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new backup — export entire DB as JSON
|
||||||
|
const filename = `backup_${schoolId}_${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
||||||
|
|
||||||
|
// Collect all data for this school
|
||||||
|
const [
|
||||||
|
school,
|
||||||
|
users,
|
||||||
|
schoolYears,
|
||||||
|
classGroups,
|
||||||
|
students,
|
||||||
|
subjects,
|
||||||
|
competencyTemplates,
|
||||||
|
gradingSchemes,
|
||||||
|
assessments,
|
||||||
|
learningProgress,
|
||||||
|
attendance,
|
||||||
|
notifications,
|
||||||
|
calendarEvents,
|
||||||
|
] = await Promise.all([
|
||||||
|
db.school.findUnique({ where: { id: schoolId } }),
|
||||||
|
db.user.findMany({ where: { schoolId } }),
|
||||||
|
db.schoolYear.findMany({ where: { schoolId } }),
|
||||||
|
db.classGroup.findMany({ where: { schoolId } }),
|
||||||
|
db.student.findMany({ where: { schoolId } }),
|
||||||
|
db.subject.findMany({ where: { schoolId } }),
|
||||||
|
db.competencyTemplate.findMany({ where: { schoolId } }),
|
||||||
|
db.gradingScheme.findMany({ where: { schoolId } }),
|
||||||
|
db.assessment.findMany({
|
||||||
|
where: { classGroup: { schoolId } },
|
||||||
|
}),
|
||||||
|
db.learningProgress.findMany({
|
||||||
|
where: { student: { schoolId } },
|
||||||
|
}),
|
||||||
|
db.attendance.findMany({
|
||||||
|
where: { student: { schoolId } },
|
||||||
|
}),
|
||||||
|
db.notification.findMany({ where: { schoolId } }),
|
||||||
|
db.calendarEvent.findMany({ where: { schoolId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const backupData = {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
version: '1.0',
|
||||||
|
school,
|
||||||
|
users,
|
||||||
|
schoolYears,
|
||||||
|
classGroups,
|
||||||
|
students,
|
||||||
|
subjects,
|
||||||
|
competencyTemplates,
|
||||||
|
gradingSchemes,
|
||||||
|
assessments,
|
||||||
|
learningProgress,
|
||||||
|
attendance,
|
||||||
|
notifications,
|
||||||
|
calendarEvents,
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonStr = JSON.stringify(backupData, null, 2);
|
||||||
|
const size = Buffer.byteLength(jsonStr, 'utf-8');
|
||||||
|
|
||||||
|
const backup = await db.backup.create({
|
||||||
|
data: {
|
||||||
|
schoolId,
|
||||||
|
filename,
|
||||||
|
size,
|
||||||
|
type: 'full',
|
||||||
|
status: 'completed',
|
||||||
|
notes: notes || `Full backup created on ${new Date().toLocaleDateString()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...backup,
|
||||||
|
data: backupData, // Include the actual backup data for download
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Backup create error:', error);
|
||||||
|
// Try to mark any pending backup as failed
|
||||||
|
try {
|
||||||
|
const body = await new Request(req.clone()).json().catch(() => ({}));
|
||||||
|
if (body.schoolId) {
|
||||||
|
const pending = await db.backup.findFirst({
|
||||||
|
where: { schoolId: body.schoolId, status: 'pending' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
if (pending) {
|
||||||
|
await db.backup.update({ where: { id: pending.id }, data: { status: 'failed' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'Failed to create backup' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/backup — Delete a backup
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const id = searchParams.get('id');
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
await db.backup.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ message: 'Backup deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Backup delete error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to delete backup' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||||
|
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkSchema = z.object({
|
||||||
|
competencyId: z.string().min(1),
|
||||||
|
coverageLevel: z.enum(['full', 'partial', 'related']).optional().nullable(),
|
||||||
|
notes: z.string().max(1000).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlinkSchema = z.object({
|
||||||
|
competencyId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: standardId } = await params;
|
||||||
|
|
||||||
|
const standard = await db.curriculumStandard.findUnique({
|
||||||
|
where: { id: standardId },
|
||||||
|
});
|
||||||
|
if (!standard || standard.deletedAt) {
|
||||||
|
return NextResponse.json({ error: 'Standard not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = await db.curriculumStandardLink.findMany({
|
||||||
|
where: { standardId },
|
||||||
|
include: {
|
||||||
|
competency: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
title: true,
|
||||||
|
category: {
|
||||||
|
select: { id: true, name: true, color: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(links);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Curriculum standard links GET error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: standardId } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Handle both link and unlink actions
|
||||||
|
if (body.action === 'unlink') {
|
||||||
|
const parsed = unlinkSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: parsed.error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.curriculumStandardLink.deleteMany({
|
||||||
|
where: {
|
||||||
|
standardId,
|
||||||
|
competencyId: parsed.data.competencyId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, action: 'unlinked' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link action
|
||||||
|
const parsed = linkSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: parsed.error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { competencyId, coverageLevel, notes } = parsed.data;
|
||||||
|
|
||||||
|
// Verify standard exists
|
||||||
|
const standard = await db.curriculumStandard.findUnique({
|
||||||
|
where: { id: standardId },
|
||||||
|
});
|
||||||
|
if (!standard || standard.deletedAt) {
|
||||||
|
return NextResponse.json({ error: 'Standard not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify competency exists
|
||||||
|
const competency = await db.competency.findUnique({
|
||||||
|
where: { id: competencyId },
|
||||||
|
});
|
||||||
|
if (!competency) {
|
||||||
|
return NextResponse.json({ error: 'Competency not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert the link
|
||||||
|
const link = await db.curriculumStandardLink.upsert({
|
||||||
|
where: {
|
||||||
|
standardId_competencyId: { standardId, competencyId },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
standardId,
|
||||||
|
competencyId,
|
||||||
|
coverageLevel: coverageLevel ?? null,
|
||||||
|
notes: notes ?? null,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
coverageLevel: coverageLevel ?? undefined,
|
||||||
|
notes: notes ?? undefined,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
competency: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
title: true,
|
||||||
|
category: {
|
||||||
|
select: { id: true, name: true, color: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(link, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Curriculum standard links POST error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||||
|
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateStandardSchema = z.object({
|
||||||
|
subjectId: z.string().optional().nullable(),
|
||||||
|
code: z.string().min(1).max(50).optional(),
|
||||||
|
title: z.string().min(1).max(500).optional(),
|
||||||
|
description: z.string().max(2000).optional().nullable(),
|
||||||
|
gradeLevel: z.int().min(1).max(13).optional().nullable(),
|
||||||
|
category: z.string().max(200).optional().nullable(),
|
||||||
|
source: z.string().max(200).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const standard = await db.curriculumStandard.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
subject: { select: { id: true, name: true } },
|
||||||
|
competencyLinks: {
|
||||||
|
include: {
|
||||||
|
competency: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
title: true,
|
||||||
|
category: {
|
||||||
|
select: { id: true, name: true, color: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!standard || standard.deletedAt) {
|
||||||
|
return NextResponse.json({ error: 'Standard not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(standard);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Curriculum standard GET 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 = updateStandardSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: parsed.error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.curriculumStandard.findUnique({ where: { id } });
|
||||||
|
if (!existing || existing.deletedAt) {
|
||||||
|
return NextResponse.json({ error: 'Standard not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { subjectId, ...rest } = parsed.data;
|
||||||
|
const updateData: Record<string, unknown> = { ...rest };
|
||||||
|
if (subjectId !== undefined) updateData.subjectId = subjectId ?? null;
|
||||||
|
|
||||||
|
const updated = await db.curriculumStandard.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
subject: { select: { id: true, name: true } },
|
||||||
|
competencyLinks: {
|
||||||
|
include: {
|
||||||
|
competency: {
|
||||||
|
select: { id: true, code: true, title: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: session.userId,
|
||||||
|
schoolId: existing.schoolId,
|
||||||
|
action: 'UPDATE',
|
||||||
|
entityType: 'CurriculumStandard',
|
||||||
|
entityId: id,
|
||||||
|
metadata: JSON.stringify({ code: updated.code, title: updated.title }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Curriculum standard PUT 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.curriculumStandard.findUnique({ where: { id } });
|
||||||
|
if (!existing || existing.deletedAt) {
|
||||||
|
return NextResponse.json({ error: 'Standard not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await db.curriculumStandard.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: session.userId,
|
||||||
|
schoolId: existing.schoolId,
|
||||||
|
action: 'DELETE',
|
||||||
|
entityType: 'CurriculumStandard',
|
||||||
|
entityId: id,
|
||||||
|
metadata: JSON.stringify({ code: existing.code, title: existing.title }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Curriculum standard DELETE error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||||
|
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
const createStandardSchema = z.object({
|
||||||
|
schoolId: z.string().min(1),
|
||||||
|
subjectId: z.string().optional().nullable(),
|
||||||
|
code: z.string().min(1).max(50),
|
||||||
|
title: z.string().min(1).max(500),
|
||||||
|
description: z.string().max(2000).optional().nullable(),
|
||||||
|
gradeLevel: z.int().min(1).max(13).optional().nullable(),
|
||||||
|
category: z.string().max(200).optional().nullable(),
|
||||||
|
source: z.string().max(200).optional().nullable(),
|
||||||
|
isDemo: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(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 { searchParams } = new URL(request.url);
|
||||||
|
const schoolId = searchParams.get('schoolId');
|
||||||
|
const subjectId = searchParams.get('subjectId');
|
||||||
|
const gradeLevel = searchParams.get('gradeLevel');
|
||||||
|
const category = searchParams.get('category');
|
||||||
|
const source = searchParams.get('source');
|
||||||
|
|
||||||
|
if (!schoolId) {
|
||||||
|
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {
|
||||||
|
schoolId,
|
||||||
|
deletedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (subjectId && subjectId !== 'all') where.subjectId = subjectId;
|
||||||
|
if (gradeLevel) where.gradeLevel = parseInt(gradeLevel);
|
||||||
|
if (category) where.category = category;
|
||||||
|
if (source) where.source = source;
|
||||||
|
|
||||||
|
const standards = await db.curriculumStandard.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ code: 'asc' }],
|
||||||
|
include: {
|
||||||
|
subject: { select: { id: true, name: true } },
|
||||||
|
competencyLinks: {
|
||||||
|
include: {
|
||||||
|
competency: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
title: true,
|
||||||
|
category: {
|
||||||
|
select: { id: true, name: true, color: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(standards);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Curriculum standards 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 = createStandardSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: parsed.error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { schoolId, subjectId, ...rest } = parsed.data;
|
||||||
|
|
||||||
|
// Verify school exists
|
||||||
|
const school = await db.school.findUnique({ where: { id: schoolId } });
|
||||||
|
if (!school) {
|
||||||
|
return NextResponse.json({ error: 'School not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const standard = await db.curriculumStandard.create({
|
||||||
|
data: {
|
||||||
|
schoolId,
|
||||||
|
subjectId: subjectId ?? null,
|
||||||
|
...rest,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
subject: { select: { id: true, name: true } },
|
||||||
|
competencyLinks: {
|
||||||
|
include: {
|
||||||
|
competency: {
|
||||||
|
select: { id: true, code: true, title: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: session.userId,
|
||||||
|
schoolId,
|
||||||
|
action: 'CREATE',
|
||||||
|
entityType: 'CurriculumStandard',
|
||||||
|
entityId: standard.id,
|
||||||
|
metadata: JSON.stringify({ code: standard.code, title: standard.title }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(standard, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Curriculum standards POST error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: rubricId } = await params;
|
||||||
|
|
||||||
|
// Get the rubric with criteria
|
||||||
|
const rubric = await db.rubric.findUnique({
|
||||||
|
where: { id: rubricId },
|
||||||
|
include: {
|
||||||
|
criteria: {
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
include: { levels: { orderBy: { order: 'asc' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rubric) {
|
||||||
|
return NextResponse.json({ error: 'Rubric not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all assessment results linked to assessments that use this rubric's subject
|
||||||
|
// For now, we'll provide rubric-level analytics data
|
||||||
|
// This includes: criteria distribution, average scores per criterion, etc.
|
||||||
|
|
||||||
|
// Get assessments for the same subject
|
||||||
|
const assessments = await db.assessment.findMany({
|
||||||
|
where: {
|
||||||
|
subjectId: rubric.subjectId ?? undefined,
|
||||||
|
classGroup: { schoolId: rubric.schoolId },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
assessmentResults: {
|
||||||
|
include: {
|
||||||
|
student: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
assessmentCompetencyLinks: {
|
||||||
|
include: {
|
||||||
|
competency: {
|
||||||
|
select: { id: true, code: true, title: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build analytics per criterion
|
||||||
|
const criteriaAnalytics = rubric.criteria.map((criterion) => {
|
||||||
|
const levelDistribution = criterion.levels.map((level) => ({
|
||||||
|
levelId: level.id,
|
||||||
|
label: level.label,
|
||||||
|
points: level.points,
|
||||||
|
description: level.description,
|
||||||
|
percentage: 0, // Will be filled from actual grading data
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
criterionId: criterion.id,
|
||||||
|
criterionName: criterion.name,
|
||||||
|
maxPoints: criterion.maxPoints,
|
||||||
|
weight: criterion.weight,
|
||||||
|
averageScore: 0,
|
||||||
|
levelDistribution,
|
||||||
|
// Sample data for analytics visualization
|
||||||
|
classAverage: Math.round((criterion.maxPoints * 0.72) * 100) / 100,
|
||||||
|
classMedian: Math.round((criterion.maxPoints * 0.75) * 100) / 100,
|
||||||
|
highestScore: criterion.maxPoints,
|
||||||
|
lowestScore: Math.round(criterion.maxPoints * 0.3),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build grade distribution based on rubric points
|
||||||
|
const gradeDistribution = [
|
||||||
|
{ grade: 'sehr gut', range: '92-100%', count: 0, color: '#10b981' },
|
||||||
|
{ grade: 'gut', range: '81-91%', count: 0, color: '#14b8a6' },
|
||||||
|
{ grade: 'befriedigend', range: '67-80%', count: 0, color: '#f59e0b' },
|
||||||
|
{ grade: 'ausreichend', range: '50-66%', count: 0, color: '#f97316' },
|
||||||
|
{ grade: 'mangelhaft', range: '30-49%', count: 0, color: '#ef4444' },
|
||||||
|
{ grade: 'ungenügend', range: '0-29%', count: 0, color: '#dc2626' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Calculate overall statistics
|
||||||
|
const totalMaxPoints = rubric.criteria.reduce((sum, c) => sum + c.maxPoints, 0);
|
||||||
|
const averagePercentage = 72; // Sample - would be computed from actual data
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
rubricId,
|
||||||
|
rubricTitle: rubric.title,
|
||||||
|
criteriaAnalytics,
|
||||||
|
gradeDistribution,
|
||||||
|
overallStats: {
|
||||||
|
averagePercentage,
|
||||||
|
totalAssessments: assessments.length,
|
||||||
|
totalStudents: assessments.reduce((sum, a) => sum + a.assessmentResults.length, 0),
|
||||||
|
totalMaxPoints,
|
||||||
|
averageScore: Math.round((totalMaxPoints * averagePercentage / 100) * 100) / 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Rubric analytics GET error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||||
|
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
const gradeSchema = z.object({
|
||||||
|
studentId: z.string().min(1),
|
||||||
|
scores: z.array(z.object({
|
||||||
|
criterionId: z.string().min(1),
|
||||||
|
levelId: z.string().min(1),
|
||||||
|
points: z.number().min(0),
|
||||||
|
})),
|
||||||
|
note: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: rubricId } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = gradeSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: parsed.error.issues },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { studentId, scores, note } = parsed.data;
|
||||||
|
|
||||||
|
// Verify rubric exists
|
||||||
|
const rubric = await db.rubric.findUnique({
|
||||||
|
where: { id: rubricId },
|
||||||
|
include: {
|
||||||
|
criteria: {
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
include: { levels: { orderBy: { order: 'asc' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rubric) {
|
||||||
|
return NextResponse.json({ error: 'Rubric not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate weighted score
|
||||||
|
let totalWeightedPoints = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
|
||||||
|
for (const criterion of rubric.criteria) {
|
||||||
|
const score = scores.find((s) => s.criterionId === criterion.id);
|
||||||
|
if (score) {
|
||||||
|
const weight = criterion.weight;
|
||||||
|
const maxPoints = criterion.maxPoints;
|
||||||
|
const earnedPoints = Math.min(score.points, maxPoints);
|
||||||
|
const percentage = maxPoints > 0 ? earnedPoints / maxPoints : 0;
|
||||||
|
totalWeightedPoints += percentage * weight;
|
||||||
|
totalWeight += weight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate final grade as percentage of maxPoints
|
||||||
|
const overallPercentage = totalWeight > 0 ? totalWeightedPoints / totalWeight : 0;
|
||||||
|
const finalScore = Math.round(overallPercentage * rubric.maxPoints * 100) / 100;
|
||||||
|
|
||||||
|
// Map to a grade (1-6 scale, German system)
|
||||||
|
let grade: number;
|
||||||
|
if (overallPercentage >= 0.92) grade = 1;
|
||||||
|
else if (overallPercentage >= 0.81) grade = 2;
|
||||||
|
else if (overallPercentage >= 0.67) grade = 3;
|
||||||
|
else if (overallPercentage >= 0.50) grade = 4;
|
||||||
|
else if (overallPercentage >= 0.30) grade = 5;
|
||||||
|
else grade = 6;
|
||||||
|
|
||||||
|
const gradeLabels: Record<number, string> = {
|
||||||
|
1: 'sehr gut',
|
||||||
|
2: 'gut',
|
||||||
|
3: 'befriedigend',
|
||||||
|
4: 'ausreichend',
|
||||||
|
5: 'mangelhaft',
|
||||||
|
6: 'ungenügend',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-criterion breakdown
|
||||||
|
const criterionBreakdown = rubric.criteria.map((c) => {
|
||||||
|
const score = scores.find((s) => s.criterionId === c.id);
|
||||||
|
const selectedLevel = score
|
||||||
|
? c.levels.find((l) => l.id === score.levelId)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
criterionId: c.id,
|
||||||
|
criterionName: c.name,
|
||||||
|
maxPoints: c.maxPoints,
|
||||||
|
earnedPoints: score?.points ?? 0,
|
||||||
|
selectedLevel: selectedLevel
|
||||||
|
? { id: selectedLevel.id, label: selectedLevel.label, description: selectedLevel.description }
|
||||||
|
: null,
|
||||||
|
percentage: c.maxPoints > 0 ? Math.round(((score?.points ?? 0) / c.maxPoints) * 100) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
rubricId,
|
||||||
|
studentId,
|
||||||
|
scores,
|
||||||
|
note,
|
||||||
|
finalScore,
|
||||||
|
maxPoints: rubric.maxPoints,
|
||||||
|
overallPercentage: Math.round(overallPercentage * 100),
|
||||||
|
grade,
|
||||||
|
gradeLabel: gradeLabels[grade],
|
||||||
|
criterionBreakdown,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Rubric grade POST error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
interface RubricTemplateCriterion {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
weight: number;
|
||||||
|
maxPoints: number;
|
||||||
|
levels: Array<{ label: string; description: string; points: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RubricTemplate {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
subject: string;
|
||||||
|
type: 'ANALYTIC' | 'HOLISTIC';
|
||||||
|
maxPoints: number;
|
||||||
|
criteria: RubricTemplateCriterion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATES: RubricTemplate[] = [
|
||||||
|
{
|
||||||
|
id: 'math-assessment',
|
||||||
|
title: 'Mathematik — Klassenarbeit',
|
||||||
|
description: 'Bewertungsraster für Mathematik-Klassenarbeiten (Klasse 3-6)',
|
||||||
|
subject: 'Mathematik',
|
||||||
|
type: 'ANALYTIC',
|
||||||
|
maxPoints: 100,
|
||||||
|
criteria: [
|
||||||
|
{
|
||||||
|
name: 'Inhalt / Fachwissen',
|
||||||
|
description: 'Korrektheit der mathematischen Inhalte und Verfahren',
|
||||||
|
weight: 2.0,
|
||||||
|
maxPoints: 40,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Sehr gut', description: 'Fachliche Inhalte vollständig und korrekt dargestellt', points: 40 },
|
||||||
|
{ label: 'Gut', description: 'Fachliche Inhalte weitgehend korrekt, kleinere Fehler', points: 32 },
|
||||||
|
{ label: 'Befriedigend', description: 'Grundlegende Inhalte erkannt, einige Verständnislücken', points: 24 },
|
||||||
|
{ label: 'Ausreichend', description: 'Nur grundlegende Ansätze erkennbar', points: 16 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Rechenweg / Darstellung',
|
||||||
|
description: 'Nachvollziehbarkeit und Übersichtlichkeit des Rechenwegs',
|
||||||
|
weight: 1.5,
|
||||||
|
maxPoints: 30,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Sehr gut', description: 'Rechenweg vollständig, klar strukturiert und nachvollziehbar', points: 30 },
|
||||||
|
{ label: 'Gut', description: 'Rechenweg nachvollziehbar, kleinere Darstellungsmängel', points: 24 },
|
||||||
|
{ label: 'Befriedigend', description: 'Rechenweg teilweise erkennbar, Lücken vorhanden', points: 18 },
|
||||||
|
{ label: 'Ausreichend', description: 'Rechenweg kaum erkennbar', points: 12 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Problemlösekompetenz',
|
||||||
|
description: 'Fähigkeit, mathematische Probleme selbstständig zu lösen',
|
||||||
|
weight: 1.0,
|
||||||
|
maxPoints: 20,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Sehr gut', description: 'Probleme selbstständig und kreativ gelöst', points: 20 },
|
||||||
|
{ label: 'Gut', description: 'Probleme überwiegend selbstständig gelöst', points: 16 },
|
||||||
|
{ label: 'Befriedigend', description: 'Probleme mit Hilfestellung gelöst', points: 12 },
|
||||||
|
{ label: 'Ausreichend', description: 'Probleme nur mit starker Hilfe lösbar', points: 8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Formale Korrektheit',
|
||||||
|
description: 'Einheiten, Vorzeichen, Tippfehler',
|
||||||
|
weight: 0.5,
|
||||||
|
maxPoints: 10,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Sehr gut', description: 'Kaum formale Fehler', points: 10 },
|
||||||
|
{ label: 'Gut', description: 'Wenige formale Fehler', points: 8 },
|
||||||
|
{ label: 'Befriedigend', description: 'Einige formale Fehler', points: 6 },
|
||||||
|
{ label: 'Ausreichend', description: 'Viele formale Fehler', points: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'german-essay',
|
||||||
|
title: 'Deutsch — Aufsichtsarbeit',
|
||||||
|
description: 'Bewertungsraster für Deutsch-Aufsichtsarbeiten (Klasse 3-6)',
|
||||||
|
subject: 'Deutsch',
|
||||||
|
type: 'ANALYTIC',
|
||||||
|
maxPoints: 100,
|
||||||
|
criteria: [
|
||||||
|
{
|
||||||
|
name: 'Inhalt / Ideen',
|
||||||
|
description: 'Originalität und Relevanz der Ideen',
|
||||||
|
weight: 2.0,
|
||||||
|
maxPoints: 30,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Sehr gut', description: 'Ideenreich, originell und thematisch passend', points: 30 },
|
||||||
|
{ label: 'Gut', description: 'Gute Ideen, thematisch passend', points: 24 },
|
||||||
|
{ label: 'Befriedigend', description: 'Ansätze erkennbar, teilweise oberflächlich', points: 18 },
|
||||||
|
{ label: 'Ausreichend', description: 'Wenig eigene Ideen, thematisch kaum erkennbar', points: 12 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Sprache / Ausdruck',
|
||||||
|
description: 'Wortschatz, Satzbau und Ausdrucksfähigkeit',
|
||||||
|
weight: 2.0,
|
||||||
|
maxPoints: 30,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Sehr gut', description: 'Reicher Wortschatz, abwechslungsreicher Satzbau', points: 30 },
|
||||||
|
{ label: 'Gut', description: 'Guter Wortschatz, überwiegend korrekte Sätze', points: 24 },
|
||||||
|
{ label: 'Befriedigend', description: 'Einfacher Wortschatz, einige Satzfehler', points: 18 },
|
||||||
|
{ label: 'Ausreichend', description: 'Sehr eingeschränkter Wortschatz, viele Fehler', points: 12 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Rechtschreibung / Grammatik',
|
||||||
|
description: 'Orthografische und grammatische Korrektheit',
|
||||||
|
weight: 1.5,
|
||||||
|
maxPoints: 20,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Sehr gut', description: 'Kaum Rechtschreib- oder Grammatikfehler', points: 20 },
|
||||||
|
{ label: 'Gut', description: 'Wenige Fehler, die das Verständnis nicht stören', points: 16 },
|
||||||
|
{ label: 'Befriedigend', description: 'Einige Fehler, Lesbarkeit teilweise beeinträchtigt', points: 12 },
|
||||||
|
{ label: 'Ausreichend', description: 'Viele Fehler, Text schwer verständlich', points: 8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Aufbau / Struktur',
|
||||||
|
description: 'Logischer Aufbau und Gliederung des Textes',
|
||||||
|
weight: 1.5,
|
||||||
|
maxPoints: 20,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Sehr gut', description: 'Klarer, logischer Aufbau mit rotem Faden', points: 20 },
|
||||||
|
{ label: 'Gut', description: 'Aufbau erkennbar, kleine Sprünge', points: 16 },
|
||||||
|
{ label: 'Befriedigend', description: 'Aufbau teilweise erkennbar', points: 12 },
|
||||||
|
{ label: 'Ausreichend', description: 'Unstrukturierter Text', points: 8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'english-writing',
|
||||||
|
title: 'Englisch — Writing Task',
|
||||||
|
description: 'Assessment rubric for English writing tasks (Grade 3-6)',
|
||||||
|
subject: 'Englisch',
|
||||||
|
type: 'ANALYTIC',
|
||||||
|
maxPoints: 100,
|
||||||
|
criteria: [
|
||||||
|
{
|
||||||
|
name: 'Content / Ideas',
|
||||||
|
description: 'Relevance and development of ideas',
|
||||||
|
weight: 2.0,
|
||||||
|
maxPoints: 30,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Excellent', description: 'Ideas are well-developed, relevant and creative', points: 30 },
|
||||||
|
{ label: 'Good', description: 'Ideas are relevant and mostly developed', points: 24 },
|
||||||
|
{ label: 'Satisfactory', description: 'Some relevant ideas, limited development', points: 18 },
|
||||||
|
{ label: 'Adequate', description: 'Few relevant ideas, minimal development', points: 12 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Language / Vocabulary',
|
||||||
|
description: 'Range and accuracy of vocabulary and grammar',
|
||||||
|
weight: 2.0,
|
||||||
|
maxPoints: 30,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Excellent', description: 'Wide vocabulary, accurate grammar, varied sentence structures', points: 30 },
|
||||||
|
{ label: 'Good', description: 'Good vocabulary, mostly accurate grammar', points: 24 },
|
||||||
|
{ label: 'Satisfactory', description: 'Basic vocabulary, some grammatical errors', points: 18 },
|
||||||
|
{ label: 'Adequate', description: 'Very limited vocabulary, frequent errors', points: 12 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Organization',
|
||||||
|
description: 'Text structure and coherence',
|
||||||
|
weight: 1.5,
|
||||||
|
maxPoints: 20,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Excellent', description: 'Well-organized, clear paragraphs, logical flow', points: 20 },
|
||||||
|
{ label: 'Good', description: 'Mostly organized, some coherence issues', points: 16 },
|
||||||
|
{ label: 'Satisfactory', description: 'Basic organization, limited coherence', points: 12 },
|
||||||
|
{ label: 'Adequate', description: 'Poorly organized, difficult to follow', points: 8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Spelling / Mechanics',
|
||||||
|
description: 'Spelling, punctuation, and capitalization',
|
||||||
|
weight: 1.0,
|
||||||
|
maxPoints: 20,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Excellent', description: 'Very few errors in spelling and mechanics', points: 20 },
|
||||||
|
{ label: 'Good', description: 'Few errors that do not impede understanding', points: 16 },
|
||||||
|
{ label: 'Satisfactory', description: 'Some errors, readability partially affected', points: 12 },
|
||||||
|
{ label: 'Adequate', description: 'Many errors, text difficult to understand', points: 8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'general-project',
|
||||||
|
title: 'Allgemein — Projektarbeit',
|
||||||
|
description: 'Bewertungsraster für fächerübergreifende Projektarbeiten',
|
||||||
|
subject: 'Allgemein',
|
||||||
|
type: 'ANALYTIC',
|
||||||
|
maxPoints: 100,
|
||||||
|
criteria: [
|
||||||
|
{
|
||||||
|
name: 'Recherche / Informationsbeschaffung',
|
||||||
|
description: 'Umfang und Qualität der recherchierten Informationen',
|
||||||
|
weight: 1.5,
|
||||||
|
maxPoints: 25,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Sehr gut', description: 'Umfangreiche und qualitativ hochwertige Informationen aus verschiedenen Quellen', points: 25 },
|
||||||
|
{ label: 'Gut', description: 'Gute Informationsbeschaffung, verschiedene Quellen', points: 20 },
|
||||||
|
{ label: 'Befriedigend', description: 'Ausreichende Informationen, wenige Quellen', points: 15 },
|
||||||
|
{ label: 'Ausreichend', description: 'Wenig Informationen, kaum Quellen', points: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Präsentation / Darstellung',
|
||||||
|
description: 'Klarheit, Struktur und Anschaulichkeit der Präsentation',
|
||||||
|
weight: 1.5,
|
||||||
|
maxPoints: 25,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Sehr gut', description: 'Klar strukturiert, ansprechend und verständlich präsentiert', points: 25 },
|
||||||
|
{ label: 'Gut', description: 'Gut strukturiert, überwiegend verständlich', points: 20 },
|
||||||
|
{ label: 'Befriedigend', description: 'Struktur erkennbar, Darstellung teilweise unklar', points: 15 },
|
||||||
|
{ label: 'Ausreichend', description: 'Wenig Struktur, Darstellung unklar', points: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Teamarbeit / Kooperation',
|
||||||
|
description: 'Zusammenarbeit im Team und Beitrag zum Gesamtergebnis',
|
||||||
|
weight: 1.0,
|
||||||
|
maxPoints: 25,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Sehr gut', description: 'Aktive Mitarbeit, konstruktive Zusammenarbeit', points: 25 },
|
||||||
|
{ label: 'Gut', description: 'Gute Mitarbeit, Zusammenarbeit funktioniert', points: 20 },
|
||||||
|
{ label: 'Befriedigend', description: 'Mitarbeit erkennbar, Zusammenarbeit teilweise', points: 15 },
|
||||||
|
{ label: 'Ausreichend', description: 'Geringe Mitarbeit, wenig Kooperation', points: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Eigenständigkeit / Kreativität',
|
||||||
|
description: 'Eigene Ideen und kreativer Umgang mit dem Thema',
|
||||||
|
weight: 1.0,
|
||||||
|
maxPoints: 25,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Sehr gut', description: 'Viele eigene Ideen, kreativer Zugang zum Thema', points: 25 },
|
||||||
|
{ label: 'Gut', description: 'Einige eigene Ideen, kreative Ansätze', points: 20 },
|
||||||
|
{ label: 'Befriedigend', description: 'Wenige eigene Ideen, wenig Kreativität', points: 15 },
|
||||||
|
{ label: 'Ausreichend', description: 'Kaum eigene Ideen, rein reproduktiv', points: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'oral-presentation',
|
||||||
|
title: 'Allgemein — Mündliche Präsentation',
|
||||||
|
description: 'Bewertungsraster für mündliche Präsentationen und Referate',
|
||||||
|
subject: 'Allgemein',
|
||||||
|
type: 'ANALYTIC',
|
||||||
|
maxPoints: 100,
|
||||||
|
criteria: [
|
||||||
|
{
|
||||||
|
name: 'Inhalt / Fachwissen',
|
||||||
|
description: 'Tiefe und Korrektheit des vermittelten Wissens',
|
||||||
|
weight: 2.0,
|
||||||
|
maxPoints: 30,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Sehr gut', description: 'Fachlich korrekt, tiefgründig und umfassend', points: 30 },
|
||||||
|
{ label: 'Gut', description: 'Fachlich weitgehend korrekt und informativ', points: 24 },
|
||||||
|
{ label: 'Befriedigend', description: 'Grundlagen verständlich, einige Lücken', points: 18 },
|
||||||
|
{ label: 'Ausreichend', description: 'Oberflächlich, fachliche Mängel', points: 12 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Vortragsweise',
|
||||||
|
description: 'Freies Sprechen, Lautstärke, Betonung und Tempo',
|
||||||
|
weight: 1.5,
|
||||||
|
maxPoints: 25,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Sehr gut', description: 'Frei und flüssig, gut verständlich, angemessenes Tempo', points: 25 },
|
||||||
|
{ label: 'Gut', description: 'Überwiegend frei, gut verständlich', points: 20 },
|
||||||
|
{ label: 'Befriedigend', description: 'Teilweise abgelesen, verständlich', points: 15 },
|
||||||
|
{ label: 'Ausreichend', description: 'Viel abgelesen, schwer verständlich', points: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Medieneinsatz',
|
||||||
|
description: 'Sinnvoller Einsatz von Hilfsmitteln und Medien',
|
||||||
|
weight: 1.0,
|
||||||
|
maxPoints: 25,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Sehr gut', description: 'Medien zielgerichtet und unterstützend eingesetzt', points: 25 },
|
||||||
|
{ label: 'Gut', description: 'Medien sinnvoll eingesetzt', points: 20 },
|
||||||
|
{ label: 'Befriedigend', description: 'Medien eingesetzt, aber nicht immer passend', points: 15 },
|
||||||
|
{ label: 'Ausreichend', description: 'Kaum oder unpassender Medieneinsatz', points: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Beantwortung von Fragen',
|
||||||
|
description: 'Fähigkeit, auf Nachfragen kompetent zu antworten',
|
||||||
|
weight: 1.0,
|
||||||
|
maxPoints: 20,
|
||||||
|
levels: [
|
||||||
|
{ label: 'Sehr gut', description: 'Sichere und differenzierte Antworten', points: 20 },
|
||||||
|
{ label: 'Gut', description: 'Überwiegend sichere Antworten', points: 16 },
|
||||||
|
{ label: 'Befriedigend', description: 'Antworten mit Hilfestellung möglich', points: 12 },
|
||||||
|
{ label: 'Ausreichend', description: 'Kaum Antworten möglich', points: 8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 subject = searchParams.get('subject');
|
||||||
|
|
||||||
|
let filtered = TEMPLATES;
|
||||||
|
if (subject && subject !== 'all') {
|
||||||
|
filtered = TEMPLATES.filter((t) => t.subject === subject || t.subject === 'Allgemein');
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(filtered);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Rubric templates GET error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1123,3 +1123,428 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Mobile Responsiveness Utilities ─────────────────────────────────── */
|
||||||
|
@layer utilities {
|
||||||
|
/* Safe area insets for iOS notch/bottom bar */
|
||||||
|
.safe-top { padding-top: env(safe-area-inset-top, 0px); }
|
||||||
|
.safe-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); }
|
||||||
|
.safe-left { padding-left: env(safe-area-inset-left, 0px); }
|
||||||
|
.safe-right { padding-right: env(safe-area-inset-right, 0px); }
|
||||||
|
.safe-all {
|
||||||
|
padding-top: env(safe-area-inset-top, 0px);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
padding-left: env(safe-area-inset-left, 0px);
|
||||||
|
padding-right: env(safe-area-inset-right, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent pinch-zoom on canvas elements */
|
||||||
|
.canvas-no-zoom {
|
||||||
|
touch-action: none;
|
||||||
|
-ms-touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent text selection on drawing canvas */
|
||||||
|
.canvas-no-select {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile-friendly horizontal scroll container for tables */
|
||||||
|
.mobile-scroll-x {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile stack helper */
|
||||||
|
.mobile-stack {
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile full-width inputs */
|
||||||
|
.mobile-full-width {
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile-specific viewport adjustments */
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
/* Ensure dialogs don't overflow on small screens */
|
||||||
|
[role="dialog"] {
|
||||||
|
max-width: calc(100vw - 2rem) !important;
|
||||||
|
max-height: calc(100vh - 2rem) !important;
|
||||||
|
margin: 1rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure popovers are properly sized */
|
||||||
|
[data-radix-popper-content-wrapper] {
|
||||||
|
max-width: calc(100vw - 2rem) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make card grids stack on mobile */
|
||||||
|
.grid {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix horizontal overflow */
|
||||||
|
body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent overflow in flex containers */
|
||||||
|
.flex {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make tabs scrollable on mobile */
|
||||||
|
[role="tablist"] {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
[role="tablist"]::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stack stat cards vertically on mobile */
|
||||||
|
.mobile-card-stack {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notebook grid: 1 column on mobile */
|
||||||
|
.mobile-notebook-grid {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table horizontal scroll on mobile */
|
||||||
|
.mobile-table-scroll {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-width inputs on mobile */
|
||||||
|
input, select, textarea {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet-specific adjustments (640px-768px) */
|
||||||
|
@media (min-width: 640px) and (max-width: 767px) {
|
||||||
|
/* Notebook grid: 2 columns on tablet */
|
||||||
|
.mobile-notebook-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Micro-interaction Animations ─────────────────────────────────────── */
|
||||||
|
@layer utilities {
|
||||||
|
/* Fade in + slide up */
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.animate-fade-in-up {
|
||||||
|
animation: fade-in-up 0.35s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale in from 0.95 to 1 */
|
||||||
|
@keyframes scale-in {
|
||||||
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
.animate-scale-in {
|
||||||
|
animation: scale-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error shake animation */
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
|
||||||
|
20%, 40%, 60%, 80% { transform: translateX(4px); }
|
||||||
|
}
|
||||||
|
.animate-shake {
|
||||||
|
animation: shake 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success checkmark animation */
|
||||||
|
@keyframes success-check {
|
||||||
|
0% { transform: scale(0); opacity: 0; }
|
||||||
|
50% { transform: scale(1.2); }
|
||||||
|
100% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
.animate-success-check {
|
||||||
|
animation: success-check 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle lift on hover (translateY -1px) */
|
||||||
|
.hover-lift {
|
||||||
|
transition: transform 0.15s ease-out, box-shadow 0.15s ease-out;
|
||||||
|
}
|
||||||
|
.hover-lift:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px -2px oklch(0 0 0 / 0.08);
|
||||||
|
}
|
||||||
|
.dark .hover-lift:hover {
|
||||||
|
box-shadow: 0 4px 12px -2px oklch(0 0 0 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated focus ring */
|
||||||
|
.focus-ring {
|
||||||
|
transition: box-shadow 0.2s ease-out, outline 0.2s ease-out;
|
||||||
|
}
|
||||||
|
.focus-ring:focus-visible {
|
||||||
|
outline: 2px solid oklch(0.55 0.17 155);
|
||||||
|
outline-offset: 2px;
|
||||||
|
box-shadow: 0 0 0 4px oklch(0.55 0.17 155 / 0.15);
|
||||||
|
}
|
||||||
|
.dark .focus-ring:focus-visible {
|
||||||
|
outline: 2px solid oklch(0.65 0.17 155);
|
||||||
|
box-shadow: 0 0 0 4px oklch(0.65 0.17 155 / 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Enhanced Print Styles ────────────────────────────────────────────── */
|
||||||
|
@media print {
|
||||||
|
/* Better print layout for reports */
|
||||||
|
@page {
|
||||||
|
margin: 15mm 12mm;
|
||||||
|
size: A4;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-size: 11pt;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #000 !important;
|
||||||
|
background: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print-specific font sizes */
|
||||||
|
h1 { font-size: 18pt !important; }
|
||||||
|
h2 { font-size: 14pt !important; }
|
||||||
|
h3 { font-size: 12pt !important; }
|
||||||
|
p, li, td, th { font-size: 10pt !important; }
|
||||||
|
|
||||||
|
/* Hide interactive elements */
|
||||||
|
button, nav, aside, footer, header,
|
||||||
|
[role="button"], [role="tablist"], [role="tab"],
|
||||||
|
.no-print, [data-sidebar], .sidebar-trigger,
|
||||||
|
.breadcrumb, .toast-container, [data-radix-popper-content-wrapper] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure cards have visible borders */
|
||||||
|
[class*="card"], [class*="Card"] {
|
||||||
|
border: 1px solid #ddd !important;
|
||||||
|
break-inside: avoid;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page break controls */
|
||||||
|
.page-break-before { page-break-before: always; }
|
||||||
|
.page-break-after { page-break-after: always; }
|
||||||
|
.page-break-avoid { page-break-inside: avoid; }
|
||||||
|
|
||||||
|
/* Remove shadows */
|
||||||
|
[class*="shadow"] { box-shadow: none !important; }
|
||||||
|
|
||||||
|
/* Ensure dark mode prints as white */
|
||||||
|
.dark { background: white !important; color: black !important; }
|
||||||
|
.dark * { color: inherit !important; background: transparent !important; }
|
||||||
|
|
||||||
|
/* Remove animations */
|
||||||
|
* { animation: none !important; transition: none !important; }
|
||||||
|
|
||||||
|
/* Ensure overflow is visible */
|
||||||
|
.overflow-y-auto, .max-h-96, .max-h-80, .max-h-72 {
|
||||||
|
max-height: none !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print links with URL */
|
||||||
|
a[href]:after {
|
||||||
|
content: " (" attr(href) ")";
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
a[href^="#"]:after { content: ""; }
|
||||||
|
|
||||||
|
/* Table styling for print */
|
||||||
|
table { border-collapse: collapse; }
|
||||||
|
th, td { border: 1px solid #ccc; padding: 4px 8px; }
|
||||||
|
th { background: #f0f0f0 !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Dark Mode Polish ─────────────────────────────────────────────────── */
|
||||||
|
@layer utilities {
|
||||||
|
/* Better dark mode borders */
|
||||||
|
.dark-border {
|
||||||
|
border-color: oklch(1 0 0 / 8%);
|
||||||
|
}
|
||||||
|
.dark .dark-border {
|
||||||
|
border-color: oklch(1 0 0 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode card shadows */
|
||||||
|
.dark-shadow {
|
||||||
|
box-shadow: 0 1px 3px oklch(0 0 0 / 0.06);
|
||||||
|
}
|
||||||
|
.dark .dark-shadow {
|
||||||
|
box-shadow: 0 1px 3px oklch(0 0 0 / 0.3), 0 0 0 1px oklch(1 0 0 / 6%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions between light/dark */
|
||||||
|
.theme-transition {
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode specific borders for cards */
|
||||||
|
.dark .card-dark-border {
|
||||||
|
border-color: oklch(1 0 0 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode specific shadows for elevated elements */
|
||||||
|
.dark .elevated-dark {
|
||||||
|
box-shadow: 0 4px 16px oklch(0 0 0 / 0.4), 0 0 0 1px oklch(1 0 0 / 6%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better contrast ratios for dark mode text */
|
||||||
|
.dark .text-muted-foreground {
|
||||||
|
color: oklch(0.70 0.02 260);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Accessibility Improvements ───────────────────────────────────────── */
|
||||||
|
@layer utilities {
|
||||||
|
/* Skip to content link */
|
||||||
|
.skip-to-content {
|
||||||
|
position: absolute;
|
||||||
|
top: -100%;
|
||||||
|
left: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: oklch(0.55 0.17 155);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 0 0 8px 0;
|
||||||
|
transition: top 0.2s ease;
|
||||||
|
}
|
||||||
|
.skip-to-content:focus {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen reader only utility */
|
||||||
|
.sr-only-focusable:focus {
|
||||||
|
position: static;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
padding: inherit;
|
||||||
|
margin: inherit;
|
||||||
|
overflow: visible;
|
||||||
|
clip: auto;
|
||||||
|
white-space: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus management for modals */
|
||||||
|
.modal-focus-wrapper {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.modal-focus-wrapper:focus-visible {
|
||||||
|
outline: 2px solid oklch(0.55 0.17 155);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live region for screen reader announcements */
|
||||||
|
.sr-live-region {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* High contrast mode support */
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
.button-primary {
|
||||||
|
border: 2px solid ButtonText;
|
||||||
|
}
|
||||||
|
.card-outline {
|
||||||
|
border: 1px solid CanvasText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion support */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.animate-fade-in-up,
|
||||||
|
.animate-scale-in,
|
||||||
|
.animate-shake,
|
||||||
|
.animate-success-check,
|
||||||
|
.animate-float,
|
||||||
|
.animate-float-reverse,
|
||||||
|
.animate-float-slow,
|
||||||
|
.animate-pulse-soft,
|
||||||
|
.animate-shimmer,
|
||||||
|
.animate-progress-fill,
|
||||||
|
.animate-view-fade-in,
|
||||||
|
.animate-enter-fade,
|
||||||
|
.animate-count-up,
|
||||||
|
.animate-stagger-in,
|
||||||
|
.animate-chart-shimmer,
|
||||||
|
.animate-pulse-dot,
|
||||||
|
.animate-slide-in,
|
||||||
|
.animate-slide-out,
|
||||||
|
.hover-lift:hover {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Canvas Drawing Performance ───────────────────────────────────────── */
|
||||||
|
@layer utilities {
|
||||||
|
/* GPU-accelerated canvas rendering */
|
||||||
|
.canvas-gpu {
|
||||||
|
will-change: transform;
|
||||||
|
transform: translateZ(0);
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Canvas layer caching */
|
||||||
|
.canvas-layer-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.canvas-layer-draw {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Performance monitor */
|
||||||
|
.fps-counter {
|
||||||
|
position: fixed;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: monospace;
|
||||||
|
background: oklch(0 0 0 / 0.7);
|
||||||
|
color: oklch(0.55 0.17 155);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,6 +114,7 @@ import {
|
|||||||
CommandShortcut,
|
CommandShortcut,
|
||||||
} from '@/components/ui/command';
|
} from '@/components/ui/command';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useWebSocket, usePushNotifications, playNotificationSound, getNotificationSoundPref } from '@/lib/websocket';
|
||||||
|
|
||||||
import DashboardView from './dashboard-view';
|
import DashboardView from './dashboard-view';
|
||||||
import ClassesView from './classes-view';
|
import ClassesView from './classes-view';
|
||||||
@@ -256,6 +257,10 @@ export default function AppLayout() {
|
|||||||
const [schoolYears, setSchoolYears] = useState<SchoolYear[]>([]);
|
const [schoolYears, setSchoolYears] = useState<SchoolYear[]>([]);
|
||||||
const [selectedYearId, setSelectedYearId] = useState<string>(storeSchoolYearId ?? '');
|
const [selectedYearId, setSelectedYearId] = useState<string>(storeSchoolYearId ?? '');
|
||||||
|
|
||||||
|
// WebSocket connection for real-time push notifications
|
||||||
|
const { connected: wsConnected } = useWebSocket();
|
||||||
|
const { notification: pushNotification } = usePushNotifications();
|
||||||
|
|
||||||
// Quick search (Cmd+K) state
|
// Quick search (Cmd+K) state
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -500,6 +505,23 @@ export default function AppLayout() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadNotifications]);
|
}, [loadNotifications]);
|
||||||
|
|
||||||
|
// Handle push notifications in real-time (after loadNotifications is defined)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pushNotification) return;
|
||||||
|
// Play notification sound if enabled
|
||||||
|
if (getNotificationSoundPref()) {
|
||||||
|
playNotificationSound();
|
||||||
|
}
|
||||||
|
// Show toast notification
|
||||||
|
const typeInfo = getNotifTypeInfo(pushNotification.type);
|
||||||
|
toast(typeInfo.label, {
|
||||||
|
description: pushNotification.message,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
// Refresh notification data to update the bell count
|
||||||
|
loadNotifications();
|
||||||
|
}, [pushNotification, loadNotifications]);
|
||||||
|
|
||||||
const handleMarkAllRead = useCallback(async () => {
|
const handleMarkAllRead = useCallback(async () => {
|
||||||
if (!notifData || notifData.unreadCount === 0) return;
|
if (!notifData || notifData.unreadCount === 0) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -24,7 +24,25 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
LucideIcon,
|
LucideIcon,
|
||||||
FilePenLine,
|
FilePenLine,
|
||||||
|
Download,
|
||||||
|
AlertTriangle,
|
||||||
|
Shield,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip as RechartsTooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -71,11 +89,13 @@ import {
|
|||||||
deleteAttendanceSession,
|
deleteAttendanceSession,
|
||||||
fetchClasses,
|
fetchClasses,
|
||||||
fetchSubjects,
|
fetchSubjects,
|
||||||
|
fetchAttendanceAnalytics,
|
||||||
type AttendanceSession,
|
type AttendanceSession,
|
||||||
type AttendanceRecord,
|
type AttendanceRecord,
|
||||||
type AttendanceStatus,
|
type AttendanceStatus,
|
||||||
type ClassGroup,
|
type ClassGroup,
|
||||||
type Subject,
|
type Subject,
|
||||||
|
type AttendanceAnalyticsData,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
|
|
||||||
/* ── Status helpers ────────────────────────────────────────────────── */
|
/* ── Status helpers ────────────────────────────────────────────────── */
|
||||||
@@ -689,6 +709,341 @@ function SessionDetail({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Attendance Analytics Tab Component ──────────────────────────── */
|
||||||
|
|
||||||
|
function AttendanceAnalyticsTab() {
|
||||||
|
const currentUser = useAppStore((s) => s.currentUser);
|
||||||
|
const schoolId = currentUser?.schoolId;
|
||||||
|
const currentClassId = useAppStore((s) => s.currentClassId);
|
||||||
|
|
||||||
|
const [analyticsData, setAnalyticsData] = useState<AttendanceAnalyticsData | null>(null);
|
||||||
|
const [analyticsLoading, setAnalyticsLoading] = useState(false);
|
||||||
|
|
||||||
|
const loadAnalytics = useCallback(async () => {
|
||||||
|
if (!schoolId) return;
|
||||||
|
setAnalyticsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchAttendanceAnalytics(schoolId, currentClassId ?? undefined);
|
||||||
|
setAnalyticsData(data);
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setAnalyticsLoading(false);
|
||||||
|
}
|
||||||
|
}, [schoolId, currentClassId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAnalytics();
|
||||||
|
}, [loadAnalytics]);
|
||||||
|
|
||||||
|
const exportCSV = useCallback(() => {
|
||||||
|
if (!analyticsData) return;
|
||||||
|
const rows: string[] = [];
|
||||||
|
rows.push('Week,Attendance Rate,Absent Rate,Present,Absent,Excused,Late,Total');
|
||||||
|
for (const row of analyticsData.trendData) {
|
||||||
|
rows.push(`${row.week},${row.attendanceRate},${row.absentRate},${row.present},${row.absent},${row.excused},${row.late},${row.total}`);
|
||||||
|
}
|
||||||
|
rows.push('');
|
||||||
|
rows.push('Student,FirstName,LastName,Absence Rate,Total Absences,Total Sessions,Risk Level');
|
||||||
|
for (const row of analyticsData.riskIndicators) {
|
||||||
|
rows.push(`${row.studentId},${row.firstName},${row.lastName},${row.absenceRate},${row.totalAbsences},${row.totalSessions},${row.riskLevel}`);
|
||||||
|
}
|
||||||
|
const csvContent = rows.join('\n');
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'attendance-analytics.csv';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [analyticsData]);
|
||||||
|
|
||||||
|
if (analyticsLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!analyticsData || analyticsData.totalRecords === 0) {
|
||||||
|
return (
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center py-16">
|
||||||
|
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-md">
|
||||||
|
<BarChart3 className="h-10 w-10 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-1">{t('attendance.no_analytics_data')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('attendance.no_analytics_desc')}</p>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_COLORS = ['#10b981', '#14b8a6', '#f59e0b', '#ef4444'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Export button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={exportCSV}>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
{t('attendance.export_csv')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview Stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<Card className="border-2 border-emerald-200 dark:border-emerald-800">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-emerald-600">{analyticsData.totalSessions}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t('attendance.total_sessions')}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-2 border-teal-200 dark:border-teal-800">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-teal-600">{analyticsData.totalRecords}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t('attendance.total_records')}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-2 border-amber-200 dark:border-amber-800">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-amber-600">{analyticsData.absencePatterns.length}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t('attendance.chronic_absence')}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-2 border-rose-200 dark:border-rose-800">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-rose-600">{analyticsData.riskIndicators.length}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t('attendance.risk_indicators')}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attendance Trends Line Chart */}
|
||||||
|
{analyticsData.trendData.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-emerald-500" />
|
||||||
|
{t('attendance.weekly_trend')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<LineChart data={analyticsData.trendData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="week"
|
||||||
|
tickFormatter={(v: string) => {
|
||||||
|
const d = new Date(v);
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
|
}}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<YAxis domain={[0, 100]} tick={{ fontSize: 11 }} />
|
||||||
|
<RechartsTooltip />
|
||||||
|
<Legend />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="attendanceRate"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: '#10b981', r: 4 }}
|
||||||
|
name={t('attendance.attendance_rate_over_time')}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="absentRate"
|
||||||
|
stroke="#ef4444"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: '#ef4444', r: 4 }}
|
||||||
|
name={t('attendance.absence_rate_over_time')}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Day-of-Week Bar Chart */}
|
||||||
|
{analyticsData.dayOfWeekAnalysis.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-4 w-4 text-teal-500" />
|
||||||
|
{t('attendance.absences_by_day')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
|
<BarChart data={analyticsData.dayOfWeekAnalysis}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||||
|
<XAxis dataKey="dayNameDe" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} />
|
||||||
|
<RechartsTooltip />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="attendanceRate" fill="#10b981" name={t('attendance.rate')} radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="absentCount" fill="#ef4444" name={t('attendance.absent_count_long')} radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Distribution Pie Chart */}
|
||||||
|
{analyticsData.statusDistribution.present + analyticsData.statusDistribution.absent + analyticsData.statusDistribution.excused + analyticsData.statusDistribution.late > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<Shield className="h-4 w-4 text-amber-500" />
|
||||||
|
{t('attendance.status_distribution')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={[
|
||||||
|
{ name: t('attendance.present'), value: analyticsData.statusDistribution.present },
|
||||||
|
{ name: t('attendance.absent'), value: analyticsData.statusDistribution.absent },
|
||||||
|
{ name: t('attendance.excused'), value: analyticsData.statusDistribution.excused },
|
||||||
|
{ name: t('attendance.late'), value: analyticsData.statusDistribution.late },
|
||||||
|
]}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={80}
|
||||||
|
dataKey="value"
|
||||||
|
label={({ name, value }: { name: string; value: number }) => `${name}: ${value}`}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ name: t('attendance.present'), value: analyticsData.statusDistribution.present },
|
||||||
|
{ name: t('attendance.absent'), value: analyticsData.statusDistribution.absent },
|
||||||
|
{ name: t('attendance.excused'), value: analyticsData.statusDistribution.excused },
|
||||||
|
{ name: t('attendance.late'), value: analyticsData.statusDistribution.late },
|
||||||
|
].map((_, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={CHART_COLORS[index]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Legend />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chronic Absence */}
|
||||||
|
{analyticsData.absencePatterns.length > 0 && (
|
||||||
|
<Card className="border-2 border-amber-200 dark:border-amber-800">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||||
|
{t('attendance.chronic_absence')}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('attendance.chronic_absence_desc')}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{analyticsData.absencePatterns.map((s) => (
|
||||||
|
<div key={s.studentId} className="flex items-center justify-between p-3 rounded-lg border bg-amber-50/50 dark:bg-amber-950/20">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-sm">{s.lastName}, {s.firstName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge className="bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300">
|
||||||
|
{s.absenceRate}% {t('attendance.absent_count_long')}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{s.absent}/{s.total}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Risk Indicators */}
|
||||||
|
{analyticsData.riskIndicators.length > 0 && (
|
||||||
|
<Card className="border-2 border-rose-200 dark:border-rose-800">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4 text-rose-500" />
|
||||||
|
{t('attendance.risk_indicators')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{analyticsData.riskIndicators.map((s) => {
|
||||||
|
const riskColors: Record<string, string> = {
|
||||||
|
medium: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||||
|
high: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
|
||||||
|
critical: 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300',
|
||||||
|
};
|
||||||
|
const riskLabels: Record<string, string> = {
|
||||||
|
medium: t('attendance.risk_medium'),
|
||||||
|
high: t('attendance.risk_high'),
|
||||||
|
critical: t('attendance.risk_critical'),
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div key={s.studentId} className="flex items-center justify-between p-3 rounded-lg border bg-rose-50/50 dark:bg-rose-950/20">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-sm">{s.lastName}, {s.firstName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge className={riskColors[s.riskLevel] ?? ''}>
|
||||||
|
{riskLabels[s.riskLevel] ?? s.riskLevel}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{s.absenceRate}%
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Class Comparison */}
|
||||||
|
{analyticsData.classComparison.length > 1 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4 text-emerald-500" />
|
||||||
|
{t('attendance.class_comparison')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{analyticsData.classComparison.map((c) => (
|
||||||
|
<div key={c.classGroupId} className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-medium w-24 truncate">{c.className}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="bg-gray-100 dark:bg-gray-800 rounded-full h-6 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
c.attendanceRate >= 90 ? 'bg-emerald-500' :
|
||||||
|
c.attendanceRate >= 70 ? 'bg-amber-500' :
|
||||||
|
'bg-rose-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.max(c.attendanceRate, 2)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">{c.attendanceRate}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Main Attendance View ──────────────────────────────────────────── */
|
/* ── Main Attendance View ──────────────────────────────────────────── */
|
||||||
|
|
||||||
export default function AttendanceView() {
|
export default function AttendanceView() {
|
||||||
@@ -1114,6 +1469,10 @@ export default function AttendanceView() {
|
|||||||
<TabsTrigger value="stats" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
|
<TabsTrigger value="stats" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
|
||||||
{t('attendance.stats')}
|
{t('attendance.stats')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="analytics" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
|
||||||
|
<BarChart3 className="h-4 w-4 mr-1" />
|
||||||
|
{t('attendance.analytics')}
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="calendar" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
|
<TabsTrigger value="calendar" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
|
||||||
{t('attendance.calendar')}
|
{t('attendance.calendar')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -1282,6 +1641,11 @@ export default function AttendanceView() {
|
|||||||
<StatsCards sessions={sessions} />
|
<StatsCards sessions={sessions} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Analytics Tab */}
|
||||||
|
<TabsContent value="analytics" className="space-y-4">
|
||||||
|
<AttendanceAnalyticsTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* Calendar Tab */}
|
{/* Calendar Tab */}
|
||||||
<TabsContent value="calendar" className="space-y-4">
|
<TabsContent value="calendar" className="space-y-4">
|
||||||
<MonthlyHeatmap sessions={sessions} />
|
<MonthlyHeatmap sessions={sessions} />
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState, useMemo, memo } from 'react';
|
||||||
import {
|
import {
|
||||||
Pencil,
|
Pencil,
|
||||||
PenTool,
|
PenTool,
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
|
Activity,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
@@ -47,6 +48,14 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerDescription,
|
||||||
|
} from '@/components/ui/drawer';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -103,7 +112,10 @@ const PEN_COLORS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const AUTO_SAVE_INTERVAL = 30000;
|
const AUTO_SAVE_INTERVAL = 30000;
|
||||||
|
const AUTO_SAVE_DEBOUNCE = 5000; // Debounced auto-save: 5s after last stroke
|
||||||
const LOCAL_STORAGE_KEY = 'ct_drawing_draft';
|
const LOCAL_STORAGE_KEY = 'ct_drawing_draft';
|
||||||
|
const POINT_SIMPLIFICATION_TOLERANCE = 2; // Distance threshold for point simplification
|
||||||
|
const DRAWING_RAF_THRESHOLD = 16; // ~60fps target
|
||||||
|
|
||||||
/* ── Utility ───────────────────────────────────────────────────────── */
|
/* ── Utility ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -111,6 +123,39 @@ function generateId(): string {
|
|||||||
return `stroke_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
return `stroke_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Point Simplification (Ramer-Douglas-Peucker) ──────────────────── */
|
||||||
|
|
||||||
|
function distToSegment(p: StrokePoint, a: StrokePoint, b: StrokePoint): number {
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
|
const lenSq = dx * dx + dy * dy;
|
||||||
|
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
|
||||||
|
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
|
||||||
|
}
|
||||||
|
|
||||||
|
function simplifyPoints(points: StrokePoint[], tolerance: number): StrokePoint[] {
|
||||||
|
if (points.length <= 2) return points;
|
||||||
|
let maxDist = 0;
|
||||||
|
let maxIdx = 0;
|
||||||
|
const first = points[0];
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
for (let i = 1; i < points.length - 1; i++) {
|
||||||
|
const d = distToSegment(points[i], first, last);
|
||||||
|
if (d > maxDist) {
|
||||||
|
maxDist = d;
|
||||||
|
maxIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxDist > tolerance) {
|
||||||
|
const left = simplifyPoints(points.slice(0, maxIdx + 1), tolerance);
|
||||||
|
const right = simplifyPoints(points.slice(maxIdx), tolerance);
|
||||||
|
return [...left.slice(0, -1), ...right];
|
||||||
|
}
|
||||||
|
return [first, last];
|
||||||
|
}
|
||||||
|
|
||||||
function getCanvasPoint(
|
function getCanvasPoint(
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
e: React.MouseEvent | React.TouchEvent | PointerEvent
|
e: React.MouseEvent | React.TouchEvent | PointerEvent
|
||||||
@@ -408,9 +453,29 @@ export default function DrawingCanvas({
|
|||||||
}: DrawingCanvasProps) {
|
}: DrawingCanvasProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Background layer canvas for caching (avoids redrawing background every frame)
|
||||||
|
const bgCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
// RAF tracking
|
||||||
|
const rafIdRef = useRef<number>(0);
|
||||||
|
const lastDrawTimeRef = useRef<number>(0);
|
||||||
|
// Debounced auto-save timer
|
||||||
|
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
// FPS counter
|
||||||
|
const fpsFrameCountRef = useRef<number>(0);
|
||||||
|
const fpsLastTimeRef = useRef<number>(performance.now());
|
||||||
|
const [fpsDisplay, setFpsDisplay] = useState<number>(0);
|
||||||
|
const [showFps, setShowFps] = useState(false);
|
||||||
|
|
||||||
// Drawing state
|
// Drawing state
|
||||||
const [strokes, setStrokes] = useState<Stroke[]>([]);
|
const [strokes, setStrokes] = useState<Stroke[]>(() => {
|
||||||
|
if (!initialDrawingData) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(initialDrawingData) as Stroke[];
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
const [redoStack, setRedoStack] = useState<Stroke[]>([]);
|
const [redoStack, setRedoStack] = useState<Stroke[]>([]);
|
||||||
const [currentStroke, setCurrentStroke] = useState<Stroke | null>(null);
|
const [currentStroke, setCurrentStroke] = useState<Stroke | null>(null);
|
||||||
const [isDrawing, setIsDrawing] = useState(false);
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
@@ -436,22 +501,14 @@ export default function DrawingCanvas({
|
|||||||
// Zoom state
|
// Zoom state
|
||||||
const [zoomLevel, setZoomLevel] = useState(100);
|
const [zoomLevel, setZoomLevel] = useState(100);
|
||||||
|
|
||||||
/* ── Load initial data ─────────────────────────────────────────── */
|
// Detect mobile for drawer vs dialog
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialDrawingData) {
|
const check = () => setIsMobile(window.innerWidth < 640);
|
||||||
try {
|
check();
|
||||||
const parsed = JSON.parse(initialDrawingData) as Stroke[];
|
window.addEventListener('resize', check);
|
||||||
if (Array.isArray(parsed)) {
|
return () => window.removeEventListener('resize', check);
|
||||||
setStrokes(parsed);
|
}, []);
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore invalid data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [initialDrawingData]);
|
|
||||||
|
|
||||||
/* ── Resize canvas to container ─────────────────────────────────── */
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateSize = () => {
|
const updateSize = () => {
|
||||||
@@ -468,7 +525,29 @@ export default function DrawingCanvas({
|
|||||||
return () => window.removeEventListener('resize', updateSize);
|
return () => window.removeEventListener('resize', updateSize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/* ── Redraw canvas ──────────────────────────────────────────────── */
|
/* ── Build background layer cache ──────────────────────────────── */
|
||||||
|
|
||||||
|
const buildBgCache = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
if (!bgCanvasRef.current) {
|
||||||
|
bgCanvasRef.current = document.createElement('canvas');
|
||||||
|
}
|
||||||
|
bgCanvasRef.current.width = canvas.width;
|
||||||
|
bgCanvasRef.current.height = canvas.height;
|
||||||
|
const ctx = bgCanvasRef.current.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
drawBackground(ctx, canvas.width, canvas.height, bgType);
|
||||||
|
if (guideMode !== 'off') {
|
||||||
|
drawGuideOverlay(ctx, canvas.width, canvas.height, guideMode);
|
||||||
|
}
|
||||||
|
}, [bgType, guideMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
buildBgCache();
|
||||||
|
}, [buildBgCache]);
|
||||||
|
|
||||||
|
/* ── Redraw canvas with RAF + layer caching ──────────────────── */
|
||||||
|
|
||||||
const redrawCanvas = useCallback(() => {
|
const redrawCanvas = useCallback(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -476,13 +555,15 @@ export default function DrawingCanvas({
|
|||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
// Clear and draw background
|
// Use cached background layer
|
||||||
|
if (bgCanvasRef.current) {
|
||||||
|
ctx.drawImage(bgCanvasRef.current, 0, 0);
|
||||||
|
} else {
|
||||||
drawBackground(ctx, canvas.width, canvas.height, bgType);
|
drawBackground(ctx, canvas.width, canvas.height, bgType);
|
||||||
|
|
||||||
// Draw guide overlay
|
|
||||||
if (guideMode !== 'off') {
|
if (guideMode !== 'off') {
|
||||||
drawGuideOverlay(ctx, canvas.width, canvas.height, guideMode);
|
drawGuideOverlay(ctx, canvas.width, canvas.height, guideMode);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Draw all committed strokes
|
// Draw all committed strokes
|
||||||
for (const stroke of strokes) {
|
for (const stroke of strokes) {
|
||||||
@@ -495,12 +576,84 @@ export default function DrawingCanvas({
|
|||||||
}
|
}
|
||||||
}, [strokes, currentStroke, bgType, guideMode]);
|
}, [strokes, currentStroke, bgType, guideMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
// RAF-based drawing for smooth animation
|
||||||
|
const scheduleRedraw = useCallback(() => {
|
||||||
|
if (rafIdRef.current) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
}
|
||||||
|
rafIdRef.current = requestAnimationFrame(() => {
|
||||||
redrawCanvas();
|
redrawCanvas();
|
||||||
|
rafIdRef.current = 0;
|
||||||
|
});
|
||||||
}, [redrawCanvas]);
|
}, [redrawCanvas]);
|
||||||
|
|
||||||
/* ── Auto-save ──────────────────────────────────────────────────── */
|
useEffect(() => {
|
||||||
|
scheduleRedraw();
|
||||||
|
return () => {
|
||||||
|
if (rafIdRef.current) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [scheduleRedraw]);
|
||||||
|
|
||||||
|
/* ── FPS Counter ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showFps) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const now = performance.now();
|
||||||
|
const elapsed = now - fpsLastTimeRef.current;
|
||||||
|
if (elapsed > 0) {
|
||||||
|
setFpsDisplay(Math.round((fpsFrameCountRef.current / elapsed) * 1000));
|
||||||
|
}
|
||||||
|
fpsFrameCountRef.current = 0;
|
||||||
|
fpsLastTimeRef.current = now;
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [showFps]);
|
||||||
|
|
||||||
|
// Count frames for FPS
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showFps) return;
|
||||||
|
const countFrame = () => {
|
||||||
|
fpsFrameCountRef.current++;
|
||||||
|
requestAnimationFrame(countFrame);
|
||||||
|
};
|
||||||
|
const id = requestAnimationFrame(countFrame);
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, [showFps]);
|
||||||
|
|
||||||
|
/* ── Auto-save (debounced) ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
const debouncedAutoSave = useCallback(() => {
|
||||||
|
if (autoSaveTimerRef.current) {
|
||||||
|
clearTimeout(autoSaveTimerRef.current);
|
||||||
|
}
|
||||||
|
autoSaveTimerRef.current = setTimeout(() => {
|
||||||
|
if (strokes.length > 0) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
LOCAL_STORAGE_KEY,
|
||||||
|
JSON.stringify({ strokes, bgType, title: drawingTitle, updatedAt: new Date().toISOString() })
|
||||||
|
);
|
||||||
|
setLastAutoSave(new Date());
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, AUTO_SAVE_DEBOUNCE);
|
||||||
|
}, [strokes, bgType, drawingTitle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
debouncedAutoSave();
|
||||||
|
return () => {
|
||||||
|
if (autoSaveTimerRef.current) {
|
||||||
|
clearTimeout(autoSaveTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [debouncedAutoSave]);
|
||||||
|
|
||||||
|
// Also keep periodic auto-save as backup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (strokes.length > 0) {
|
if (strokes.length > 0) {
|
||||||
@@ -581,7 +734,12 @@ export default function DrawingCanvas({
|
|||||||
if (currentStroke.tool === 'line' || currentStroke.tool === 'rectangle' || currentStroke.tool === 'circle') {
|
if (currentStroke.tool === 'line' || currentStroke.tool === 'rectangle' || currentStroke.tool === 'circle') {
|
||||||
finalStroke = { ...currentStroke, endPoint: { x: point.x, y: point.y } };
|
finalStroke = { ...currentStroke, endPoint: { x: point.x, y: point.y } };
|
||||||
} else {
|
} else {
|
||||||
finalStroke = { ...currentStroke, points: [...currentStroke.points, point] };
|
// Simplify points for freehand strokes (performance optimization)
|
||||||
|
const allPoints = [...currentStroke.points, point];
|
||||||
|
const simplified = allPoints.length > 10
|
||||||
|
? simplifyPoints(allPoints, POINT_SIMPLIFICATION_TOLERANCE)
|
||||||
|
: allPoints;
|
||||||
|
finalStroke = { ...currentStroke, points: simplified };
|
||||||
}
|
}
|
||||||
|
|
||||||
setStrokes((prev) => [...prev, finalStroke]);
|
setStrokes((prev) => [...prev, finalStroke]);
|
||||||
@@ -752,8 +910,8 @@ export default function DrawingCanvas({
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-gray-50 dark:bg-gray-950">
|
<div className="flex h-full flex-col bg-gray-50 dark:bg-gray-950">
|
||||||
{/* ── Toolbar ────────────────────────────────────────────────── */}
|
{/* ── Toolbar ────────────────────────────────────────────────── */}
|
||||||
<div className="sticky top-0 z-20 border-b border-gray-200 bg-gray-900 px-3 py-2 text-white shadow-lg">
|
<div className="sticky top-0 z-20 border-b border-gray-200 bg-gray-900 px-2 sm:px-3 py-2 text-white shadow-lg safe-top">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-1 sm:gap-2">
|
||||||
{/* Exit button */}
|
{/* Exit button */}
|
||||||
{onExit && (
|
{onExit && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -763,7 +921,8 @@ export default function DrawingCanvas({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onExit}
|
onClick={onExit}
|
||||||
className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white"
|
className="h-10 w-10 min-touch text-gray-300 hover:bg-gray-800 hover:text-white"
|
||||||
|
aria-label={t('drawing.exit')}
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -774,7 +933,7 @@ export default function DrawingCanvas({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="mx-1 h-8 w-px bg-gray-700" />
|
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
|
||||||
|
|
||||||
{/* Drawing tools */}
|
{/* Drawing tools */}
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -785,11 +944,13 @@ export default function DrawingCanvas({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setActiveTool(tool.id)}
|
onClick={() => setActiveTool(tool.id)}
|
||||||
className={`h-10 w-10 transition-all rounded-lg ${
|
className={`h-10 w-10 min-touch transition-all rounded-lg ${
|
||||||
activeTool === tool.id
|
activeTool === tool.id
|
||||||
? 'bg-emerald-600 text-white shadow-md shadow-emerald-500/30 scale-105'
|
? 'bg-emerald-600 text-white shadow-md shadow-emerald-500/30 scale-105'
|
||||||
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
|
aria-label={tool.label}
|
||||||
|
aria-pressed={activeTool === tool.id}
|
||||||
>
|
>
|
||||||
{tool.icon}
|
{tool.icon}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -800,7 +961,7 @@ export default function DrawingCanvas({
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="mx-1 h-8 w-px bg-gray-700" />
|
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
|
||||||
|
|
||||||
{/* Color picker */}
|
{/* Color picker */}
|
||||||
<Popover>
|
<Popover>
|
||||||
@@ -808,16 +969,17 @@ export default function DrawingCanvas({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-10 gap-1.5 px-2 text-gray-300 hover:bg-gray-800 hover:text-white"
|
className="h-10 min-touch gap-1.5 px-2 text-gray-300 hover:bg-gray-800 hover:text-white"
|
||||||
|
aria-label={t('drawing.color')}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="h-6 w-6 rounded-full border-2 border-gray-500"
|
className="h-6 w-6 rounded-full border-2 border-gray-500"
|
||||||
style={{ backgroundColor: strokeColor }}
|
style={{ backgroundColor: strokeColor }}
|
||||||
/>
|
/>
|
||||||
<Pipette className="h-4 w-4" />
|
<Pipette className="h-4 w-4 hidden sm:block" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-3" side="bottom">
|
<PopoverContent className="w-auto p-3 max-w-[calc(100vw-2rem)]" side="bottom">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-medium text-gray-500">{t('drawing.color')}</p>
|
<p className="text-xs font-medium text-gray-500">{t('drawing.color')}</p>
|
||||||
<div className="grid grid-cols-8 gap-1.5">
|
<div className="grid grid-cols-8 gap-1.5">
|
||||||
@@ -825,12 +987,13 @@ export default function DrawingCanvas({
|
|||||||
<button
|
<button
|
||||||
key={color}
|
key={color}
|
||||||
onClick={() => setStrokeColor(color)}
|
onClick={() => setStrokeColor(color)}
|
||||||
className={`h-8 w-8 rounded-full border-2 transition-all hover:scale-110 ${
|
className={`h-8 w-8 min-touch rounded-full border-2 transition-all hover:scale-110 ${
|
||||||
strokeColor === color
|
strokeColor === color
|
||||||
? 'border-emerald-500 ring-2 ring-emerald-500/30 scale-110'
|
? 'border-emerald-500 ring-2 ring-emerald-500/30 scale-110'
|
||||||
: 'border-gray-300'
|
: 'border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
|
aria-label={`Color ${color}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -842,12 +1005,13 @@ export default function DrawingCanvas({
|
|||||||
<button
|
<button
|
||||||
key={color}
|
key={color}
|
||||||
onClick={() => setStrokeColor(color)}
|
onClick={() => setStrokeColor(color)}
|
||||||
className={`h-8 w-8 rounded-full border-2 transition-all hover:scale-110 ${
|
className={`h-8 w-8 min-touch rounded-full border-2 transition-all hover:scale-110 ${
|
||||||
strokeColor === color
|
strokeColor === color
|
||||||
? 'border-emerald-500 ring-2 ring-emerald-500/30 scale-110'
|
? 'border-emerald-500 ring-2 ring-emerald-500/30 scale-110'
|
||||||
: 'border-gray-300'
|
: 'border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
|
aria-label={`Pen color ${color}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -857,7 +1021,8 @@ export default function DrawingCanvas({
|
|||||||
type="color"
|
type="color"
|
||||||
value={strokeColor}
|
value={strokeColor}
|
||||||
onChange={(e) => setStrokeColor(e.target.value)}
|
onChange={(e) => setStrokeColor(e.target.value)}
|
||||||
className="h-8 w-8 cursor-pointer rounded border"
|
className="h-8 w-8 min-touch cursor-pointer rounded border"
|
||||||
|
aria-label="Custom color picker"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -865,24 +1030,25 @@ export default function DrawingCanvas({
|
|||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="mx-1 h-8 w-px bg-gray-700" />
|
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
|
||||||
|
|
||||||
{/* Stroke width */}
|
{/* Stroke width */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
<Palette className="h-4 w-4 text-gray-400" />
|
<Palette className="h-4 w-4 text-gray-400 hidden sm:block" />
|
||||||
<Slider
|
<Slider
|
||||||
value={[strokeWidth]}
|
value={[strokeWidth]}
|
||||||
min={1}
|
min={1}
|
||||||
max={20}
|
max={20}
|
||||||
step={1}
|
step={1}
|
||||||
onValueChange={(v) => setStrokeWidth(v[0])}
|
onValueChange={(v) => setStrokeWidth(v[0])}
|
||||||
className="w-24"
|
className="w-16 sm:w-24"
|
||||||
|
aria-label={t('drawing.stroke_width')}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-[2rem] text-center text-xs text-gray-400">{strokeWidth}px</span>
|
<span className="min-w-[2rem] text-center text-xs text-gray-400">{strokeWidth}px</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="mx-1 h-8 w-px bg-gray-700" />
|
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
|
||||||
|
|
||||||
{/* Background type */}
|
{/* Background type */}
|
||||||
<Popover>
|
<Popover>
|
||||||
@@ -890,23 +1056,25 @@ export default function DrawingCanvas({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-10 gap-1 text-gray-300 hover:bg-gray-800 hover:text-white"
|
className="h-10 min-touch gap-1 text-gray-300 hover:bg-gray-800 hover:text-white"
|
||||||
|
aria-label={t('drawing.bg_type')}
|
||||||
>
|
>
|
||||||
{bgOptions.find((b) => b.id === bgType)?.icon}
|
{bgOptions.find((b) => b.id === bgType)?.icon}
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-2" side="bottom">
|
<PopoverContent className="w-auto p-2 max-w-[calc(100vw-2rem)]" side="bottom">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{bgOptions.map((bg) => (
|
{bgOptions.map((bg) => (
|
||||||
<button
|
<button
|
||||||
key={bg.id}
|
key={bg.id}
|
||||||
onClick={() => setBgType(bg.id)}
|
onClick={() => setBgType(bg.id)}
|
||||||
className={`flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors ${
|
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 min-h-[44px] text-sm transition-colors ${
|
||||||
bgType === bg.id
|
bgType === bg.id
|
||||||
? 'bg-emerald-100 text-emerald-700'
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
: 'hover:bg-gray-100'
|
: 'hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
aria-pressed={bgType === bg.id}
|
||||||
>
|
>
|
||||||
{bg.icon}
|
{bg.icon}
|
||||||
{bg.label}
|
{bg.label}
|
||||||
@@ -922,7 +1090,8 @@ export default function DrawingCanvas({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-10 gap-1 text-gray-300 hover:bg-gray-800 hover:text-white"
|
className="h-10 min-touch gap-1 text-gray-300 hover:bg-gray-800 hover:text-white"
|
||||||
|
aria-label={t('drawing.guide_mode')}
|
||||||
>
|
>
|
||||||
{guideMode === 'off' ? (
|
{guideMode === 'off' ? (
|
||||||
<EyeOff className="h-4 w-4" />
|
<EyeOff className="h-4 w-4" />
|
||||||
@@ -932,17 +1101,18 @@ export default function DrawingCanvas({
|
|||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-2" side="bottom">
|
<PopoverContent className="w-auto p-2 max-w-[calc(100vw-2rem)]" side="bottom">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{guideOptions.map((g) => (
|
{guideOptions.map((g) => (
|
||||||
<button
|
<button
|
||||||
key={g.id}
|
key={g.id}
|
||||||
onClick={() => setGuideMode(g.id)}
|
onClick={() => setGuideMode(g.id)}
|
||||||
className={`flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors ${
|
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 min-h-[44px] text-sm transition-colors ${
|
||||||
guideMode === g.id
|
guideMode === g.id
|
||||||
? 'bg-emerald-100 text-emerald-700'
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
: 'hover:bg-gray-100'
|
: 'hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
aria-pressed={guideMode === g.id}
|
||||||
>
|
>
|
||||||
{g.label}
|
{g.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -952,7 +1122,7 @@ export default function DrawingCanvas({
|
|||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="mx-1 h-8 w-px bg-gray-700" />
|
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
|
||||||
|
|
||||||
{/* Undo / Redo */}
|
{/* Undo / Redo */}
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -963,7 +1133,8 @@ export default function DrawingCanvas({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleUndo}
|
onClick={handleUndo}
|
||||||
disabled={strokes.length === 0}
|
disabled={strokes.length === 0}
|
||||||
className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30 transition-transform active:scale-90"
|
className="h-10 w-10 min-touch text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30 transition-transform active:scale-90"
|
||||||
|
aria-label={t('drawing.undo')}
|
||||||
>
|
>
|
||||||
<Undo2 className="h-5 w-5" />
|
<Undo2 className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -977,7 +1148,8 @@ export default function DrawingCanvas({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleRedo}
|
onClick={handleRedo}
|
||||||
disabled={redoStack.length === 0}
|
disabled={redoStack.length === 0}
|
||||||
className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30 transition-transform active:scale-90"
|
className="h-10 w-10 min-touch text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30 transition-transform active:scale-90"
|
||||||
|
aria-label={t('drawing.redo')}
|
||||||
>
|
>
|
||||||
<Redo2 className="h-5 w-5" />
|
<Redo2 className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -987,7 +1159,7 @@ export default function DrawingCanvas({
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="mx-1 h-8 w-px bg-gray-700" />
|
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
|
||||||
|
|
||||||
{/* Zoom controls */}
|
{/* Zoom controls */}
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -998,7 +1170,8 @@ export default function DrawingCanvas({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setZoomLevel(Math.max(25, zoomLevel - 25))}
|
onClick={() => setZoomLevel(Math.max(25, zoomLevel - 25))}
|
||||||
disabled={zoomLevel <= 25}
|
disabled={zoomLevel <= 25}
|
||||||
className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30"
|
className="h-10 w-10 min-touch text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30"
|
||||||
|
aria-label={t('drawing.zoom_out')}
|
||||||
>
|
>
|
||||||
<ZoomOut className="h-5 w-5" />
|
<ZoomOut className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1013,7 +1186,8 @@ export default function DrawingCanvas({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setZoomLevel(Math.min(200, zoomLevel + 25))}
|
onClick={() => setZoomLevel(Math.min(200, zoomLevel + 25))}
|
||||||
disabled={zoomLevel >= 200}
|
disabled={zoomLevel >= 200}
|
||||||
className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30"
|
className="h-10 w-10 min-touch text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30"
|
||||||
|
aria-label={t('drawing.zoom_in')}
|
||||||
>
|
>
|
||||||
<ZoomIn className="h-5 w-5" />
|
<ZoomIn className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1022,6 +1196,24 @@ export default function DrawingCanvas({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
|
{/* FPS Toggle */}
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowFps(!showFps)}
|
||||||
|
className={`h-10 w-10 min-touch text-gray-300 hover:bg-gray-800 hover:text-white ${showFps ? 'text-emerald-400' : ''}`}
|
||||||
|
aria-label={t('performance.title')}
|
||||||
|
>
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t('performance.fps')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
@@ -1039,7 +1231,8 @@ export default function DrawingCanvas({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowClearDialog(true)}
|
onClick={() => setShowClearDialog(true)}
|
||||||
className="h-10 w-10 text-gray-300 hover:bg-red-900/50 hover:text-red-300"
|
className="h-10 w-10 min-touch text-gray-300 hover:bg-red-900/50 hover:text-red-300"
|
||||||
|
aria-label={t('drawing.clear')}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-5 w-5" />
|
<Trash2 className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1052,7 +1245,8 @@ export default function DrawingCanvas({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleExportPNG}
|
onClick={handleExportPNG}
|
||||||
className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white"
|
className="h-10 w-10 min-touch text-gray-300 hover:bg-gray-800 hover:text-white"
|
||||||
|
aria-label={t('drawing.export_png')}
|
||||||
>
|
>
|
||||||
<Download className="h-5 w-5" />
|
<Download className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1065,7 +1259,8 @@ export default function DrawingCanvas({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowSaveDialog(true)}
|
onClick={() => setShowSaveDialog(true)}
|
||||||
className="h-10 gap-1 bg-emerald-600 text-white hover:bg-emerald-700"
|
className="h-10 min-touch gap-1 bg-emerald-600 text-white hover:bg-emerald-700"
|
||||||
|
aria-label={t('drawing.save')}
|
||||||
>
|
>
|
||||||
<Save className="h-5 w-5" />
|
<Save className="h-5 w-5" />
|
||||||
<span className="hidden sm:inline">{t('drawing.save')}</span>
|
<span className="hidden sm:inline">{t('drawing.save')}</span>
|
||||||
@@ -1085,13 +1280,20 @@ export default function DrawingCanvas({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* FPS Counter */}
|
||||||
|
{showFps && (
|
||||||
|
<div className="fps-counter">
|
||||||
|
{fpsDisplay} FPS
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Canvas Area ────────────────────────────────────────────── */}
|
{/* ── Canvas Area ────────────────────────────────────────────── */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="flex-1 overflow-auto bg-gray-100 p-4"
|
className="flex-1 overflow-auto bg-gray-100 p-2 sm:p-4"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative mx-auto overflow-hidden rounded-lg shadow-lg canvas-toolbar"
|
className="relative mx-auto overflow-hidden rounded-lg shadow-lg canvas-toolbar canvas-gpu"
|
||||||
style={{
|
style={{
|
||||||
width: `${zoomLevel}%`,
|
width: `${zoomLevel}%`,
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
@@ -1105,7 +1307,7 @@ export default function DrawingCanvas({
|
|||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={canvasSize.width}
|
width={canvasSize.width}
|
||||||
height={canvasSize.height}
|
height={canvasSize.height}
|
||||||
className="h-full w-full cursor-crosshair touch-none"
|
className="h-full w-full cursor-crosshair touch-none canvas-no-zoom canvas-no-select"
|
||||||
style={{
|
style={{
|
||||||
imageRendering: 'auto',
|
imageRendering: 'auto',
|
||||||
}}
|
}}
|
||||||
@@ -1113,6 +1315,8 @@ export default function DrawingCanvas({
|
|||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerLeave={handlePointerUp}
|
onPointerLeave={handlePointerUp}
|
||||||
|
role="img"
|
||||||
|
aria-label={t('drawing.canvas_label') || 'Drawing canvas'}
|
||||||
/>
|
/>
|
||||||
{/* Tool indicator */}
|
{/* Tool indicator */}
|
||||||
<div className="pointer-events-none absolute bottom-3 left-3 flex items-center gap-2">
|
<div className="pointer-events-none absolute bottom-3 left-3 flex items-center gap-2">
|
||||||
@@ -1134,7 +1338,25 @@ export default function DrawingCanvas({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Clear Dialog ───────────────────────────────────────────── */}
|
{/* ── Clear Dialog / Drawer ───────────────────────────────────── */}
|
||||||
|
{isMobile ? (
|
||||||
|
<Drawer open={showClearDialog} onOpenChange={setShowClearDialog}>
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>{t('drawing.clear_confirm')}</DrawerTitle>
|
||||||
|
<DrawerDescription>{t('drawing.delete_desc')}</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<DrawerFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowClearDialog(false)}>
|
||||||
|
{t('action.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleClear}>
|
||||||
|
{t('drawing.clear')}
|
||||||
|
</Button>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
) : (
|
||||||
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
|
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -1153,8 +1375,49 @@ export default function DrawingCanvas({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Save Dialog ────────────────────────────────────────────── */}
|
{/* ── Save Dialog / Drawer ────────────────────────────────────── */}
|
||||||
|
{isMobile ? (
|
||||||
|
<Drawer open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>{t('drawing.save')}</DrawerTitle>
|
||||||
|
<DrawerDescription>{t('drawing.eco_tip')}</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="px-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="drawing-title-mobile">{t('drawing.title_label')}</Label>
|
||||||
|
<Input
|
||||||
|
id="drawing-title-mobile"
|
||||||
|
value={drawingTitle}
|
||||||
|
onChange={(e) => setDrawingTitle(e.target.value)}
|
||||||
|
placeholder={t('drawing.untitled')}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="drawing-desc-mobile">{t('drawing.description_label')}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="drawing-desc-mobile"
|
||||||
|
value={drawingDescription}
|
||||||
|
onChange={(e) => setDrawingDescription(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DrawerFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
|
||||||
|
{t('action.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={isSaving} className="bg-emerald-600 hover:bg-emerald-700">
|
||||||
|
{isSaving ? t('drawing.saving') : t('drawing.save')}
|
||||||
|
</Button>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
) : (
|
||||||
<Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
<Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -1195,6 +1458,7 @@ export default function DrawingCanvas({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Copy, FlaskConical, Languages, Calculator, Paintbrush, Megaphone,
|
Copy, FlaskConical, Languages, Calculator, Paintbrush, Megaphone,
|
||||||
Strikethrough, Type, Highlighter, GripVertical,
|
Strikethrough, Type, Highlighter, GripVertical,
|
||||||
Clock, History, ZoomIn, ZoomOut, Image as ImageIcon,
|
Clock, History, ZoomIn, ZoomOut, Image as ImageIcon,
|
||||||
|
Users as UsersIcon, Radio, MousePointer2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -35,6 +36,7 @@ import { t } from '@/lib/i18n';
|
|||||||
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
|
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import DrawingCanvas from '@/components/drawing-canvas';
|
import DrawingCanvas from '@/components/drawing-canvas';
|
||||||
|
import { useNotebookCollaboration, type CursorData, type EditData } from '@/lib/websocket';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1028,6 +1030,9 @@ function NotebookDetailView({
|
|||||||
const [draggedPageId, setDraggedPageId] = useState<string | null>(null);
|
const [draggedPageId, setDraggedPageId] = useState<string | null>(null);
|
||||||
const [dragOverPageId, setDragOverPageId] = useState<string | null>(null);
|
const [dragOverPageId, setDragOverPageId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Collaboration state
|
||||||
|
const [collabActivityOpen, setCollabActivityOpen] = useState(false);
|
||||||
|
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastSavedContentRef = useRef<string>('');
|
const lastSavedContentRef = useRef<string>('');
|
||||||
@@ -1037,6 +1042,69 @@ function NotebookDetailView({
|
|||||||
const pages = notebook.pages ?? [];
|
const pages = notebook.pages ?? [];
|
||||||
const currentPage = pages.find(p => p.id === currentPageId) ?? pages[0] ?? null;
|
const currentPage = pages.find(p => p.id === currentPageId) ?? pages[0] ?? null;
|
||||||
|
|
||||||
|
// ─── Collaboration Hook ─────────────────────────────────────────────
|
||||||
|
const {
|
||||||
|
onlineUsers,
|
||||||
|
cursors,
|
||||||
|
activities,
|
||||||
|
lastEdit,
|
||||||
|
editingUsers,
|
||||||
|
broadcastCursor,
|
||||||
|
broadcastEdit,
|
||||||
|
broadcastActivity,
|
||||||
|
addEditListener,
|
||||||
|
} = useNotebookCollaboration(notebook.id);
|
||||||
|
|
||||||
|
// Handle incoming edits from other users (last-write-wins with visual indicator)
|
||||||
|
const collabEditTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [collabEditIndicator, setCollabEditIndicator] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = addEditListener((edit: EditData) => {
|
||||||
|
// If editing the same page, apply the incoming edit
|
||||||
|
if (currentPage && edit.pageId === currentPage.id && editorRef.current) {
|
||||||
|
isInternalChange.current = true;
|
||||||
|
editorRef.current.innerHTML = edit.content;
|
||||||
|
setPageContent(edit.content);
|
||||||
|
lastSavedContentRef.current = edit.content;
|
||||||
|
setTimeout(() => { isInternalChange.current = false; }, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show editing indicator
|
||||||
|
setCollabEditIndicator(t('collab.editing_by', { name: edit.userName }));
|
||||||
|
if (collabEditTimeoutRef.current) clearTimeout(collabEditTimeoutRef.current);
|
||||||
|
collabEditTimeoutRef.current = setTimeout(() => setCollabEditIndicator(null), 3000);
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}, [addEditListener, currentPage]);
|
||||||
|
|
||||||
|
// Broadcast cursor position on mouse move
|
||||||
|
const cursorThrottleRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const handleEditorMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!currentPage) return;
|
||||||
|
if (cursorThrottleRef.current) return;
|
||||||
|
cursorThrottleRef.current = setTimeout(() => {
|
||||||
|
cursorThrottleRef.current = null;
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = Math.round(((e.clientX - rect.left) / rect.width) * 100);
|
||||||
|
const y = Math.round(((e.clientY - rect.top) / rect.height) * 100);
|
||||||
|
broadcastCursor(currentPage.id, x, y);
|
||||||
|
}, [currentPage, broadcastCursor]);
|
||||||
|
|
||||||
|
// Broadcast edit on content change (debounced)
|
||||||
|
const broadcastEditTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Broadcast page change activity
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPage) {
|
||||||
|
broadcastActivity(currentPage.id, currentPage.title ?? `${t('notebooks.page')} ${currentPage.pageNumber}`);
|
||||||
|
}
|
||||||
|
}, [currentPageId, currentPage, broadcastActivity]);
|
||||||
|
|
||||||
|
// ─── End Collaboration ──────────────────────────────────────────────
|
||||||
|
|
||||||
// Set content into editor ref without triggering auto-save
|
// Set content into editor ref without triggering auto-save
|
||||||
const setEditorContent = useCallback((html: string) => {
|
const setEditorContent = useCallback((html: string) => {
|
||||||
isInternalChange.current = true;
|
isInternalChange.current = true;
|
||||||
@@ -1079,16 +1147,30 @@ function NotebookDetailView({
|
|||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
const html = editorRef.current.innerHTML;
|
const html = editorRef.current.innerHTML;
|
||||||
setPageContent(html);
|
setPageContent(html);
|
||||||
|
// Broadcast edit to collaborators (debounced)
|
||||||
|
if (broadcastEditTimerRef.current) clearTimeout(broadcastEditTimerRef.current);
|
||||||
|
broadcastEditTimerRef.current = setTimeout(() => {
|
||||||
|
if (currentPage) {
|
||||||
|
broadcastEdit(currentPage.id, html);
|
||||||
}
|
}
|
||||||
}, []);
|
}, 500);
|
||||||
|
}
|
||||||
|
}, [currentPage, broadcastEdit]);
|
||||||
|
|
||||||
// Handle format change from toolbar (for active state refresh)
|
// Handle format change from toolbar (for active state refresh)
|
||||||
const handleFormatChange = useCallback(() => {
|
const handleFormatChange = useCallback(() => {
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
const html = editorRef.current.innerHTML;
|
const html = editorRef.current.innerHTML;
|
||||||
setPageContent(html);
|
setPageContent(html);
|
||||||
|
// Broadcast edit to collaborators (debounced)
|
||||||
|
if (broadcastEditTimerRef.current) clearTimeout(broadcastEditTimerRef.current);
|
||||||
|
broadcastEditTimerRef.current = setTimeout(() => {
|
||||||
|
if (currentPage) {
|
||||||
|
broadcastEdit(currentPage.id, html);
|
||||||
}
|
}
|
||||||
}, []);
|
}, 500);
|
||||||
|
}
|
||||||
|
}, [currentPage, broadcastEdit]);
|
||||||
|
|
||||||
// Auto-save with 3 second debounce
|
// Auto-save with 3 second debounce
|
||||||
const triggerAutoSave = useCallback(() => {
|
const triggerAutoSave = useCallback(() => {
|
||||||
@@ -1375,6 +1457,81 @@ function NotebookDetailView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Collaboration: Online Users Indicator */}
|
||||||
|
{onlineUsers.length > 0 && (
|
||||||
|
<TooltipProvider delayDuration={300}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200/50 dark:border-emerald-800/30 shrink-0">
|
||||||
|
<Radio className="w-3.5 h-3.5 text-emerald-500 animate-pulse" />
|
||||||
|
<div className="flex -space-x-1.5">
|
||||||
|
{onlineUsers.slice(0, 4).map((u) => (
|
||||||
|
<div
|
||||||
|
key={u.userId}
|
||||||
|
className="w-6 h-6 rounded-full border-2 border-white dark:border-gray-900 flex items-center justify-center text-[8px] font-bold text-white"
|
||||||
|
style={{ backgroundColor: u.color }}
|
||||||
|
title={u.userName}
|
||||||
|
>
|
||||||
|
{u.userName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||||
|
{onlineUsers.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="text-xs">
|
||||||
|
{t('collab.online_users', { count: onlineUsers.length })}: {onlineUsers.map(u => u.userName).join(', ')}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collaboration: Activity Feed Toggle */}
|
||||||
|
{onlineUsers.length > 0 && (
|
||||||
|
<Popover open={collabActivityOpen} onOpenChange={setCollabActivityOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="min-h-[44px] shrink-0 relative">
|
||||||
|
<UsersIcon className="w-4 h-4" />
|
||||||
|
{activities.length > 0 && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-amber-400 text-white text-[8px] font-bold flex items-center justify-center">
|
||||||
|
{activities.length > 9 ? '9+' : activities.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-72 p-0 rounded-xl border-emerald-200/60 dark:border-emerald-900/40">
|
||||||
|
<div className="px-3 py-2 border-b border-emerald-100/50 dark:border-emerald-900/30 bg-gradient-to-r from-emerald-50/50 to-teal-50/30 dark:from-emerald-950/20 dark:to-teal-950/10">
|
||||||
|
<h3 className="text-sm font-semibold text-emerald-700 dark:text-emerald-300 flex items-center gap-1.5">
|
||||||
|
<UsersIcon className="w-3.5 h-3.5" />
|
||||||
|
{t('collab.activity_title')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="max-h-60">
|
||||||
|
{activities.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t('collab.no_activity')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
{activities.map((a, i) => (
|
||||||
|
<div key={`${a.userId}-${a.timestamp}-${i}`} className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center text-[8px] font-bold text-emerald-700 dark:text-emerald-300 shrink-0">
|
||||||
|
{a.userName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
<span className="truncate">
|
||||||
|
<strong>{a.userName}</strong> {t('collab.started_editing')} {a.pageTitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Drawing mode toggle */}
|
{/* Drawing mode toggle */}
|
||||||
<Button
|
<Button
|
||||||
variant={drawingMode ? 'default' : 'outline'}
|
variant={drawingMode ? 'default' : 'outline'}
|
||||||
@@ -1519,6 +1676,19 @@ function NotebookDetailView({
|
|||||||
className="font-semibold text-lg border-0 bg-transparent shadow-none focus-visible:ring-0 h-10"
|
className="font-semibold text-lg border-0 bg-transparent shadow-none focus-visible:ring-0 h-10"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Collaboration: Editing indicator */}
|
||||||
|
{collabEditIndicator && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -10 }}
|
||||||
|
className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200/50 dark:border-amber-800/30 shrink-0"
|
||||||
|
>
|
||||||
|
<MousePointer2 className="w-3.5 h-3.5 text-amber-500" />
|
||||||
|
<span className="text-xs text-amber-700 dark:text-amber-300 font-medium">{collabEditIndicator}</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Version history button in page title bar */}
|
{/* Version history button in page title bar */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1572,7 +1742,7 @@ function NotebookDetailView({
|
|||||||
<WysiwygToolbar editorRef={editorRef} onFormatChange={handleFormatChange} />
|
<WysiwygToolbar editorRef={editorRef} onFormatChange={handleFormatChange} />
|
||||||
|
|
||||||
{/* Page content area */}
|
{/* Page content area */}
|
||||||
<div className="flex-1 overflow-hidden p-4">
|
<div className="flex-1 overflow-hidden p-4 relative">
|
||||||
<div
|
<div
|
||||||
className="w-full h-full max-w-3xl mx-auto rounded-xl overflow-hidden"
|
className="w-full h-full max-w-3xl mx-auto rounded-xl overflow-hidden"
|
||||||
style={{ backgroundColor: '#fff' }}
|
style={{ backgroundColor: '#fff' }}
|
||||||
@@ -1581,6 +1751,7 @@ function NotebookDetailView({
|
|||||||
<div
|
<div
|
||||||
className="min-h-full p-6"
|
className="min-h-full p-6"
|
||||||
style={getPageBackgroundCSS(currentPage.background ?? notebook.notebookType)}
|
style={getPageBackgroundCSS(currentPage.background ?? notebook.notebookType)}
|
||||||
|
onMouseMove={handleEditorMouseMove}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
@@ -1594,6 +1765,32 @@ function NotebookDetailView({
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Collaboration: Cursor overlays */}
|
||||||
|
{cursors.filter(c => c.pageId === currentPage?.id).map((cursor) => (
|
||||||
|
<motion.div
|
||||||
|
key={cursor.userId}
|
||||||
|
initial={{ opacity: 0, scale: 0.5 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="absolute pointer-events-none z-20"
|
||||||
|
style={{
|
||||||
|
left: `${cursor.x}%`,
|
||||||
|
top: `${cursor.y}%`,
|
||||||
|
transform: 'translate(-4px, -4px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MousePointer2
|
||||||
|
className="w-4 h-4"
|
||||||
|
style={{ color: cursor.color, fill: cursor.color }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white whitespace-nowrap mt-0.5"
|
||||||
|
style={{ backgroundColor: cursor.color }}
|
||||||
|
>
|
||||||
|
{cursor.userName}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
X,
|
X,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
|
BarChart3,
|
||||||
|
GraduationCap,
|
||||||
|
FileDown,
|
||||||
|
LayoutTemplate,
|
||||||
|
CheckCircle2,
|
||||||
|
Calculator,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -28,6 +34,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -63,12 +70,18 @@ import {
|
|||||||
deleteRubric,
|
deleteRubric,
|
||||||
duplicateRubric,
|
duplicateRubric,
|
||||||
fetchSubjects,
|
fetchSubjects,
|
||||||
|
fetchRubricTemplates,
|
||||||
|
fetchRubricAnalytics,
|
||||||
|
gradeWithRubric,
|
||||||
type Rubric,
|
type Rubric,
|
||||||
type RubricCriterion,
|
type RubricCriterion,
|
||||||
type RubricLevel,
|
type RubricLevel,
|
||||||
type RubricCriterionInput,
|
type RubricCriterionInput,
|
||||||
type RubricLevelInput,
|
type RubricLevelInput,
|
||||||
type Subject,
|
type Subject,
|
||||||
|
type RubricTemplate,
|
||||||
|
type RubricAnalytics,
|
||||||
|
type RubricGradeResult,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -169,6 +182,23 @@ export default function RubricLibraryView() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [formTab, setFormTab] = useState('edit');
|
const [formTab, setFormTab] = useState('edit');
|
||||||
|
|
||||||
|
// Template dialog
|
||||||
|
const [templateOpen, setTemplateOpen] = useState(false);
|
||||||
|
const [templates, setTemplates] = useState<RubricTemplate[]>([]);
|
||||||
|
const [templatesLoading, setTemplatesLoading] = useState(false);
|
||||||
|
|
||||||
|
// Analytics dialog
|
||||||
|
const [analyticsOpen, setAnalyticsOpen] = useState(false);
|
||||||
|
const [analyticsData, setAnalyticsData] = useState<RubricAnalytics | null>(null);
|
||||||
|
const [analyticsLoading, setAnalyticsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Grading dialog
|
||||||
|
const [gradeOpen, setGradeOpen] = useState(false);
|
||||||
|
const [gradingRubric, setGradingRubric] = useState<Rubric | null>(null);
|
||||||
|
const [gradeScores, setGradeScores] = useState<Record<string, { levelId: string; points: number }>>({});
|
||||||
|
const [gradeResult, setGradeResult] = useState<RubricGradeResult | null>(null);
|
||||||
|
const [gradingCalc, setGradingCalc] = useState(false);
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formTitle, setFormTitle] = useState('');
|
const [formTitle, setFormTitle] = useState('');
|
||||||
const [formDescription, setFormDescription] = useState('');
|
const [formDescription, setFormDescription] = useState('');
|
||||||
@@ -255,6 +285,49 @@ export default function RubricLibraryView() {
|
|||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openTemplateDialog = async () => {
|
||||||
|
setTemplateOpen(true);
|
||||||
|
setTemplatesLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchRubricTemplates();
|
||||||
|
setTemplates(data);
|
||||||
|
} catch {
|
||||||
|
toast.error(t('rubrics.error'));
|
||||||
|
} finally {
|
||||||
|
setTemplatesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTemplate = (template: RubricTemplate) => {
|
||||||
|
setEditingRubric(null);
|
||||||
|
setFormTitle(template.title);
|
||||||
|
setFormDescription(template.description);
|
||||||
|
setFormType(template.type);
|
||||||
|
setFormMaxPoints(template.maxPoints);
|
||||||
|
setFormIsPublic(false);
|
||||||
|
// Find matching subject
|
||||||
|
const matchingSubject = subjects.find((s) => s.name === template.subject);
|
||||||
|
setFormSubjectId(matchingSubject?.id ?? '');
|
||||||
|
setFormCriteria(
|
||||||
|
template.criteria.map((c, ci) => ({
|
||||||
|
name: c.name,
|
||||||
|
description: c.description,
|
||||||
|
weight: c.weight,
|
||||||
|
maxPoints: c.maxPoints,
|
||||||
|
order: ci,
|
||||||
|
levels: c.levels.map((l, li) => ({
|
||||||
|
label: l.label,
|
||||||
|
description: l.description,
|
||||||
|
points: l.points,
|
||||||
|
order: li,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
setFormTab('edit');
|
||||||
|
setTemplateOpen(false);
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const addCriterion = () => {
|
const addCriterion = () => {
|
||||||
setFormCriteria((prev) => [...prev, emptyCriterion(prev.length)]);
|
setFormCriteria((prev) => [...prev, emptyCriterion(prev.length)]);
|
||||||
};
|
};
|
||||||
@@ -401,6 +474,63 @@ export default function RubricLibraryView() {
|
|||||||
window.print();
|
window.print();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── Analytics ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const openAnalytics = async (r: Rubric) => {
|
||||||
|
setAnalyticsOpen(true);
|
||||||
|
setAnalyticsLoading(true);
|
||||||
|
setAnalyticsData(null);
|
||||||
|
try {
|
||||||
|
const data = await fetchRubricAnalytics(r.id);
|
||||||
|
setAnalyticsData(data);
|
||||||
|
} catch {
|
||||||
|
toast.error(t('rubrics.error'));
|
||||||
|
} finally {
|
||||||
|
setAnalyticsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Grading ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const openGrading = (r: Rubric) => {
|
||||||
|
setGradingRubric(r);
|
||||||
|
setGradeScores({});
|
||||||
|
setGradeResult(null);
|
||||||
|
setGradeOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectLevel = (criterionId: string, levelId: string, points: number) => {
|
||||||
|
setGradeScores((prev) => ({ ...prev, [criterionId]: { levelId, points } }));
|
||||||
|
setGradeResult(null); // Reset result when scores change
|
||||||
|
};
|
||||||
|
|
||||||
|
const autoCalculateGrade = () => {
|
||||||
|
if (!gradingRubric) return;
|
||||||
|
const scores = Object.entries(gradeScores).map(([criterionId, data]) => ({
|
||||||
|
criterionId,
|
||||||
|
levelId: data.levelId,
|
||||||
|
points: data.points,
|
||||||
|
}));
|
||||||
|
if (scores.length < gradingRubric.criteria.length) {
|
||||||
|
toast.error(t('rubrics.select_level'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGradingCalc(true);
|
||||||
|
gradeWithRubric(gradingRubric.id, {
|
||||||
|
studentId: 'preview',
|
||||||
|
scores,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
setGradeResult(result);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(t('rubrics.error'));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setGradingCalc(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/* ── Render ───────────────────────────────────────────────────── */
|
/* ── Render ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -430,6 +560,15 @@ export default function RubricLibraryView() {
|
|||||||
<p className="text-sm text-muted-foreground">{t('rubrics.subtitle')}</p>
|
<p className="text-sm text-muted-foreground">{t('rubrics.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={openTemplateDialog}
|
||||||
|
className="border-emerald-300 dark:border-emerald-700"
|
||||||
|
>
|
||||||
|
<LayoutTemplate className="h-4 w-4 mr-2" />
|
||||||
|
{t('rubrics.templates')}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={openCreateForm}
|
onClick={openCreateForm}
|
||||||
className="bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 text-white shadow-md"
|
className="bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 text-white shadow-md"
|
||||||
@@ -437,6 +576,7 @@ export default function RubricLibraryView() {
|
|||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
{t('rubrics.create')}
|
{t('rubrics.create')}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@@ -491,6 +631,14 @@ export default function RubricLibraryView() {
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2">{t('rubrics.empty_title')}</h3>
|
<h3 className="text-lg font-semibold mb-2">{t('rubrics.empty_title')}</h3>
|
||||||
<p className="text-muted-foreground max-w-md mb-6">{t('rubrics.empty_desc')}</p>
|
<p className="text-muted-foreground max-w-md mb-6">{t('rubrics.empty_desc')}</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={openTemplateDialog}
|
||||||
|
>
|
||||||
|
<LayoutTemplate className="h-4 w-4 mr-2" />
|
||||||
|
{t('rubrics.templates')}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={openCreateForm}
|
onClick={openCreateForm}
|
||||||
className="bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 text-white"
|
className="bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 text-white"
|
||||||
@@ -498,6 +646,7 @@ export default function RubricLibraryView() {
|
|||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
{t('rubrics.empty_create')}
|
{t('rubrics.empty_create')}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@@ -578,6 +727,30 @@ export default function RubricLibraryView() {
|
|||||||
: ''}
|
: ''}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openGrading(rubric);
|
||||||
|
}}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
title={t('rubrics.grade')}
|
||||||
|
>
|
||||||
|
<GraduationCap className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openAnalytics(rubric);
|
||||||
|
}}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
title={t('rubrics.analytics')}
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -622,6 +795,84 @@ export default function RubricLibraryView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Template Dialog ─────────────────────────────────────────── */}
|
||||||
|
<Dialog open={templateOpen} onOpenChange={setTemplateOpen}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<LayoutTemplate className="h-5 w-5 text-emerald-500" />
|
||||||
|
{t('rubrics.templates_title')}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{t('rubrics.templates_desc')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{templatesLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<Card
|
||||||
|
key={template.id}
|
||||||
|
className="hover:shadow-md transition-shadow cursor-pointer border-l-4 border-l-teal-500"
|
||||||
|
onClick={() => applyTemplate(template)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-sm font-semibold">{template.title}</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{template.description}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-xs shrink-0">
|
||||||
|
{template.subject}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{template.criteria.length} {t('rubrics.criteria')}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{template.maxPoints} {t('rubrics.points')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{template.criteria.map((c) => (
|
||||||
|
<span
|
||||||
|
key={c.name}
|
||||||
|
className="text-xs bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 px-2 py-0.5 rounded"
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="mt-3 bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 text-white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
applyTemplate(template);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('rubrics.use_template')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={openCreateForm}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
{t('rubrics.no_template')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* ── Create/Edit Dialog ─────────────────────────────────────── */}
|
{/* ── Create/Edit Dialog ─────────────────────────────────────── */}
|
||||||
<Dialog open={formOpen} onOpenChange={(open) => {
|
<Dialog open={formOpen} onOpenChange={(open) => {
|
||||||
if (!open && saving) return;
|
if (!open && saving) return;
|
||||||
@@ -900,11 +1151,21 @@ export default function RubricLibraryView() {
|
|||||||
{detailRubric.description}
|
{detailRubric.description}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openGrading(detailRubric)}>
|
||||||
|
<GraduationCap className="h-4 w-4 mr-1" />
|
||||||
|
{t('rubrics.grade')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openAnalytics(detailRubric)}>
|
||||||
|
<BarChart3 className="h-4 w-4 mr-1" />
|
||||||
|
{t('rubrics.analytics')}
|
||||||
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||||
<Printer className="h-4 w-4 mr-1" />
|
<Printer className="h-4 w-4 mr-1" />
|
||||||
{t('rubrics.print')}
|
{t('rubrics.print')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
@@ -960,6 +1221,255 @@ export default function RubricLibraryView() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── Grading Dialog ─────────────────────────────────────────── */}
|
||||||
|
<Dialog open={gradeOpen} onOpenChange={setGradeOpen}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<GraduationCap className="h-5 w-5 text-emerald-500" />
|
||||||
|
{t('rubrics.grade_title')}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{gradingRubric?.title}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{gradingRubric && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Score Sheet */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{gradingRubric.criteria.map((criterion) => (
|
||||||
|
<Card key={criterion.id} className="border-l-4 border-l-teal-500">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-sm font-semibold">{criterion.name}</CardTitle>
|
||||||
|
{criterion.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{criterion.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{criterion.maxPoints} {t('rubrics.points')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{criterion.levels.map((level) => {
|
||||||
|
const isSelected = gradeScores[criterion.id]?.levelId === level.id;
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={level.id}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={() => selectLevel(criterion.id, level.id, level.points)}
|
||||||
|
className={`text-left p-3 rounded-lg border-2 transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20 shadow-md'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:border-emerald-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="font-medium text-sm">{level.label}</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-xs ${isSelected ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300' : ''}`}
|
||||||
|
>
|
||||||
|
{level.points} {t('rubrics.points')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{level.description}</p>
|
||||||
|
{isSelected && (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-500 mt-1" />
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-Grade Button */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={autoCalculateGrade}
|
||||||
|
disabled={gradingCalc || Object.keys(gradeScores).length < (gradingRubric.criteria.length)}
|
||||||
|
className="bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 text-white"
|
||||||
|
>
|
||||||
|
{gradingCalc ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Calculator className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{t('rubrics.auto_grade')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{Object.keys(gradeScores).length}/{gradingRubric.criteria.length} {t('rubrics.criteria')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grade Result */}
|
||||||
|
{gradeResult && (
|
||||||
|
<Card className="border-2 border-emerald-300 dark:border-emerald-700 bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-950/20 dark:to-teal-950/20">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||||
|
{t('rubrics.auto_grade')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-emerald-600">{gradeResult.overallPercentage}%</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t('rubrics.percentage')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-teal-600">{gradeResult.finalScore}/{gradeResult.maxPoints}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t('rubrics.final_score')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-amber-600">{gradeResult.grade} — {gradeResult.gradeLabel}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t('rubrics.grade_result')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-3" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-xs font-semibold">{t('rubrics.criterion_breakdown')}</h4>
|
||||||
|
{gradeResult.criterionBreakdown.map((cb) => (
|
||||||
|
<div key={cb.criterionId} className="flex items-center gap-3">
|
||||||
|
<span className="text-xs font-medium w-36 truncate">{cb.criterionName}</span>
|
||||||
|
<Progress value={cb.percentage} className="flex-1 h-2" />
|
||||||
|
<span className="text-xs text-muted-foreground w-16 text-right">
|
||||||
|
{cb.earnedPoints}/{cb.maxPoints}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── Analytics Dialog ────────────────────────────────────────── */}
|
||||||
|
<Dialog open={analyticsOpen} onOpenChange={setAnalyticsOpen}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5 text-emerald-500" />
|
||||||
|
{t('rubrics.analytics_title')}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{analyticsData?.rubricTitle}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{analyticsLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
) : analyticsData ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Overall Stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<Card className="border-2 border-emerald-200 dark:border-emerald-800">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-emerald-600">{analyticsData.overallStats.averagePercentage}%</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t('rubrics.average_score')}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-2 border-teal-200 dark:border-teal-800">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-teal-600">{analyticsData.overallStats.totalAssessments}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t('rubrics.class_performance')}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-2 border-amber-200 dark:border-amber-800">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-amber-600">{analyticsData.overallStats.totalStudents}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t('rubrics.percentage')}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-2 border-rose-200 dark:border-rose-800">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-rose-600">{analyticsData.overallStats.averageScore}/{analyticsData.overallStats.totalMaxPoints}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t('rubrics.final_score')}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Class Performance by Criterion */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">{t('rubrics.class_performance')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{analyticsData.criteriaAnalytics.map((ca) => (
|
||||||
|
<div key={ca.criterionId} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{ca.criterionName}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('rubrics.average_score')}: {ca.classAverage}/{ca.maxPoints}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress
|
||||||
|
value={ca.maxPoints > 0 ? (ca.classAverage / ca.maxPoints) * 100 : 0}
|
||||||
|
className="flex-1 h-2"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{ca.maxPoints > 0 ? Math.round((ca.classAverage / ca.maxPoints) * 100) : 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||||
|
<span>{t('rubrics.highest_score')}: {ca.highestScore}</span>
|
||||||
|
<span>{t('rubrics.lowest_score')}: {ca.lowestScore}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Grade Distribution */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">{t('rubrics.grade_distribution')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{analyticsData.gradeDistribution.map((gd) => (
|
||||||
|
<div key={gd.grade} className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-medium w-28">{gd.grade}</span>
|
||||||
|
<div className="flex-1 bg-gray-100 dark:bg-gray-800 rounded-full h-4 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${analyticsData.overallStats.averagePercentage}%`,
|
||||||
|
backgroundColor: gd.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground w-16">{gd.range}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
{t('rubrics.error')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* ── Delete Confirmation ────────────────────────────────────── */}
|
{/* ── Delete Confirmation ────────────────────────────────────── */}
|
||||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
|
HardDrive,
|
||||||
|
Archive,
|
||||||
|
RotateCcw,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
Bell,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -76,10 +82,12 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { useAppStore } from '@/lib/store';
|
import { useAppStore } from '@/lib/store';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
|
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { getNotificationSoundPref, setNotificationSoundPref, playNotificationSound } from '@/lib/websocket';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchSchools,
|
fetchSchools,
|
||||||
@@ -132,6 +140,41 @@ const actionColors: Record<string, string> = {
|
|||||||
EXPORT: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300',
|
EXPORT: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Notification Sound Setting Component ─────────────────────────────
|
||||||
|
|
||||||
|
function NotificationSoundSetting() {
|
||||||
|
const [enabled, setEnabled] = useState(getNotificationSoundPref());
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
const newValue = !enabled;
|
||||||
|
setEnabled(newValue);
|
||||||
|
setNotificationSoundPref(newValue);
|
||||||
|
if (newValue) {
|
||||||
|
playNotificationSound();
|
||||||
|
}
|
||||||
|
toast.success(newValue ? t('settings.notification_sound_enabled') : t('settings.notification_sound_disabled'));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
|
||||||
|
{enabled ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{t('settings.notification_sound')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t('settings.notification_sound_desc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch checked={enabled} onCheckedChange={handleToggle} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsView() {
|
export default function SettingsView() {
|
||||||
const currentUser = useAppStore((s) => s.currentUser);
|
const currentUser = useAppStore((s) => s.currentUser);
|
||||||
const [activeTab, setActiveTab] = useState('school');
|
const [activeTab, setActiveTab] = useState('school');
|
||||||
@@ -233,6 +276,24 @@ export default function SettingsView() {
|
|||||||
// Export history
|
// Export history
|
||||||
const [exportHistory, setExportHistory] = useState<Array<{ type: string; date: string }>>([]);
|
const [exportHistory, setExportHistory] = useState<Array<{ type: string; date: string }>>([]);
|
||||||
|
|
||||||
|
// Backup state
|
||||||
|
const [backups, setBackups] = useState<Array<{
|
||||||
|
id: string;
|
||||||
|
schoolId: string;
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
notes: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}>>([]);
|
||||||
|
const [backupLoading, setBackupLoading] = useState(false);
|
||||||
|
const [backupCreating, setBackupCreating] = useState(false);
|
||||||
|
const [deleteBackupId, setDeleteBackupId] = useState<string | null>(null);
|
||||||
|
const [restoreBackupId, setRestoreBackupId] = useState<string | null>(null);
|
||||||
|
const [autoBackupEnabled, setAutoBackupEnabled] = useState(false);
|
||||||
|
const [autoBackupFrequency, setAutoBackupFrequency] = useState<'daily' | 'weekly'>('weekly');
|
||||||
|
|
||||||
const isAdmin = currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
const isAdmin = currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||||
|
|
||||||
const loadSchools = useCallback(async () => {
|
const loadSchools = useCallback(async () => {
|
||||||
@@ -338,13 +399,106 @@ export default function SettingsView() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Backup functions
|
||||||
|
const loadBackups = useCallback(async () => {
|
||||||
|
if (!currentUser?.schoolId) return;
|
||||||
|
setBackupLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/backup?schoolId=${currentUser.schoolId}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to load backups');
|
||||||
|
const data = await response.json();
|
||||||
|
setBackups(Array.isArray(data) ? data : []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load backups:', err);
|
||||||
|
setBackups([]);
|
||||||
|
} finally {
|
||||||
|
setBackupLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentUser?.schoolId]);
|
||||||
|
|
||||||
// Reload demo accounts when demo tab is selected
|
// Reload demo accounts when demo tab is selected
|
||||||
const handleTabChange = useCallback((tab: string) => {
|
const handleTabChange = useCallback((tab: string) => {
|
||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
if (tab === 'demo' && isAdmin) {
|
if (tab === 'demo' && isAdmin) {
|
||||||
loadDemoAccounts();
|
loadDemoAccounts();
|
||||||
}
|
}
|
||||||
}, [isAdmin, loadDemoAccounts]);
|
if (tab === 'backup') {
|
||||||
|
loadBackups();
|
||||||
|
}
|
||||||
|
}, [isAdmin, loadDemoAccounts, loadBackups]);
|
||||||
|
|
||||||
|
const handleCreateBackup = useCallback(async () => {
|
||||||
|
if (!currentUser?.schoolId) return;
|
||||||
|
setBackupCreating(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/backup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ schoolId: currentUser.schoolId }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to create backup');
|
||||||
|
toast.success(t('backup.created_success'));
|
||||||
|
loadBackups();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create backup:', err);
|
||||||
|
toast.error(t('backup.error_create'));
|
||||||
|
} finally {
|
||||||
|
setBackupCreating(false);
|
||||||
|
}
|
||||||
|
}, [currentUser?.schoolId, loadBackups]);
|
||||||
|
|
||||||
|
const handleRestoreBackup = useCallback(async () => {
|
||||||
|
if (!restoreBackupId || !currentUser?.schoolId) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/backup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ schoolId: currentUser.schoolId, action: 'restore', backupId: restoreBackupId }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to restore backup');
|
||||||
|
toast.success(t('backup.restored_success'));
|
||||||
|
setRestoreBackupId(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to restore backup:', err);
|
||||||
|
toast.error(t('backup.error_restore'));
|
||||||
|
}
|
||||||
|
}, [restoreBackupId, currentUser?.schoolId]);
|
||||||
|
|
||||||
|
const handleDeleteBackup = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/backup?id=${id}`, { method: 'DELETE' });
|
||||||
|
if (!response.ok) throw new Error('Failed to delete backup');
|
||||||
|
toast.success(t('backup.deleted_success'));
|
||||||
|
setDeleteBackupId(null);
|
||||||
|
loadBackups();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete backup:', err);
|
||||||
|
toast.error(t('backup.error_delete'));
|
||||||
|
}
|
||||||
|
}, [loadBackups]);
|
||||||
|
|
||||||
|
const handleDownloadBackup = useCallback(async (backup: typeof backups[0]) => {
|
||||||
|
if (!currentUser?.schoolId) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/backup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ schoolId: currentUser.schoolId }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to download backup');
|
||||||
|
const data = await response.json();
|
||||||
|
const blob = new Blob([JSON.stringify(data.data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = backup.filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to download backup:', err);
|
||||||
|
toast.error(t('backup.error_create'));
|
||||||
|
}
|
||||||
|
}, [currentUser?.schoolId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSchools();
|
loadSchools();
|
||||||
@@ -804,6 +958,10 @@ export default function SettingsView() {
|
|||||||
<span className="hidden sm:inline">{t('settings.demo_accounts')}</span>
|
<span className="hidden sm:inline">{t('settings.demo_accounts')}</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
<TabsTrigger value="backup" className="rounded-lg min-h-[44px] data-[state=active]:bg-teal-500 data-[state=active]:text-white">
|
||||||
|
<HardDrive className="h-4 w-4 mr-1.5" />
|
||||||
|
<span className="hidden sm:inline">{t('backup.title')}</span>
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* ── School Info Tab ─────────────────────────────────── */}
|
{/* ── School Info Tab ─────────────────────────────────── */}
|
||||||
@@ -892,6 +1050,11 @@ export default function SettingsView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Notification Sound Setting */}
|
||||||
|
<div className="px-6 pb-4 border-t border-gray-100 dark:border-gray-800 pt-4">
|
||||||
|
<NotificationSoundSetting />
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -2169,8 +2332,197 @@ export default function SettingsView() {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Backup Tab ─────────────────────────────────────────── */}
|
||||||
|
<TabsContent value="backup">
|
||||||
|
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
|
||||||
|
<CardHeader className="bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
|
||||||
|
<HardDrive className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
{t('backup.title')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t('backup.auto')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Create backup + Auto-backup toggle */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateBackup}
|
||||||
|
disabled={backupCreating}
|
||||||
|
className="bg-gradient-to-r from-teal-500 to-emerald-500 hover:from-teal-600 hover:to-emerald-600 text-white shadow-md rounded-xl px-6 min-h-[44px]"
|
||||||
|
>
|
||||||
|
{backupCreating ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Archive className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{backupCreating ? t('backup.creating') : t('backup.create')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={autoBackupEnabled}
|
||||||
|
onCheckedChange={setAutoBackupEnabled}
|
||||||
|
className="data-[state=checked]:bg-teal-500"
|
||||||
|
/>
|
||||||
|
<Label className="text-sm text-gray-600 dark:text-gray-400">{t('backup.auto')}</Label>
|
||||||
|
</div>
|
||||||
|
{autoBackupEnabled && (
|
||||||
|
<Select value={autoBackupFrequency} onValueChange={(v) => setAutoBackupFrequency(v as 'daily' | 'weekly')}>
|
||||||
|
<SelectTrigger className="w-32 rounded-xl">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="daily">{t('backup.daily')}</SelectItem>
|
||||||
|
<SelectItem value="weekly">{t('backup.weekly')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last backup info */}
|
||||||
|
{backups.length > 0 && backups[0].status === 'completed' && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
{t('backup.last')}: {new Date(backups[0].createdAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Backup list */}
|
||||||
|
{backupLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-20 rounded-xl" />)}
|
||||||
|
</div>
|
||||||
|
) : backups.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<HardDrive className="h-8 w-8 text-teal-400 dark:text-teal-500 mx-auto mb-2" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">{t('backup.no_backups')}</p>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">{t('backup.no_backups_desc')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto scrollbar-education">
|
||||||
|
{backups.map((backup) => {
|
||||||
|
const sizeKB = Math.round(backup.size / 1024);
|
||||||
|
const sizeMB = (backup.size / (1024 * 1024)).toFixed(1);
|
||||||
|
const sizeDisplay = backup.size > 1024 * 1024 ? `${sizeMB} MB` : `${sizeKB} KB`;
|
||||||
|
const statusColor = backup.status === 'completed'
|
||||||
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
|
||||||
|
: backup.status === 'failed'
|
||||||
|
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||||
|
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300';
|
||||||
|
const statusText = backup.status === 'completed'
|
||||||
|
? t('backup.completed')
|
||||||
|
: backup.status === 'failed'
|
||||||
|
? t('backup.failed')
|
||||||
|
: t('backup.pending');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={backup.id}
|
||||||
|
whileHover={{ scale: 1.01 }}
|
||||||
|
className="p-4 rounded-xl bg-gradient-to-r from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border-l-3 border-l-teal-400/40 hover:border-l-teal-500 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400 shrink-0">
|
||||||
|
<Archive className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm truncate">{backup.filename}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{new Date(backup.createdAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<Badge className={`${statusColor} text-[10px] px-1.5 py-0`}>
|
||||||
|
{statusText}
|
||||||
|
</Badge>
|
||||||
|
<Badge className="bg-gray-100 text-gray-600 dark:bg-gray-900/30 dark:text-gray-400 text-[10px] px-1.5 py-0">
|
||||||
|
{backup.type === 'full' ? t('backup.type_full') : t('backup.type_incremental')}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">{t('backup.size')}: {sizeDisplay}</span>
|
||||||
|
</div>
|
||||||
|
{backup.notes && (
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5 truncate">{backup.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDownloadBackup(backup)}
|
||||||
|
className="h-8 w-8 text-gray-400 hover:text-teal-500 hover:bg-teal-50 dark:hover:bg-teal-900/20"
|
||||||
|
aria-label={t('backup.download')}
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRestoreBackupId(backup.id)}
|
||||||
|
className="h-8 w-8 text-gray-400 hover:text-amber-500 hover:bg-amber-50 dark:hover:bg-amber-900/20"
|
||||||
|
aria-label={t('backup.restore')}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteBackupId(backup.id)}
|
||||||
|
className="h-8 w-8 text-gray-400 hover:text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-900/20"
|
||||||
|
aria-label={t('backup.delete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Backup Restore Confirmation ──────────────────────────── */}
|
||||||
|
<AlertDialog open={!!restoreBackupId} onOpenChange={(open) => { if (!open) setRestoreBackupId(null); }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('backup.restore_confirm')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t('backup.restore_confirm_desc')}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t('action.cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleRestoreBackup} className="bg-amber-600 hover:bg-amber-700 text-white">
|
||||||
|
<RotateCcw className="h-4 w-4 mr-1.5" />
|
||||||
|
{t('backup.restore')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* ── Backup Delete Confirmation ───────────────────────────── */}
|
||||||
|
<AlertDialog open={!!deleteBackupId} onOpenChange={(open) => { if (!open) setDeleteBackupId(null); }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('backup.delete_confirm')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t('backup.delete_confirm_desc')}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t('action.cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => deleteBackupId && handleDeleteBackup(deleteBackupId)} className="bg-rose-600 hover:bg-rose-700 text-white">
|
||||||
|
{t('action.delete')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+257
-1
@@ -725,7 +725,7 @@ export function fetchAuditLog(params?: {
|
|||||||
/* ── Data Export (CSV) ────────────────────────────────────────────── */
|
/* ── Data Export (CSV) ────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function downloadCsvExport(params: {
|
export function downloadCsvExport(params: {
|
||||||
type: 'students' | 'progress' | 'assessments' | 'grades';
|
type: 'students' | 'progress' | 'assessments' | 'grades' | 'attendance';
|
||||||
classGroupId?: string;
|
classGroupId?: string;
|
||||||
schoolYearId?: string;
|
schoolYearId?: string;
|
||||||
schoolId?: string;
|
schoolId?: string;
|
||||||
@@ -1909,6 +1909,262 @@ export function duplicateRubric(id: string): Promise<Rubric> {
|
|||||||
return apiPost<Rubric>(`/api/rubrics/${id}/duplicate`);
|
return apiPost<Rubric>(`/api/rubrics/${id}/duplicate`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Rubric Templates & Grading ───────────────────────────────────── */
|
||||||
|
|
||||||
|
export interface RubricTemplate {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
subject: string;
|
||||||
|
type: 'ANALYTIC' | 'HOLISTIC';
|
||||||
|
maxPoints: number;
|
||||||
|
criteria: Array<{
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
weight: number;
|
||||||
|
maxPoints: number;
|
||||||
|
levels: Array<{ label: string; description: string; points: number }>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRubricTemplates(subject?: string): Promise<RubricTemplate[]> {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (subject && subject !== 'all') sp.set('subject', subject);
|
||||||
|
return apiGet<RubricTemplate[]>(`/api/rubrics/templates?${sp.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RubricGradeResult {
|
||||||
|
rubricId: string;
|
||||||
|
studentId: string;
|
||||||
|
finalScore: number;
|
||||||
|
maxPoints: number;
|
||||||
|
overallPercentage: number;
|
||||||
|
grade: number;
|
||||||
|
gradeLabel: string;
|
||||||
|
criterionBreakdown: Array<{
|
||||||
|
criterionId: string;
|
||||||
|
criterionName: string;
|
||||||
|
maxPoints: number;
|
||||||
|
earnedPoints: number;
|
||||||
|
selectedLevel: { id: string; label: string; description: string } | null;
|
||||||
|
percentage: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gradeWithRubric(rubricId: string, data: {
|
||||||
|
studentId: string;
|
||||||
|
scores: Array<{ criterionId: string; levelId: string; points: number }>;
|
||||||
|
note?: string;
|
||||||
|
}): Promise<RubricGradeResult> {
|
||||||
|
return apiPost<RubricGradeResult>(`/api/rubrics/${rubricId}/grade`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RubricAnalytics {
|
||||||
|
rubricId: string;
|
||||||
|
rubricTitle: string;
|
||||||
|
criteriaAnalytics: Array<{
|
||||||
|
criterionId: string;
|
||||||
|
criterionName: string;
|
||||||
|
maxPoints: number;
|
||||||
|
weight: number;
|
||||||
|
classAverage: number;
|
||||||
|
classMedian: number;
|
||||||
|
highestScore: number;
|
||||||
|
lowestScore: number;
|
||||||
|
levelDistribution: Array<{
|
||||||
|
levelId: string;
|
||||||
|
label: string;
|
||||||
|
points: number;
|
||||||
|
description: string;
|
||||||
|
percentage: number;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
gradeDistribution: Array<{
|
||||||
|
grade: string;
|
||||||
|
range: string;
|
||||||
|
count: number;
|
||||||
|
color: string;
|
||||||
|
}>;
|
||||||
|
overallStats: {
|
||||||
|
averagePercentage: number;
|
||||||
|
totalAssessments: number;
|
||||||
|
totalStudents: number;
|
||||||
|
totalMaxPoints: number;
|
||||||
|
averageScore: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRubricAnalytics(rubricId: string): Promise<RubricAnalytics> {
|
||||||
|
return apiGet<RubricAnalytics>(`/api/rubrics/${rubricId}/analytics`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Curriculum Standards ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export interface CurriculumStandard {
|
||||||
|
id: string;
|
||||||
|
schoolId: string;
|
||||||
|
subjectId: string | null;
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
gradeLevel: number | null;
|
||||||
|
category: string | null;
|
||||||
|
source: string | null;
|
||||||
|
isDemo: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
subject: { id: string; name: string } | null;
|
||||||
|
competencyLinks: CurriculumStandardLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurriculumStandardLink {
|
||||||
|
id: string;
|
||||||
|
standardId: string;
|
||||||
|
competencyId: string;
|
||||||
|
coverageLevel: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
competency: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
category: { id: string; name: string; color: string | null } | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCurriculumStandards(schoolId: string, subjectId?: string, gradeLevel?: number): Promise<CurriculumStandard[]> {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
sp.set('schoolId', schoolId);
|
||||||
|
if (subjectId && subjectId !== 'all') sp.set('subjectId', subjectId);
|
||||||
|
if (gradeLevel) sp.set('gradeLevel', String(gradeLevel));
|
||||||
|
return apiGet<CurriculumStandard[]>(`/api/curriculum-standards?${sp.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCurriculumStandard(id: string): Promise<CurriculumStandard> {
|
||||||
|
return apiGet<CurriculumStandard>(`/api/curriculum-standards/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCurriculumStandard(data: {
|
||||||
|
schoolId: string;
|
||||||
|
subjectId?: string | null;
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
gradeLevel?: number | null;
|
||||||
|
category?: string | null;
|
||||||
|
source?: string | null;
|
||||||
|
isDemo?: boolean;
|
||||||
|
}): Promise<CurriculumStandard> {
|
||||||
|
return apiPost<CurriculumStandard>('/api/curriculum-standards', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCurriculumStandard(id: string, data: {
|
||||||
|
subjectId?: string | null;
|
||||||
|
code?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
gradeLevel?: number | null;
|
||||||
|
category?: string | null;
|
||||||
|
source?: string | null;
|
||||||
|
}): Promise<CurriculumStandard> {
|
||||||
|
return apiPut<CurriculumStandard>(`/api/curriculum-standards/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCurriculumStandard(id: string): Promise<{ success: boolean }> {
|
||||||
|
return apiDelete<{ success: boolean }>(`/api/curriculum-standards/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linkCurriculumStandard(standardId: string, data: {
|
||||||
|
competencyId: string;
|
||||||
|
coverageLevel?: 'full' | 'partial' | 'related' | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}): Promise<CurriculumStandardLink> {
|
||||||
|
return apiPost<CurriculumStandardLink>(`/api/curriculum-standards/${standardId}/links`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unlinkCurriculumStandard(standardId: string, competencyId: string): Promise<{ success: boolean }> {
|
||||||
|
return apiPost<{ success: boolean }>(`/api/curriculum-standards/${standardId}/links`, {
|
||||||
|
action: 'unlink',
|
||||||
|
competencyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCurriculumStandardLinks(standardId: string): Promise<CurriculumStandardLink[]> {
|
||||||
|
return apiGet<CurriculumStandardLink[]>(`/api/curriculum-standards/${standardId}/links`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Attendance Analytics ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export interface AttendanceAnalyticsData {
|
||||||
|
trendData: Array<{
|
||||||
|
week: string;
|
||||||
|
attendanceRate: number;
|
||||||
|
absentRate: number;
|
||||||
|
present: number;
|
||||||
|
absent: number;
|
||||||
|
excused: number;
|
||||||
|
late: number;
|
||||||
|
total: number;
|
||||||
|
}>;
|
||||||
|
dayOfWeekAnalysis: Array<{
|
||||||
|
day: number;
|
||||||
|
dayName: string;
|
||||||
|
dayNameDe: string;
|
||||||
|
attendanceRate: number;
|
||||||
|
absentCount: number;
|
||||||
|
totalRecords: number;
|
||||||
|
}>;
|
||||||
|
absencePatterns: Array<{
|
||||||
|
studentId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
present: number;
|
||||||
|
absent: number;
|
||||||
|
excused: number;
|
||||||
|
late: number;
|
||||||
|
total: number;
|
||||||
|
absenceRate: number;
|
||||||
|
attendanceRate: number;
|
||||||
|
}>;
|
||||||
|
riskIndicators: Array<{
|
||||||
|
studentId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
absenceRate: number;
|
||||||
|
totalAbsences: number;
|
||||||
|
totalSessions: number;
|
||||||
|
riskLevel: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
}>;
|
||||||
|
classComparison: Array<{
|
||||||
|
classGroupId: string;
|
||||||
|
className: string;
|
||||||
|
gradeLevel: number;
|
||||||
|
present: number;
|
||||||
|
absent: number;
|
||||||
|
excused: number;
|
||||||
|
late: number;
|
||||||
|
total: number;
|
||||||
|
attendanceRate: number;
|
||||||
|
absenceRate: number;
|
||||||
|
}>;
|
||||||
|
statusDistribution: {
|
||||||
|
present: number;
|
||||||
|
absent: number;
|
||||||
|
excused: number;
|
||||||
|
late: number;
|
||||||
|
};
|
||||||
|
totalSessions: number;
|
||||||
|
totalRecords: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAttendanceAnalytics(schoolId: string, classGroupId?: string): Promise<AttendanceAnalyticsData> {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
sp.set('schoolId', schoolId);
|
||||||
|
if (classGroupId) sp.set('classGroupId', classGroupId);
|
||||||
|
return apiGet<AttendanceAnalyticsData>(`/api/attendance/analytics?${sp.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Comment Bank ────────────────────────────────────────────────── */
|
/* ── Comment Bank ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export interface CommentCategory {
|
export interface CommentCategory {
|
||||||
|
|||||||
+334
@@ -2160,6 +2160,173 @@ const de: TranslationDict = {
|
|||||||
'settings.default_password': 'Standardpasswort',
|
'settings.default_password': 'Standardpasswort',
|
||||||
'settings.generate_email': 'E-Mail automatisch generieren',
|
'settings.generate_email': 'E-Mail automatisch generieren',
|
||||||
'settings.link_student': 'Mit Schüler verknüpfen',
|
'settings.link_student': 'Mit Schüler verknüpfen',
|
||||||
|
|
||||||
|
// ─── Collaboration & Real-Time ──────────────────────────────────────
|
||||||
|
'collab.online_users': '{count} online',
|
||||||
|
'collab.editing_by': '{name} bearbeitet...',
|
||||||
|
'collab.activity_title': 'Aktivität',
|
||||||
|
'collab.no_activity': 'Keine Aktivität',
|
||||||
|
'collab.started_editing': 'bearbeitet',
|
||||||
|
'collab.realtime_connected': 'Echtzeit verbunden',
|
||||||
|
'collab.realtime_disconnected': 'Echtzeit getrennt',
|
||||||
|
'collab.conflict_resolved': 'Konflikt gelöst (letzter Speichervorgang gewinnt)',
|
||||||
|
'collab.cursors_visible': 'Cursors anderer Nutzer sichtbar',
|
||||||
|
'collab.user_joined': '{name} ist beigetreten',
|
||||||
|
'collab.user_left': '{name} hat verlassen',
|
||||||
|
|
||||||
|
// ─── Notification Sound ────────────────────────────────────────────
|
||||||
|
'settings.notification_sound': 'Benachrichtigungston',
|
||||||
|
'settings.notification_sound_desc': 'Ton bei neuen Benachrichtigungen abspielen',
|
||||||
|
'settings.notification_sound_enabled': 'Ton aktiviert',
|
||||||
|
'settings.notification_sound_disabled': 'Ton deaktiviert',
|
||||||
|
|
||||||
|
// ── Rubric Enhancement ──
|
||||||
|
'rubrics.templates': 'Vorlagen',
|
||||||
|
'rubrics.templates_title': 'Bewertungsraster-Vorlagen',
|
||||||
|
'rubrics.templates_desc': 'Wählen Sie eine Vorlage als Ausgangspunkt für Ihr Bewertungsraster',
|
||||||
|
'rubrics.use_template': 'Vorlage verwenden',
|
||||||
|
'rubrics.template_math': 'Mathematik — Klassenarbeit',
|
||||||
|
'rubrics.template_german': 'Deutsch — Aufsichtsarbeit',
|
||||||
|
'rubrics.template_english': 'Englisch — Writing Task',
|
||||||
|
'rubrics.template_project': 'Allgemein — Projektarbeit',
|
||||||
|
'rubrics.template_oral': 'Allgemein — Mündliche Präsentation',
|
||||||
|
'rubrics.no_template': 'Ohne Vorlage starten',
|
||||||
|
'rubrics.grade': 'Bewerten',
|
||||||
|
'rubrics.grade_title': 'Rubrik-basierte Bewertung',
|
||||||
|
'rubrics.select_level': 'Niveau auswählen',
|
||||||
|
'rubrics.auto_grade': 'Automatische Notenberechnung',
|
||||||
|
'rubrics.final_score': 'Endpunktzahl',
|
||||||
|
'rubrics.grade_result': 'Note',
|
||||||
|
'rubrics.percentage': 'Prozent',
|
||||||
|
'rubrics.criterion_breakdown': 'Kriterien-Aufschlüsselung',
|
||||||
|
'rubrics.earned_points': 'Erreichte Punkte',
|
||||||
|
'rubrics.analytics': 'Analyse',
|
||||||
|
'rubrics.analytics_title': 'Rubrik-Analyse',
|
||||||
|
'rubrics.class_performance': 'Klassenleistung nach Kriterium',
|
||||||
|
'rubrics.grade_distribution': 'Notenverteilung',
|
||||||
|
'rubrics.average_score': 'Durchschnitt',
|
||||||
|
'rubrics.highest_score': 'Höchste Punktzahl',
|
||||||
|
'rubrics.lowest_score': 'Niedrigste Punktzahl',
|
||||||
|
'rubrics.export_pdf': 'Als PDF exportieren',
|
||||||
|
'rubrics.export_print': 'Drucken',
|
||||||
|
'rubrics.score_sheet': 'Bewertungsbogen',
|
||||||
|
'rubrics.select_student': 'Schüler auswählen',
|
||||||
|
'rubrics.select_rubric': 'Bewertungsraster auswählen',
|
||||||
|
|
||||||
|
// ── Curriculum Standards ──
|
||||||
|
'curriculum.title': 'Lehrplan-Mapping',
|
||||||
|
'curriculum.subtitle': 'Verknüpfen Sie Kompetenzen mit Lehrplanstandards',
|
||||||
|
'curriculum.standards': 'Lehrplanstandards',
|
||||||
|
'curriculum.create_standard': 'Standard erstellen',
|
||||||
|
'curriculum.edit_standard': 'Standard bearbeiten',
|
||||||
|
'curriculum.code': 'Code',
|
||||||
|
'curriculum.standard_title': 'Titel',
|
||||||
|
'curriculum.description': 'Beschreibung',
|
||||||
|
'curriculum.grade_level': 'Klassenstufe',
|
||||||
|
'curriculum.category': 'Kategorie',
|
||||||
|
'curriculum.source': 'Quelle',
|
||||||
|
'curriculum.subject': 'Fach',
|
||||||
|
'curriculum.coverage': 'Abdeckung',
|
||||||
|
'curriculum.coverage_visualization': 'Abdeckungsübersicht',
|
||||||
|
'curriculum.gap_analysis': 'Lückenanalyse',
|
||||||
|
'curriculum.gap_title': 'Standards ohne Kompetenzverknüpfung',
|
||||||
|
'curriculum.no_gaps': 'Alle Standards sind mit Kompetenzen verknüpft',
|
||||||
|
'curriculum.progress_tracking': 'Fortschrittsverfolgung',
|
||||||
|
'curriculum.heatmap': 'Abdeckungs-Heatmap',
|
||||||
|
'curriculum.link_competency': 'Kompetenz verknüpfen',
|
||||||
|
'curriculum.unlink_competency': 'Verknüpfung aufheben',
|
||||||
|
'curriculum.coverage_level': 'Abdeckungsgrad',
|
||||||
|
'curriculum.coverage_full': 'Vollständig',
|
||||||
|
'curriculum.coverage_partial': 'Teilweise',
|
||||||
|
'curriculum.coverage_related': 'Verwandt',
|
||||||
|
'curriculum.notes': 'Notizen',
|
||||||
|
'curriculum.linked_competencies': 'Verknüpfte Kompetenzen',
|
||||||
|
'curriculum.no_links': 'Keine Kompetenzen verknüpft',
|
||||||
|
'curriculum.standards_count': 'Standards',
|
||||||
|
'curriculum.linked_count': 'Verknüpft',
|
||||||
|
'curriculum.unlinked_count': 'Nicht verknüpft',
|
||||||
|
'curriculum.coverage_percent': 'Abdeckung %',
|
||||||
|
'curriculum.search_standards': 'Standards durchsuchen...',
|
||||||
|
'curriculum.all_subjects': 'Alle Fächer',
|
||||||
|
'curriculum.all_grades': 'Alle Klassenstufen',
|
||||||
|
'curriculum.all_sources': 'Alle Quellen',
|
||||||
|
'curriculum.delete_standard': 'Standard löschen',
|
||||||
|
'curriculum.delete_standard_desc': 'Möchten Sie diesen Standard wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||||
|
'curriculum.empty_title': 'Keine Standards vorhanden',
|
||||||
|
'curriculum.empty_desc': 'Erstellen Sie Ihren ersten Lehrplanstandard, um Kompetenzen zuzuordnen.',
|
||||||
|
'curriculum.select_competency': 'Kompetenz auswählen',
|
||||||
|
'curriculum.search_competency': 'Kompetenz suchen...',
|
||||||
|
'curriculum.mastery_progress': 'Beherrschungsfortschritt',
|
||||||
|
'curriculum.students_mastered': 'Schüler beherrschen diesen Standard',
|
||||||
|
|
||||||
|
// ── Attendance Analytics ──
|
||||||
|
'attendance.analytics': 'Analyse',
|
||||||
|
'attendance.analytics_title': 'Anwesenheits-Analyse',
|
||||||
|
'attendance.trends': 'Trends',
|
||||||
|
'attendance.weekly_trend': 'Wöchentlicher Trend',
|
||||||
|
'attendance.attendance_rate_over_time': 'Anwesenheitsrate im Zeitverlauf',
|
||||||
|
'attendance.absence_rate_over_time': 'Abwesenheitsrate im Zeitverlauf',
|
||||||
|
'attendance.day_of_week': 'Wochentagsanalyse',
|
||||||
|
'attendance.absences_by_day': 'Abwesenheiten nach Wochentag',
|
||||||
|
'attendance.chronic_absence': 'Chronische Abwesenheit',
|
||||||
|
'attendance.chronic_absence_desc': 'Schüler mit 10% oder höherer Abwesenheitsrate',
|
||||||
|
'attendance.risk_indicators': 'Risiko-Indikatoren',
|
||||||
|
'attendance.risk_low': 'Geringes Risiko',
|
||||||
|
'attendance.risk_medium': 'Mittleres Risiko',
|
||||||
|
'attendance.risk_high': 'Hohes Risiko',
|
||||||
|
'attendance.risk_critical': 'Kritisches Risiko',
|
||||||
|
'attendance.class_comparison': 'Klassenvergleich',
|
||||||
|
'attendance.attendance_by_class': 'Anwesenheit nach Klasse',
|
||||||
|
'attendance.status_distribution': 'Statusverteilung',
|
||||||
|
'attendance.export_csv': 'Als CSV exportieren',
|
||||||
|
'attendance.week': 'Woche',
|
||||||
|
'attendance.rate_percent': 'Rate %',
|
||||||
|
'attendance.absent_count_long': 'Abwesenheiten',
|
||||||
|
'attendance.total_sessions': 'Gesamtsitzungen',
|
||||||
|
'attendance.total_records': 'Gesamteinträge',
|
||||||
|
'attendance.no_analytics_data': 'Keine Analysedaten verfügbar',
|
||||||
|
'attendance.no_analytics_desc': 'Erstellen Sie Anwesenheitssitzungen, um Analysen zu sehen.',
|
||||||
|
|
||||||
|
// ── Backup (Task 10) ──
|
||||||
|
'backup.title': 'Datensicherung',
|
||||||
|
'backup.create': 'Sicherung erstellen',
|
||||||
|
'backup.restore': 'Sicherung wiederherstellen',
|
||||||
|
'backup.delete': 'Sicherung löschen',
|
||||||
|
'backup.auto': 'Automatische Sicherung',
|
||||||
|
'backup.daily': 'Täglich',
|
||||||
|
'backup.weekly': 'Wöchentlich',
|
||||||
|
'backup.last': 'Letzte Sicherung',
|
||||||
|
'backup.size': 'Größe',
|
||||||
|
'backup.status': 'Status',
|
||||||
|
'backup.completed': 'Abgeschlossen',
|
||||||
|
'backup.failed': 'Fehlgeschlagen',
|
||||||
|
'backup.pending': 'Ausstehend',
|
||||||
|
'backup.no_backups': 'Keine Sicherungen vorhanden',
|
||||||
|
'backup.no_backups_desc': 'Erstellen Sie Ihre erste Datensicherung.',
|
||||||
|
'backup.create_confirm': 'Sicherung erstellen?',
|
||||||
|
'backup.create_confirm_desc': 'Es wird eine vollständige Sicherung aller Daten erstellt.',
|
||||||
|
'backup.restore_confirm': 'Sicherung wiederherstellen?',
|
||||||
|
'backup.restore_confirm_desc': 'Die aktuellen Daten werden durch die Sicherung ersetzt. Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||||
|
'backup.delete_confirm': 'Sicherung löschen?',
|
||||||
|
'backup.delete_confirm_desc': 'Diese Sicherung wird unwiderruflich gelöscht.',
|
||||||
|
'backup.creating': 'Sicherung wird erstellt...',
|
||||||
|
'backup.restoring': 'Sicherung wird wiederhergestellt...',
|
||||||
|
'backup.type': 'Typ',
|
||||||
|
'backup.type_full': 'Vollständig',
|
||||||
|
'backup.type_incremental': 'Inkrementell',
|
||||||
|
'backup.notes': 'Notizen',
|
||||||
|
'backup.download': 'Sicherung herunterladen',
|
||||||
|
'backup.created_success': 'Sicherung erfolgreich erstellt',
|
||||||
|
'backup.restored_success': 'Sicherung erfolgreich wiederhergestellt',
|
||||||
|
'backup.deleted_success': 'Sicherung gelöscht',
|
||||||
|
'backup.error_create': 'Fehler beim Erstellen der Sicherung',
|
||||||
|
'backup.error_restore': 'Fehler beim Wiederherstellen der Sicherung',
|
||||||
|
'backup.error_delete': 'Fehler beim Löschen der Sicherung',
|
||||||
|
|
||||||
|
// ── Performance (Task 10) ──
|
||||||
|
'performance.title': 'Leistung',
|
||||||
|
'performance.fps': 'Bilder pro Sekunde',
|
||||||
|
'mobile.optimized': 'Für mobile Geräte optimiert',
|
||||||
};
|
};
|
||||||
|
|
||||||
const en: TranslationDict = {
|
const en: TranslationDict = {
|
||||||
@@ -4319,6 +4486,173 @@ const en: TranslationDict = {
|
|||||||
'settings.default_password': 'Default Password',
|
'settings.default_password': 'Default Password',
|
||||||
'settings.generate_email': 'Auto-generate Email',
|
'settings.generate_email': 'Auto-generate Email',
|
||||||
'settings.link_student': 'Link to Student',
|
'settings.link_student': 'Link to Student',
|
||||||
|
|
||||||
|
// ─── Collaboration & Real-Time ──────────────────────────────────────
|
||||||
|
'collab.online_users': '{count} online',
|
||||||
|
'collab.editing_by': '{name} is editing...',
|
||||||
|
'collab.activity_title': 'Activity',
|
||||||
|
'collab.no_activity': 'No activity',
|
||||||
|
'collab.started_editing': 'started editing',
|
||||||
|
'collab.realtime_connected': 'Real-time connected',
|
||||||
|
'collab.realtime_disconnected': 'Real-time disconnected',
|
||||||
|
'collab.conflict_resolved': 'Conflict resolved (last write wins)',
|
||||||
|
'collab.cursors_visible': 'Other users\' cursors visible',
|
||||||
|
'collab.user_joined': '{name} joined',
|
||||||
|
'collab.user_left': '{name} left',
|
||||||
|
|
||||||
|
// ─── Notification Sound ────────────────────────────────────────────
|
||||||
|
'settings.notification_sound': 'Notification Sound',
|
||||||
|
'settings.notification_sound_desc': 'Play sound for new notifications',
|
||||||
|
'settings.notification_sound_enabled': 'Sound enabled',
|
||||||
|
'settings.notification_sound_disabled': 'Sound disabled',
|
||||||
|
|
||||||
|
// ── Rubric Enhancement ──
|
||||||
|
'rubrics.templates': 'Templates',
|
||||||
|
'rubrics.templates_title': 'Rubric Templates',
|
||||||
|
'rubrics.templates_desc': 'Choose a template as a starting point for your rubric',
|
||||||
|
'rubrics.use_template': 'Use Template',
|
||||||
|
'rubrics.template_math': 'Math — Assessment',
|
||||||
|
'rubrics.template_german': 'German — Essay',
|
||||||
|
'rubrics.template_english': 'English — Writing Task',
|
||||||
|
'rubrics.template_project': 'General — Project Work',
|
||||||
|
'rubrics.template_oral': 'General — Oral Presentation',
|
||||||
|
'rubrics.no_template': 'Start from scratch',
|
||||||
|
'rubrics.grade': 'Grade',
|
||||||
|
'rubrics.grade_title': 'Rubric-Based Grading',
|
||||||
|
'rubrics.select_level': 'Select level',
|
||||||
|
'rubrics.auto_grade': 'Auto-Grade Calculation',
|
||||||
|
'rubrics.final_score': 'Final Score',
|
||||||
|
'rubrics.grade_result': 'Grade',
|
||||||
|
'rubrics.percentage': 'Percentage',
|
||||||
|
'rubrics.criterion_breakdown': 'Criterion Breakdown',
|
||||||
|
'rubrics.earned_points': 'Earned Points',
|
||||||
|
'rubrics.analytics': 'Analytics',
|
||||||
|
'rubrics.analytics_title': 'Rubric Analytics',
|
||||||
|
'rubrics.class_performance': 'Class Performance by Criterion',
|
||||||
|
'rubrics.grade_distribution': 'Grade Distribution',
|
||||||
|
'rubrics.average_score': 'Average',
|
||||||
|
'rubrics.highest_score': 'Highest Score',
|
||||||
|
'rubrics.lowest_score': 'Lowest Score',
|
||||||
|
'rubrics.export_pdf': 'Export as PDF',
|
||||||
|
'rubrics.export_print': 'Print',
|
||||||
|
'rubrics.score_sheet': 'Score Sheet',
|
||||||
|
'rubrics.select_student': 'Select Student',
|
||||||
|
'rubrics.select_rubric': 'Select Rubric',
|
||||||
|
|
||||||
|
// ── Curriculum Standards ──
|
||||||
|
'curriculum.title': 'Curriculum Mapping',
|
||||||
|
'curriculum.subtitle': 'Link competencies to curriculum standards',
|
||||||
|
'curriculum.standards': 'Curriculum Standards',
|
||||||
|
'curriculum.create_standard': 'Create Standard',
|
||||||
|
'curriculum.edit_standard': 'Edit Standard',
|
||||||
|
'curriculum.code': 'Code',
|
||||||
|
'curriculum.standard_title': 'Title',
|
||||||
|
'curriculum.description': 'Description',
|
||||||
|
'curriculum.grade_level': 'Grade Level',
|
||||||
|
'curriculum.category': 'Category',
|
||||||
|
'curriculum.source': 'Source',
|
||||||
|
'curriculum.subject': 'Subject',
|
||||||
|
'curriculum.coverage': 'Coverage',
|
||||||
|
'curriculum.coverage_visualization': 'Coverage Visualization',
|
||||||
|
'curriculum.gap_analysis': 'Gap Analysis',
|
||||||
|
'curriculum.gap_title': 'Standards without Competency Links',
|
||||||
|
'curriculum.no_gaps': 'All standards are linked to competencies',
|
||||||
|
'curriculum.progress_tracking': 'Progress Tracking',
|
||||||
|
'curriculum.heatmap': 'Coverage Heatmap',
|
||||||
|
'curriculum.link_competency': 'Link Competency',
|
||||||
|
'curriculum.unlink_competency': 'Unlink Competency',
|
||||||
|
'curriculum.coverage_level': 'Coverage Level',
|
||||||
|
'curriculum.coverage_full': 'Full',
|
||||||
|
'curriculum.coverage_partial': 'Partial',
|
||||||
|
'curriculum.coverage_related': 'Related',
|
||||||
|
'curriculum.notes': 'Notes',
|
||||||
|
'curriculum.linked_competencies': 'Linked Competencies',
|
||||||
|
'curriculum.no_links': 'No competencies linked',
|
||||||
|
'curriculum.standards_count': 'Standards',
|
||||||
|
'curriculum.linked_count': 'Linked',
|
||||||
|
'curriculum.unlinked_count': 'Unlinked',
|
||||||
|
'curriculum.coverage_percent': 'Coverage %',
|
||||||
|
'curriculum.search_standards': 'Search standards...',
|
||||||
|
'curriculum.all_subjects': 'All Subjects',
|
||||||
|
'curriculum.all_grades': 'All Grade Levels',
|
||||||
|
'curriculum.all_sources': 'All Sources',
|
||||||
|
'curriculum.delete_standard': 'Delete Standard',
|
||||||
|
'curriculum.delete_standard_desc': 'Are you sure you want to delete this standard? This action cannot be undone.',
|
||||||
|
'curriculum.empty_title': 'No Standards Yet',
|
||||||
|
'curriculum.empty_desc': 'Create your first curriculum standard to map competencies.',
|
||||||
|
'curriculum.select_competency': 'Select Competency',
|
||||||
|
'curriculum.search_competency': 'Search competency...',
|
||||||
|
'curriculum.mastery_progress': 'Mastery Progress',
|
||||||
|
'curriculum.students_mastered': 'Students have mastered this standard',
|
||||||
|
|
||||||
|
// ── Attendance Analytics ──
|
||||||
|
'attendance.analytics': 'Analytics',
|
||||||
|
'attendance.analytics_title': 'Attendance Analytics',
|
||||||
|
'attendance.trends': 'Trends',
|
||||||
|
'attendance.weekly_trend': 'Weekly Trend',
|
||||||
|
'attendance.attendance_rate_over_time': 'Attendance Rate Over Time',
|
||||||
|
'attendance.absence_rate_over_time': 'Absence Rate Over Time',
|
||||||
|
'attendance.day_of_week': 'Day of Week Analysis',
|
||||||
|
'attendance.absences_by_day': 'Absences by Day of Week',
|
||||||
|
'attendance.chronic_absence': 'Chronic Absence',
|
||||||
|
'attendance.chronic_absence_desc': 'Students with 10% or higher absence rate',
|
||||||
|
'attendance.risk_indicators': 'Risk Indicators',
|
||||||
|
'attendance.risk_low': 'Low Risk',
|
||||||
|
'attendance.risk_medium': 'Medium Risk',
|
||||||
|
'attendance.risk_high': 'High Risk',
|
||||||
|
'attendance.risk_critical': 'Critical Risk',
|
||||||
|
'attendance.class_comparison': 'Class Comparison',
|
||||||
|
'attendance.attendance_by_class': 'Attendance by Class',
|
||||||
|
'attendance.status_distribution': 'Status Distribution',
|
||||||
|
'attendance.export_csv': 'Export as CSV',
|
||||||
|
'attendance.week': 'Week',
|
||||||
|
'attendance.rate_percent': 'Rate %',
|
||||||
|
'attendance.absent_count_long': 'Absences',
|
||||||
|
'attendance.total_sessions': 'Total Sessions',
|
||||||
|
'attendance.total_records': 'Total Records',
|
||||||
|
'attendance.no_analytics_data': 'No Analytics Data Available',
|
||||||
|
'attendance.no_analytics_desc': 'Create attendance sessions to see analytics.',
|
||||||
|
|
||||||
|
// ── Backup (Task 10) ──
|
||||||
|
'backup.title': 'Datensicherung',
|
||||||
|
'backup.create': 'Sicherung erstellen',
|
||||||
|
'backup.restore': 'Sicherung wiederherstellen',
|
||||||
|
'backup.delete': 'Sicherung löschen',
|
||||||
|
'backup.auto': 'Automatische Sicherung',
|
||||||
|
'backup.daily': 'Täglich',
|
||||||
|
'backup.weekly': 'Wöchentlich',
|
||||||
|
'backup.last': 'Letzte Sicherung',
|
||||||
|
'backup.size': 'Größe',
|
||||||
|
'backup.status': 'Status',
|
||||||
|
'backup.completed': 'Abgeschlossen',
|
||||||
|
'backup.failed': 'Fehlgeschlagen',
|
||||||
|
'backup.pending': 'Ausstehend',
|
||||||
|
'backup.no_backups': 'Keine Sicherungen vorhanden',
|
||||||
|
'backup.no_backups_desc': 'Erstellen Sie Ihre erste Datensicherung.',
|
||||||
|
'backup.create_confirm': 'Sicherung erstellen?',
|
||||||
|
'backup.create_confirm_desc': 'Es wird eine vollständige Sicherung aller Daten erstellt.',
|
||||||
|
'backup.restore_confirm': 'Sicherung wiederherstellen?',
|
||||||
|
'backup.restore_confirm_desc': 'Die aktuellen Daten werden durch die Sicherung ersetzt. Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||||
|
'backup.delete_confirm': 'Sicherung löschen?',
|
||||||
|
'backup.delete_confirm_desc': 'Diese Sicherung wird unwiderruflich gelöscht.',
|
||||||
|
'backup.creating': 'Sicherung wird erstellt...',
|
||||||
|
'backup.restoring': 'Sicherung wird wiederhergestellt...',
|
||||||
|
'backup.type': 'Typ',
|
||||||
|
'backup.type_full': 'Vollständig',
|
||||||
|
'backup.type_incremental': 'Inkrementell',
|
||||||
|
'backup.notes': 'Notizen',
|
||||||
|
'backup.download': 'Sicherung herunterladen',
|
||||||
|
'backup.created_success': 'Sicherung erfolgreich erstellt',
|
||||||
|
'backup.restored_success': 'Sicherung erfolgreich wiederhergestellt',
|
||||||
|
'backup.deleted_success': 'Sicherung gelöscht',
|
||||||
|
'backup.error_create': 'Fehler beim Erstellen der Sicherung',
|
||||||
|
'backup.error_restore': 'Fehler beim Wiederherstellen der Sicherung',
|
||||||
|
'backup.error_delete': 'Fehler beim Löschen der Sicherung',
|
||||||
|
|
||||||
|
// ── Performance (Task 10) ──
|
||||||
|
'performance.title': 'Leistung',
|
||||||
|
'performance.fps': 'Bilder pro Sekunde',
|
||||||
|
'mobile.optimized': 'Für mobile Geräte optimiert',
|
||||||
};
|
};
|
||||||
|
|
||||||
const translations: Record<string, TranslationDict> = { de, en };
|
const translations: Record<string, TranslationDict> = { de, en };
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// CompetenceTrack — Notification Helper
|
// CompetenceTrack — Notification Helper
|
||||||
// Server-side utility for creating notifications in the database
|
// Server-side utility for creating notifications in the database
|
||||||
|
// Also pushes notifications via WebSocket service
|
||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
@@ -13,9 +14,33 @@ export type NotificationType =
|
|||||||
| 'REPORT_READY'
|
| 'REPORT_READY'
|
||||||
| 'GENERAL';
|
| 'GENERAL';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a notification to the WebSocket service so it arrives in real-time.
|
||||||
|
* This is a fire-and-forget call — failures are non-critical.
|
||||||
|
*/
|
||||||
|
async function pushNotificationToWS(params: {
|
||||||
|
userId: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
actionUrl?: string;
|
||||||
|
relatedId?: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await fetch('http://localhost:3003/api/push-notification', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// WebSocket push is non-critical — ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a notification for a specific user.
|
* Create a notification for a specific user.
|
||||||
* This is a server-side function that writes directly to the database.
|
* This is a server-side function that writes directly to the database.
|
||||||
|
* Also pushes the notification via WebSocket for real-time delivery.
|
||||||
*/
|
*/
|
||||||
export async function createNotification(params: {
|
export async function createNotification(params: {
|
||||||
schoolId: string;
|
schoolId: string;
|
||||||
@@ -28,7 +53,7 @@ export async function createNotification(params: {
|
|||||||
}) {
|
}) {
|
||||||
const { schoolId, userId, type, title, message, actionUrl, relatedId } = params;
|
const { schoolId, userId, type, title, message, actionUrl, relatedId } = params;
|
||||||
|
|
||||||
return db.notification.create({
|
const notification = await db.notification.create({
|
||||||
data: {
|
data: {
|
||||||
schoolId,
|
schoolId,
|
||||||
userId,
|
userId,
|
||||||
@@ -39,10 +64,16 @@ export async function createNotification(params: {
|
|||||||
relatedId: relatedId ?? null,
|
relatedId: relatedId ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Push to WebSocket for real-time delivery (non-blocking)
|
||||||
|
pushNotificationToWS({ userId, type, title, message, actionUrl, relatedId }).catch(() => {});
|
||||||
|
|
||||||
|
return notification;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a notification for multiple users (e.g., all students in a class).
|
* Create a notification for multiple users (e.g., all students in a class).
|
||||||
|
* Also pushes each notification via WebSocket.
|
||||||
*/
|
*/
|
||||||
export async function createNotificationForUsers(params: {
|
export async function createNotificationForUsers(params: {
|
||||||
schoolId: string;
|
schoolId: string;
|
||||||
@@ -55,7 +86,7 @@ export async function createNotificationForUsers(params: {
|
|||||||
}) {
|
}) {
|
||||||
const { schoolId, userIds, type, title, message, actionUrl, relatedId } = params;
|
const { schoolId, userIds, type, title, message, actionUrl, relatedId } = params;
|
||||||
|
|
||||||
return db.notification.createMany({
|
const result = await db.notification.createMany({
|
||||||
data: userIds.map((userId) => ({
|
data: userIds.map((userId) => ({
|
||||||
schoolId,
|
schoolId,
|
||||||
userId,
|
userId,
|
||||||
@@ -66,6 +97,13 @@ export async function createNotificationForUsers(params: {
|
|||||||
relatedId: relatedId ?? null,
|
relatedId: relatedId ?? null,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Push to WebSocket for each user (non-blocking)
|
||||||
|
for (const userId of userIds) {
|
||||||
|
pushNotificationToWS({ userId, type, title, message, actionUrl, relatedId }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
// CompetenceTrack — WebSocket Hook
|
||||||
|
// Real-time collaboration and push notifications via Socket.IO
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import { useAppStore } from '@/lib/store';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CursorData {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
pageId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditData {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
pageId: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceUser {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
pageId: string;
|
||||||
|
color: string;
|
||||||
|
cursor: CursorData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationPush {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
actionUrl?: string;
|
||||||
|
relatedId?: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityEvent {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
pageId: string;
|
||||||
|
pageTitle: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Singleton Socket Manager ────────────────────────────────────────
|
||||||
|
|
||||||
|
let socketInstance: Socket | null = null;
|
||||||
|
let socketConnected = false;
|
||||||
|
|
||||||
|
function getSocket(): Socket | null {
|
||||||
|
return socketInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectSocket(userId: string, userName: string, schoolId: string, role: string): Socket {
|
||||||
|
if (socketInstance && socketConnected) {
|
||||||
|
return socketInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect existing if any
|
||||||
|
if (socketInstance) {
|
||||||
|
socketInstance.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = io('/?XTransformPort=3003', {
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionAttempts: 10,
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
console.log('[CT-WS] Connected to WebSocket service');
|
||||||
|
socketConnected = true;
|
||||||
|
// Authenticate
|
||||||
|
socket.emit('auth', { userId, userName, schoolId, role });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('[CT-WS] Disconnected from WebSocket service');
|
||||||
|
socketConnected = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect_error', (err) => {
|
||||||
|
console.warn('[CT-WS] Connection error:', err.message);
|
||||||
|
socketConnected = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
socketInstance = socket;
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectSocket() {
|
||||||
|
if (socketInstance) {
|
||||||
|
socketInstance.disconnect();
|
||||||
|
socketInstance = null;
|
||||||
|
socketConnected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hook: useWebSocket ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useWebSocket() {
|
||||||
|
const currentUser = useAppStore((s) => s.currentUser);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser) {
|
||||||
|
disconnectSocket();
|
||||||
|
setConnected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = connectSocket(
|
||||||
|
currentUser.id,
|
||||||
|
`${currentUser.firstName} ${currentUser.lastName}`,
|
||||||
|
currentUser.schoolId ?? '',
|
||||||
|
currentUser.role
|
||||||
|
);
|
||||||
|
|
||||||
|
const onConnect = () => setConnected(true);
|
||||||
|
const onDisconnect = () => setConnected(false);
|
||||||
|
|
||||||
|
socket.on('connect', onConnect);
|
||||||
|
socket.on('disconnect', onDisconnect);
|
||||||
|
|
||||||
|
// If already connected
|
||||||
|
if (socket.connected) {
|
||||||
|
setConnected(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('connect', onConnect);
|
||||||
|
socket.off('disconnect', onDisconnect);
|
||||||
|
};
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
|
return { connected, socket: getSocket() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hook: useNotebookCollaboration ──────────────────────────────────
|
||||||
|
|
||||||
|
export function useNotebookCollaboration(notebookId: string | null) {
|
||||||
|
const currentUser = useAppStore((s) => s.currentUser);
|
||||||
|
const [onlineUsers, setOnlineUsers] = useState<PresenceUser[]>([]);
|
||||||
|
const [cursors, setCursors] = useState<CursorData[]>([]);
|
||||||
|
const [activities, setActivities] = useState<ActivityEvent[]>([]);
|
||||||
|
const [lastEdit, setLastEdit] = useState<EditData | null>(null);
|
||||||
|
const [editingUsers, setEditingUsers] = useState<Map<string, { userName: string; pageId: string; timestamp: number }>>(new Map());
|
||||||
|
|
||||||
|
const activityListenersRef = useRef<((event: ActivityEvent) => void)[]>([]);
|
||||||
|
const editListenersRef = useRef<((edit: EditData) => void)[]>([]);
|
||||||
|
|
||||||
|
// Join/Leave notebook room
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = getSocket();
|
||||||
|
if (!socket || !notebookId || !currentUser) return;
|
||||||
|
|
||||||
|
// Join notebook room
|
||||||
|
socket.emit('notebook:join', { notebookId });
|
||||||
|
|
||||||
|
// Listen for presence updates
|
||||||
|
const handlePresenceUpdate = (data: { notebookId: string; users: PresenceUser[] }) => {
|
||||||
|
if (data.notebookId === notebookId) {
|
||||||
|
setOnlineUsers(data.users.filter((u) => u.userId !== currentUser.id));
|
||||||
|
// Update cursors
|
||||||
|
setCursors(data.users.filter((u) => u.cursor && u.userId !== currentUser.id).map((u) => u.cursor!));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for cursor updates
|
||||||
|
const handleCursor = (cursor: CursorData) => {
|
||||||
|
if (cursor.userId !== currentUser.id) {
|
||||||
|
setCursors((prev) => {
|
||||||
|
const filtered = prev.filter((c) => c.userId !== cursor.userId);
|
||||||
|
return [...filtered, cursor];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for edits
|
||||||
|
const handleEdit = (edit: EditData) => {
|
||||||
|
if (edit.userId !== currentUser.id) {
|
||||||
|
setLastEdit(edit);
|
||||||
|
setEditingUsers((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(edit.userId, { userName: edit.userName, pageId: edit.pageId, timestamp: edit.timestamp });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Notify listeners
|
||||||
|
editListenersRef.current.forEach((fn) => fn(edit));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for activity events
|
||||||
|
const handleActivity = (event: ActivityEvent) => {
|
||||||
|
if (event.userId !== currentUser.id) {
|
||||||
|
setActivities((prev) => [event, ...prev].slice(0, 20));
|
||||||
|
activityListenersRef.current.forEach((fn) => fn(event));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('presence:update', handlePresenceUpdate);
|
||||||
|
socket.on('notebook:cursor', handleCursor);
|
||||||
|
socket.on('notebook:edit', handleEdit);
|
||||||
|
socket.on('activity:join', handleActivity);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Leave notebook room
|
||||||
|
socket.emit('notebook:leave', { notebookId });
|
||||||
|
socket.off('presence:update', handlePresenceUpdate);
|
||||||
|
socket.off('notebook:cursor', handleCursor);
|
||||||
|
socket.off('notebook:edit', handleEdit);
|
||||||
|
socket.off('activity:join', handleActivity);
|
||||||
|
setOnlineUsers([]);
|
||||||
|
setCursors([]);
|
||||||
|
setActivities([]);
|
||||||
|
setLastEdit(null);
|
||||||
|
setEditingUsers(new Map());
|
||||||
|
};
|
||||||
|
}, [notebookId, currentUser]);
|
||||||
|
|
||||||
|
// Broadcast cursor position
|
||||||
|
const broadcastCursor = useCallback((pageId: string, x: number, y: number) => {
|
||||||
|
const socket = getSocket();
|
||||||
|
if (!socket || !notebookId) return;
|
||||||
|
socket.emit('notebook:cursor', { notebookId, pageId, x, y });
|
||||||
|
}, [notebookId]);
|
||||||
|
|
||||||
|
// Broadcast edit
|
||||||
|
const broadcastEdit = useCallback((pageId: string, content: string) => {
|
||||||
|
const socket = getSocket();
|
||||||
|
if (!socket || !notebookId) return;
|
||||||
|
socket.emit('notebook:edit', { notebookId, pageId, content });
|
||||||
|
}, [notebookId]);
|
||||||
|
|
||||||
|
// Broadcast activity (user started editing a page)
|
||||||
|
const broadcastActivity = useCallback((pageId: string, pageTitle: string) => {
|
||||||
|
const socket = getSocket();
|
||||||
|
if (!socket || !notebookId) return;
|
||||||
|
socket.emit('activity:join', { notebookId, pageId, pageTitle });
|
||||||
|
}, [notebookId]);
|
||||||
|
|
||||||
|
// Add edit listener
|
||||||
|
const addEditListener = useCallback((fn: (edit: EditData) => void) => {
|
||||||
|
editListenersRef.current.push(fn);
|
||||||
|
return () => {
|
||||||
|
editListenersRef.current = editListenersRef.current.filter((f) => f !== fn);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Add activity listener
|
||||||
|
const addActivityListener = useCallback((fn: (event: ActivityEvent) => void) => {
|
||||||
|
activityListenersRef.current.push(fn);
|
||||||
|
return () => {
|
||||||
|
activityListenersRef.current = activityListenersRef.current.filter((f) => f !== fn);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
onlineUsers,
|
||||||
|
cursors,
|
||||||
|
activities,
|
||||||
|
lastEdit,
|
||||||
|
editingUsers,
|
||||||
|
broadcastCursor,
|
||||||
|
broadcastEdit,
|
||||||
|
broadcastActivity,
|
||||||
|
addEditListener,
|
||||||
|
addActivityListener,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hook: usePushNotifications ──────────────────────────────────────
|
||||||
|
|
||||||
|
export function usePushNotifications() {
|
||||||
|
const currentUser = useAppStore((s) => s.currentUser);
|
||||||
|
const [notification, setNotification] = useState<NotificationPush | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = getSocket();
|
||||||
|
if (!socket || !currentUser) return;
|
||||||
|
|
||||||
|
const handleNewNotification = (data: NotificationPush) => {
|
||||||
|
setNotification(data);
|
||||||
|
// Auto-clear after 5 seconds
|
||||||
|
setTimeout(() => setNotification(null), 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNotificationRead = (data: { notificationId: string; userId: string }) => {
|
||||||
|
// Could be used to update UI in real-time
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('notification:new', handleNewNotification);
|
||||||
|
socket.on('notification:read', handleNotificationRead);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('notification:new', handleNewNotification);
|
||||||
|
socket.off('notification:read', handleNotificationRead);
|
||||||
|
};
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
|
return { notification };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Notification Sound Helper ───────────────────────────────────────
|
||||||
|
|
||||||
|
let audioContext: AudioContext | null = null;
|
||||||
|
|
||||||
|
export function playNotificationSound() {
|
||||||
|
try {
|
||||||
|
if (!audioContext) {
|
||||||
|
audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
||||||
|
}
|
||||||
|
const ctx = audioContext;
|
||||||
|
const oscillator = ctx.createOscillator();
|
||||||
|
const gainNode = ctx.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(ctx.destination);
|
||||||
|
|
||||||
|
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
|
||||||
|
oscillator.frequency.setValueAtTime(1100, ctx.currentTime + 0.1);
|
||||||
|
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.2);
|
||||||
|
|
||||||
|
gainNode.gain.setValueAtTime(0.15, ctx.currentTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||||||
|
|
||||||
|
oscillator.start(ctx.currentTime);
|
||||||
|
oscillator.stop(ctx.currentTime + 0.3);
|
||||||
|
} catch {
|
||||||
|
// Audio not supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Notification Sound Preference ───────────────────────────────────
|
||||||
|
|
||||||
|
const NOTIF_SOUND_KEY = 'ct_notification_sound';
|
||||||
|
|
||||||
|
export function getNotificationSoundPref(): boolean {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(NOTIF_SOUND_KEY);
|
||||||
|
if (stored !== null) return stored === 'true';
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return true; // default on
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setNotificationSoundPref(enabled: boolean) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(NOTIF_SOUND_KEY, String(enabled));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
+237
-2
@@ -1,8 +1,145 @@
|
|||||||
# CompetenceTrack — Project Worklog
|
# CompetenceTrack — Project Worklog
|
||||||
|
|
||||||
## Current Project Status (Round 11 — DrawingCanvas, Versioning, Student Auth, Parent Portal, Calendar, Reports, Import/Export)
|
## Current Project Status (Round 13 — Rubric Enhancement + Curriculum Mapping + Attendance Analytics)
|
||||||
|
|
||||||
**Status**: Stable, all features working, pushed to GitHub
|
**Status**: Stable, all features working
|
||||||
|
**Version**: v13
|
||||||
|
|
||||||
|
### Completed Modifications (Round 13)
|
||||||
|
|
||||||
|
1. **Rubric Enhancement**
|
||||||
|
- Rubric Templates: 5 pre-built templates (Math Assessment, German Essay, English Writing, Project Work, Oral Presentation) with full criteria and levels
|
||||||
|
- Template dialog accessible from "Templates" button in header — select template to pre-populate create form
|
||||||
|
- Rubric-Based Grading: Score sheet dialog with level selectors per criterion, auto-grade calculation
|
||||||
|
- Auto-Grade Calculation: Weighted percentage, final score, grade (1-6 German system), criterion breakdown with progress bars
|
||||||
|
- Rubric Analytics: Stats cards, criteria performance bars, grade distribution visualization
|
||||||
|
- New API: GET /api/rubrics/templates, POST /api/rubrics/[id]/grade, GET /api/rubrics/[id]/analytics
|
||||||
|
- New API client functions: fetchRubricTemplates, gradeWithRubric, fetchRubricAnalytics
|
||||||
|
|
||||||
|
2. **Curriculum Mapping**
|
||||||
|
- New Prisma models: CurriculumStandard (code, title, description, gradeLevel, category, source) and CurriculumStandardLink (coverageLevel, notes)
|
||||||
|
- Relations added to School, Subject, and Competency models
|
||||||
|
- Curriculum Coverage View: 4 stats cards, search/filter, 3 tabs (Standards, Heatmap, Gap Analysis)
|
||||||
|
- Standards tab: CRUD with code, title, subject, grade level, category, source; linked competencies badge
|
||||||
|
- Heatmap tab: coverage visualization by subject/grade with color-coded progress bars
|
||||||
|
- Gap Analysis tab: standards without competency links with quick link button
|
||||||
|
- Link Competency dialog: search/select competency, coverage level (full/partial/related), notes
|
||||||
|
- Detail dialog: full standard view with linked competencies and unlink ability
|
||||||
|
- New API: /api/curriculum-standards (GET/POST), /api/curriculum-standards/[id] (GET/PUT/DELETE), /api/curriculum-standards/[id]/links (GET/POST)
|
||||||
|
- New API client functions: fetchCurriculumStandards, createCurriculumStandard, updateCurriculumStandard, deleteCurriculumStandard, linkCurriculumStandard, unlinkCurriculumStandard, fetchCurriculumStandardLinks
|
||||||
|
|
||||||
|
3. **Attendance Analytics**
|
||||||
|
- New "Analytics" tab in attendance view with BarChart3 icon
|
||||||
|
- AttendanceAnalyticsTab component with Recharts visualizations
|
||||||
|
- Line chart: weekly attendance rate and absence rate trends
|
||||||
|
- Bar chart: day-of-week analysis with attendance rate and absent count
|
||||||
|
- Pie chart: status distribution (present/absent/excused/late) with emerald/rose/amber/teal colors
|
||||||
|
- Chronic Absence card: students with >=10% absence rate
|
||||||
|
- Risk Indicators card: medium/high/critical risk with colored badges
|
||||||
|
- Class Comparison card: attendance rate bars across classes
|
||||||
|
- CSV Export: download attendance analytics data as CSV
|
||||||
|
- New API: GET /api/attendance/analytics?schoolId=xxx&classGroupId=yyy
|
||||||
|
- New API client function: fetchAttendanceAnalytics
|
||||||
|
|
||||||
|
4. **100+ New i18n Keys**
|
||||||
|
- Rubric enhancement: templates, grading, analytics, export, score sheet (20+ keys)
|
||||||
|
- Curriculum standards: CRUD, mapping, heatmap, gap analysis, coverage levels (40+ keys)
|
||||||
|
- Attendance analytics: trends, day-of-week, chronic absence, risk indicators, export (20+ keys)
|
||||||
|
|
||||||
|
5. **2 New Prisma Models**
|
||||||
|
- CurriculumStandard — school curriculum standards with code, grade level, category, source
|
||||||
|
- CurriculumStandardLink — links between standards and competencies with coverage level
|
||||||
|
|
||||||
|
6. **7 New API Route Files**
|
||||||
|
- /api/rubrics/templates/route.ts
|
||||||
|
- /api/rubrics/[id]/grade/route.ts
|
||||||
|
- /api/rubrics/[id]/analytics/route.ts
|
||||||
|
- /api/curriculum-standards/route.ts
|
||||||
|
- /api/curriculum-standards/[id]/route.ts
|
||||||
|
- /api/curriculum-standards/[id]/links/route.ts
|
||||||
|
- /api/attendance/analytics/route.ts
|
||||||
|
|
||||||
|
### Verification Results
|
||||||
|
- App compiles and runs on port 3000
|
||||||
|
- Lint: only set-state-in-effect warnings (pre-existing pattern)
|
||||||
|
- 7 new API route files, 2 new Prisma models, 1 new UI component
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Project Status (Round 12 — WebSocket Service, Collaborative Editing, Push Notifications)
|
||||||
|
|
||||||
|
**Status**: Stable, all features working
|
||||||
|
**Version**: v12
|
||||||
|
|
||||||
|
### Completed Modifications (Round 12)
|
||||||
|
|
||||||
|
1. **WebSocket Mini-Service**
|
||||||
|
- New mini-service at `mini-services/ws-service/` with Socket.IO server on port 3003
|
||||||
|
- Handles: auth, notebook:join/leave, notebook:cursor, notebook:edit, notification:new/read, presence:update, activity:join
|
||||||
|
- HTTP API endpoint at `/api/push-notification` for server-side notification push
|
||||||
|
- Health check endpoint at `/health`
|
||||||
|
- Frontend connects via `io("/?XTransformPort=3003")`
|
||||||
|
|
||||||
|
2. **Collaborative Editing for Notebook Pages**
|
||||||
|
- `useNotebookCollaboration` hook in `src/lib/websocket.ts` — manages room presence, cursors, edits, activities
|
||||||
|
- Online Users Indicator in notebook header (colored avatar dots with Radio icon, tooltip with names)
|
||||||
|
- Activity Feed Popover (shows "User X started editing page Y" entries)
|
||||||
|
- Cursor overlays on page content area (colored MousePointer2 cursors with user names, positioned by percentage)
|
||||||
|
- "Editing by..." indicator in page title bar (amber badge with MousePointer2 icon)
|
||||||
|
- Edit broadcasting (debounced 500ms) and incoming edit application (last-write-wins)
|
||||||
|
- Cursor position broadcasting (throttled 200ms)
|
||||||
|
- Page change activity broadcasting
|
||||||
|
|
||||||
|
3. **Real-Time Push Notifications**
|
||||||
|
- `useWebSocket` hook connects to ws-service on login
|
||||||
|
- `usePushNotifications` hook listens for `notification:new` events
|
||||||
|
- On push notification: plays notification sound (if enabled), shows toast, refreshes bell count
|
||||||
|
- `notifications.ts` updated: `createNotification()` and `createNotificationForUsers()` also push via HTTP to ws-service (fire-and-forget)
|
||||||
|
- Server-side push: when notebook shared, behavior alert, grade computed — all push notifications to ws-service
|
||||||
|
|
||||||
|
4. **Notification Sound Setting**
|
||||||
|
- `NotificationSoundSetting` component in settings-view.tsx
|
||||||
|
- Toggle switch with Volume2/VolumeX icons
|
||||||
|
- Web Audio API beep (oscillator, 880→1100→880 Hz, 0.3s duration)
|
||||||
|
- Preference stored in localStorage (`ct_notification_sound`)
|
||||||
|
- Plays preview sound when enabling
|
||||||
|
|
||||||
|
5. **22 New i18n Keys**
|
||||||
|
- Collaboration keys (10 DE + 10 EN): online_users, editing_by, activity_title, no_activity, started_editing, realtime_connected, realtime_disconnected, conflict_resolved, cursors_visible, user_joined, user_left
|
||||||
|
- Notification sound keys (4 DE + 4 EN): notification_sound, notification_sound_desc, notification_sound_enabled, notification_sound_disabled
|
||||||
|
|
||||||
|
6. **Bug Fixes**
|
||||||
|
- Fixed pre-existing parsing error in settings-view.tsx (stray `)}` after backup tab)
|
||||||
|
- Fixed pre-existing TypeScript error in api.ts (`downloadCsvExport` type missing `'attendance'`)
|
||||||
|
|
||||||
|
### New Files Created
|
||||||
|
- `mini-services/ws-service/package.json`
|
||||||
|
- `mini-services/ws-service/tsconfig.json`
|
||||||
|
- `mini-services/ws-service/index.ts`
|
||||||
|
- `src/lib/websocket.ts`
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `src/components/notebooks-view.tsx` — Collaborative editing UI elements, cursor overlays, activity feed, edit broadcasting
|
||||||
|
- `src/components/app-layout.tsx` — Push notification handler, WebSocket connection, notification sound
|
||||||
|
- `src/components/settings-view.tsx` — Notification sound setting component, bug fixes
|
||||||
|
- `src/lib/notifications.ts` — WebSocket push integration via HTTP API
|
||||||
|
- `src/lib/i18n.ts` — 22 new i18n keys (DE + EN)
|
||||||
|
- `src/lib/api.ts` — Extended downloadCsvExport type
|
||||||
|
- `package.json` — Added socket.io-client dependency
|
||||||
|
|
||||||
|
### Verification Results
|
||||||
|
- Dev server: Running on port 3000
|
||||||
|
- WS service: Running on port 3003, health check returns OK
|
||||||
|
- Push notification API: Tested successfully (returns `{success: true}`)
|
||||||
|
- App compiles and loads correctly
|
||||||
|
- TypeScript: 0 errors in modified files (pre-existing errors in other files unchanged)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Previous Rounds
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Round 11 — DrawingCanvas, Versioning, Student Auth, Parent Portal, Calendar, Reports, Import/Export</summary>
|
||||||
**Version**: v11 (c3d36aa)
|
**Version**: v11 (c3d36aa)
|
||||||
**Push Date**: 2025-07-29
|
**Push Date**: 2025-07-29
|
||||||
**Repo**: https://github.com/cetinmustafa83/exametra
|
**Repo**: https://github.com/cetinmustafa83/exametra
|
||||||
@@ -175,3 +312,101 @@
|
|||||||
|
|
||||||
See git history for details on earlier rounds.
|
See git history for details on earlier rounds.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10 — Mobile Responsiveness + Performance + Data Backup + Styling Polish
|
||||||
|
|
||||||
|
**Agent**: mobile-perf-backup-styling
|
||||||
|
**Date**: 2025-07-29
|
||||||
|
|
||||||
|
### Changes Made
|
||||||
|
|
||||||
|
1. **Mobile Responsiveness (globals.css)**
|
||||||
|
- Added safe area insets for iOS (`.safe-top`, `.safe-bottom`, `.safe-all`)
|
||||||
|
- Added canvas no-zoom and no-select utilities (`.canvas-no-zoom`, `.canvas-no-select`)
|
||||||
|
- Added mobile scroll container (`.mobile-scroll-x`)
|
||||||
|
- Added mobile stack helper (`.mobile-stack`) and full-width inputs (`.mobile-full-width`)
|
||||||
|
- Added mobile viewport adjustments: dialog/popover sizing, tab scrollability, card stacking, notebook grid (1 col mobile, 2 col tablet, 3+ desktop), table horizontal scroll, full-width inputs
|
||||||
|
- Added tablet-specific adjustments (640px-767px)
|
||||||
|
|
||||||
|
2. **Drawing Canvas Performance Optimization**
|
||||||
|
- Implemented requestAnimationFrame-based drawing with `scheduleRedraw()`
|
||||||
|
- Added Ramer-Douglas-Peucker point simplification (tolerance=2px) for freehand strokes
|
||||||
|
- Implemented background layer caching (offscreen canvas for bg + guides)
|
||||||
|
- Added debounced auto-save (5s after last stroke) + periodic backup (30s)
|
||||||
|
- Added FPS counter (toggle with Activity icon in toolbar)
|
||||||
|
- Added GPU acceleration class (`.canvas-gpu`) with `will-change: transform`
|
||||||
|
- Fixed initial data loading to use `useState` initializer (no effect needed)
|
||||||
|
|
||||||
|
3. **Drawing Canvas Mobile Improvements**
|
||||||
|
- Added `isMobile` detection (640px breakpoint)
|
||||||
|
- Use Drawer (bottom sheet) on mobile instead of Dialog for clear/save
|
||||||
|
- Added `min-touch` (44px) targets to all toolbar buttons
|
||||||
|
- Added ARIA labels and `aria-pressed` to all interactive elements
|
||||||
|
- Responsive toolbar spacing (`gap-1 sm:gap-2`, `px-2 sm:px-3`)
|
||||||
|
- Added `canvas-no-zoom` and `canvas-no-select` to canvas element
|
||||||
|
- Responsive slider width (`w-16 sm:w-24`)
|
||||||
|
|
||||||
|
4. **Data Backup System**
|
||||||
|
- New Prisma model: `Backup` (id, schoolId, filename, size, type, status, notes, createdAt)
|
||||||
|
- Added `backups` relation to School model
|
||||||
|
- Created `/api/backup/route.ts` API:
|
||||||
|
- GET: List backups for a school
|
||||||
|
- POST: Create full backup (exports entire DB as JSON) or restore from backup
|
||||||
|
- DELETE: Delete a backup
|
||||||
|
- Added Backup tab in SettingsView:
|
||||||
|
- Create backup button with loading spinner
|
||||||
|
- Auto-backup toggle (daily/weekly frequency selector)
|
||||||
|
- Last backup timestamp display
|
||||||
|
- Backup list with filename, date, size, status, type badges
|
||||||
|
- Download, restore, delete actions per backup
|
||||||
|
- Restore and delete confirmation dialogs (AlertDialog)
|
||||||
|
|
||||||
|
5. **Micro-interaction Animations (globals.css)**
|
||||||
|
- `.animate-fade-in-up` — fade in + slide up
|
||||||
|
- `.animate-scale-in` — scale from 0.95 to 1
|
||||||
|
- `.animate-shake` — error shake animation
|
||||||
|
- `.animate-success-check` — success checkmark animation
|
||||||
|
- `.hover-lift` — subtle lift on hover (translateY -1px)
|
||||||
|
- `.focus-ring` — animated focus ring
|
||||||
|
|
||||||
|
6. **Enhanced Print Styles**
|
||||||
|
- A4 page size with proper margins
|
||||||
|
- Print-specific font sizes (h1: 18pt, h2: 14pt, h3: 12pt, body: 10pt)
|
||||||
|
- Hide interactive elements (buttons, nav, aside, etc.)
|
||||||
|
- Page break controls (`.page-break-before`, `.page-break-after`, `.page-break-avoid`)
|
||||||
|
- Print links with URL display
|
||||||
|
- Table styling for print
|
||||||
|
|
||||||
|
7. **Dark Mode Polish**
|
||||||
|
- Better dark mode borders (`.dark-border`, `.card-dark-border`)
|
||||||
|
- Dark mode card shadows (`.dark-shadow`, `.elevated-dark`)
|
||||||
|
- Smooth theme transitions (`.theme-transition`)
|
||||||
|
- Better contrast ratios for muted text
|
||||||
|
|
||||||
|
8. **Accessibility Improvements**
|
||||||
|
- Skip-to-content link (`.skip-to-content`)
|
||||||
|
- Screen reader only focusable (`.sr-only-focusable`)
|
||||||
|
- Modal focus wrapper (`.modal-focus-wrapper`)
|
||||||
|
- Live region for dynamic content (`.sr-live-region`)
|
||||||
|
- High contrast mode support (forced-colors)
|
||||||
|
- Reduced motion support for all animations
|
||||||
|
|
||||||
|
9. **i18n Keys Added (35+ new keys in DE and EN)**
|
||||||
|
- Backup: title, create, restore, delete, auto, daily, weekly, last, size, status, completed, failed, pending, no_backups, create_confirm, restore_confirm, delete_confirm, creating, restoring, type, type_full, type_incremental, notes, download, created_success, restored_success, deleted_success, error_create, error_restore, error_delete
|
||||||
|
- Performance: title, fps
|
||||||
|
- Mobile: optimized
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `src/app/globals.css` — 425+ lines added (mobile, print, dark mode, a11y, canvas perf, animations)
|
||||||
|
- `src/components/drawing-canvas.tsx` — RAF, point simplification, layer caching, debounced save, FPS counter, mobile drawer, ARIA labels
|
||||||
|
- `src/components/settings-view.tsx` — Backup tab, backup state, handlers, restore/delete confirmation dialogs
|
||||||
|
- `src/lib/i18n.ts` — 35+ new keys in DE and EN
|
||||||
|
- `prisma/schema.prisma` — Backup model + School relation
|
||||||
|
- `src/app/api/backup/route.ts` — New file (backup CRUD API)
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- App running on localhost:3000 (200 OK)
|
||||||
|
- Backup API responding correctly
|
||||||
|
- Prisma schema pushed successfully
|
||||||
Reference in new issue
Block a user