- Report Card Generator: Professional report cards with PDF export - Subject grades table with competency levels - Competency radar chart, teacher comments - Template management, batch generation - Status workflow: draft → review → published → archived - Seating Chart View: Visual classroom layout - Grid-based classroom with desk positions - Multiple layout templates (rows, groups, U-shape, circle) - Click-to-assign student placement - Randomize and smart arrange features - Student info popover - School Library View: Book lending system - Book catalog with search and filter - Checkout system with due dates and renewals - Overdue tracking with notifications - Reservation queue system - Statistics dashboard with charts - Enhanced Parent Portal: Comprehensive dashboard - Child overview with multiple children selector - Academic progress with competency radar chart - Attendance & illness with approval - Communication, schedule, and reports tabs - Bug fixes: i18n breadcrumb keys for new views - Styling: Gradient headers, animations, hover effects across all new views - 200+ i18n keys added, 15+ API routes, 3+ Prisma models
375 lines
12 KiB
TypeScript
Executable File
375 lines
12 KiB
TypeScript
Executable File
import { NextResponse } from 'next/server';
|
|
import { db } from '@/lib/db';
|
|
import { getSession } from '@/lib/auth';
|
|
|
|
/* ── helpers ──────────────────────────────────────────────────────── */
|
|
|
|
type EventType = 'assessment' | 'attendance' | 'progress' | 'report' | 'lesson';
|
|
|
|
interface CalendarEvent {
|
|
date: string; // YYYY-MM-DD
|
|
type: EventType;
|
|
id: string;
|
|
title: string;
|
|
meta: Record<string, unknown>;
|
|
}
|
|
|
|
/** Format a Date (or ISO string) as YYYY-MM-DD in UTC to avoid TZ drift. */
|
|
function toDayKey(value: Date | string): string {
|
|
const d = typeof value === 'string' ? new Date(value) : value;
|
|
if (Number.isNaN(d.getTime())) return '';
|
|
const y = d.getUTCFullYear();
|
|
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
return `${y}-${m}-${day}`;
|
|
}
|
|
|
|
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 schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
|
|
const monthParam = searchParams.get('month'); // YYYY-MM
|
|
|
|
if (!schoolId) {
|
|
return NextResponse.json(
|
|
{ error: 'schoolId is required (pass as query param or have a school assigned to user)' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Parse month, default to current month
|
|
let year: number;
|
|
let monthIndex: number; // 0-based
|
|
if (monthParam && /^\d{4}-\d{2}$/.test(monthParam)) {
|
|
const [yStr, mStr] = monthParam.split('-');
|
|
year = parseInt(yStr, 10);
|
|
monthIndex = parseInt(mStr, 10) - 1;
|
|
} else {
|
|
const now = new Date();
|
|
year = now.getUTCFullYear();
|
|
monthIndex = now.getUTCMonth();
|
|
}
|
|
|
|
// Build a UTC-based month range so it's timezone-stable
|
|
const startOfMonth = new Date(Date.UTC(year, monthIndex, 1, 0, 0, 0, 0));
|
|
const endOfMonth = new Date(Date.UTC(year, monthIndex + 1, 0, 23, 59, 59, 999));
|
|
|
|
const dateRange = { gte: startOfMonth, lte: endOfMonth };
|
|
|
|
const events: CalendarEvent[] = [];
|
|
|
|
/* ── Assessments ──────────────────────────────────────────────── */
|
|
try {
|
|
const assessments = await db.assessment.findMany({
|
|
where: {
|
|
date: dateRange,
|
|
classGroup: { schoolId },
|
|
},
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
date: true,
|
|
type: true,
|
|
maxScore: true,
|
|
weight: true,
|
|
classGroup: { select: { id: true, name: true } },
|
|
subject: { select: { id: true, name: true } },
|
|
},
|
|
orderBy: { date: 'asc' },
|
|
});
|
|
|
|
for (const a of assessments) {
|
|
events.push({
|
|
date: toDayKey(a.date),
|
|
type: 'assessment',
|
|
id: a.id,
|
|
title: a.title,
|
|
meta: {
|
|
assessmentType: a.type,
|
|
classGroup: a.classGroup?.name ?? null,
|
|
subject: a.subject?.name ?? null,
|
|
maxScore: a.maxScore ?? null,
|
|
weight: a.weight,
|
|
},
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('Calendar: assessments query failed:', err);
|
|
}
|
|
|
|
/* ── Attendance Sessions ──────────────────────────────────────── */
|
|
try {
|
|
const sessions = await db.attendanceSession.findMany({
|
|
where: {
|
|
date: dateRange,
|
|
classGroup: { schoolId },
|
|
},
|
|
select: {
|
|
id: true,
|
|
date: true,
|
|
status: true,
|
|
period: true,
|
|
classGroup: { select: { id: true, name: true } },
|
|
subject: { select: { id: true, name: true } },
|
|
records: {
|
|
select: { status: true },
|
|
},
|
|
},
|
|
orderBy: { date: 'asc' },
|
|
});
|
|
|
|
for (const s of sessions) {
|
|
const counts: Record<string, number> = {
|
|
PRESENT: 0,
|
|
ABSENT: 0,
|
|
EXCUSED: 0,
|
|
LATE: 0,
|
|
};
|
|
for (const r of s.records) {
|
|
counts[r.status] = (counts[r.status] ?? 0) + 1;
|
|
}
|
|
const total = s.records.length;
|
|
const present = counts.PRESENT ?? 0;
|
|
const rate = total > 0 ? Math.round((present / total) * 100) : null;
|
|
|
|
events.push({
|
|
date: toDayKey(s.date),
|
|
type: 'attendance',
|
|
id: s.id,
|
|
title: `${s.classGroup?.name ?? ''} ${t_attendance(s)}`.trim(),
|
|
meta: {
|
|
classGroup: s.classGroup?.name ?? null,
|
|
subject: s.subject?.name ?? null,
|
|
period: s.period ?? null,
|
|
sessionStatus: s.status,
|
|
counts,
|
|
total,
|
|
present,
|
|
rate,
|
|
},
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('Calendar: attendance query failed:', err);
|
|
}
|
|
|
|
/* ── Learning Progress Entries (aggregated by day) ───────────── */
|
|
try {
|
|
const progressEntries = await db.learningProgressEntry.findMany({
|
|
where: {
|
|
createdAt: dateRange,
|
|
classGroup: { schoolId },
|
|
},
|
|
select: {
|
|
id: true,
|
|
createdAt: true,
|
|
masteryLevelValue: true,
|
|
classGroup: { select: { id: true, name: true } },
|
|
},
|
|
orderBy: { createdAt: 'asc' },
|
|
});
|
|
|
|
// Aggregate by day + class group for nicer chips
|
|
const progressByDay = new Map<string, { count: number; classGroups: Set<string> }>();
|
|
for (const e of progressEntries) {
|
|
const key = toDayKey(e.createdAt);
|
|
if (!key) continue;
|
|
const slot = progressByDay.get(key) ?? { count: 0, classGroups: new Set<string>() };
|
|
slot.count += 1;
|
|
if (e.classGroup?.name) slot.classGroups.add(e.classGroup.name);
|
|
progressByDay.set(key, slot);
|
|
}
|
|
|
|
for (const [key, slot] of progressByDay.entries()) {
|
|
events.push({
|
|
date: key,
|
|
type: 'progress',
|
|
id: `progress-${key}`,
|
|
title: `${slot.count} ${slot.count === 1 ? 'Eintrag' : 'Einträge'}`,
|
|
meta: {
|
|
count: slot.count,
|
|
classGroups: Array.from(slot.classGroups),
|
|
},
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('Calendar: progress query failed:', err);
|
|
}
|
|
|
|
/* ── Reports ──────────────────────────────────────────────────── */
|
|
try {
|
|
const reports = await db.report.findMany({
|
|
where: {
|
|
generatedAt: dateRange,
|
|
classGroup: { schoolId },
|
|
},
|
|
select: {
|
|
id: true,
|
|
generatedAt: true,
|
|
period: true,
|
|
status: true,
|
|
includesGrades: true,
|
|
student: { select: { id: true, firstName: true, lastName: true } },
|
|
classGroup: { select: { id: true, name: true } },
|
|
},
|
|
orderBy: { generatedAt: 'asc' },
|
|
});
|
|
|
|
for (const r of reports) {
|
|
const studentName = r.student
|
|
? `${r.student.firstName} ${r.student.lastName}`
|
|
: '—';
|
|
events.push({
|
|
date: toDayKey(r.generatedAt),
|
|
type: 'report',
|
|
id: r.id,
|
|
title: `${studentName} · ${r.period}`,
|
|
meta: {
|
|
studentName,
|
|
classGroup: r.classGroup?.name ?? null,
|
|
period: r.period,
|
|
status: r.status,
|
|
includesGrades: r.includesGrades,
|
|
},
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('Calendar: reports query failed:', err);
|
|
}
|
|
|
|
/* ── Lessons (LessonPlan) — try/catch, model may not exist yet ─ */
|
|
try {
|
|
// The LessonPlan model is added by another agent concurrently.
|
|
// Use runtime access so this route still compiles & runs even if the
|
|
// Prisma Client hasn't been regenerated yet.
|
|
const lessonPlanClient = (db as unknown as {
|
|
lessonPlan?: {
|
|
findMany: (args: Record<string, unknown>) => Promise<Array<Record<string, unknown>>>;
|
|
};
|
|
}).lessonPlan;
|
|
|
|
if (lessonPlanClient && typeof lessonPlanClient.findMany === 'function') {
|
|
const lessons = await lessonPlanClient.findMany({
|
|
where: {
|
|
date: dateRange,
|
|
classGroup: { schoolId },
|
|
},
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
date: true,
|
|
status: true,
|
|
classGroup: { select: { id: true, name: true } },
|
|
subject: { select: { id: true, name: true } },
|
|
},
|
|
orderBy: { date: 'asc' },
|
|
});
|
|
|
|
for (const l of lessons) {
|
|
const dateVal = l.date as Date | string;
|
|
const classGroupName =
|
|
(l.classGroup as { name?: string } | null | undefined)?.name ?? null;
|
|
const subjectName =
|
|
(l.subject as { name?: string } | null | undefined)?.name ?? null;
|
|
events.push({
|
|
date: toDayKey(dateVal),
|
|
type: 'lesson',
|
|
id: String(l.id),
|
|
title: String(l.title ?? 'Unterricht'),
|
|
meta: {
|
|
classGroup: classGroupName,
|
|
subject: subjectName,
|
|
status: l.status ?? null,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// LessonPlan model doesn't exist yet — gracefully return empty.
|
|
console.info('Calendar: LessonPlan not available yet, skipping lessons.');
|
|
}
|
|
|
|
/* ── Custom Calendar Events ───────────────────────────────────── */
|
|
try {
|
|
const customEvents = await db.calendarEvent.findMany({
|
|
where: {
|
|
schoolId,
|
|
date: dateRange,
|
|
},
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
date: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
eventType: true,
|
|
allDay: true,
|
|
notes: true,
|
|
subject: { select: { id: true, name: true } },
|
|
classGroup: { select: { id: true, name: true } },
|
|
},
|
|
orderBy: { date: 'asc' },
|
|
});
|
|
|
|
for (const ce of customEvents) {
|
|
// Map custom event types to CalendarEventType
|
|
const mappedType: EventType = ['assessment', 'lesson'].includes(ce.eventType)
|
|
? (ce.eventType as EventType)
|
|
: 'lesson';
|
|
events.push({
|
|
date: toDayKey(ce.date),
|
|
type: mappedType,
|
|
id: ce.id,
|
|
title: ce.title,
|
|
meta: {
|
|
customEvent: true,
|
|
eventType: ce.eventType,
|
|
startTime: ce.startTime ?? null,
|
|
endTime: ce.endTime ?? null,
|
|
allDay: ce.allDay,
|
|
notes: ce.notes ?? null,
|
|
subject: ce.subject?.name ?? null,
|
|
classGroup: ce.classGroup?.name ?? null,
|
|
},
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('Calendar: custom events query failed:', err);
|
|
}
|
|
|
|
// Stable ordering: by date asc, then by type for deterministic display
|
|
const typeOrder: Record<EventType, number> = {
|
|
lesson: 0,
|
|
assessment: 1,
|
|
attendance: 2,
|
|
progress: 3,
|
|
report: 4,
|
|
};
|
|
events.sort((a, b) => {
|
|
if (a.date !== b.date) return a.date.localeCompare(b.date);
|
|
return typeOrder[a.type] - typeOrder[b.type];
|
|
});
|
|
|
|
return NextResponse.json({ events, month: `${year}-${String(monthIndex + 1).padStart(2, '0')}` });
|
|
} catch (error) {
|
|
console.error('Calendar GET error:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Internal server error' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Tiny helper so we don't import i18n on the server. */
|
|
function t_attendance(s: { period?: string | null; subject?: { name?: string } | null }): string {
|
|
const parts: string[] = [];
|
|
if (s.subject?.name) parts.push(s.subject.name);
|
|
if (s.period) parts.push(s.period);
|
|
return parts.length ? `· ${parts.join(' ')}` : 'Anwesenheit';
|
|
}
|