diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ffa771a..49496c0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,6 +39,7 @@ model School { drawings Drawing[] notifications Notification[] calendarEvents CalendarEvent[] + parentStudentLinks ParentStudentLink[] } model User { @@ -75,6 +76,7 @@ model User { drawings Drawing[] notifications Notification[] calendarEvents CalendarEvent[] + parentLinks ParentStudentLink[] } model SchoolYear { @@ -156,6 +158,7 @@ model Student { parentContacts ParentContact[] parentMessages ParentMessage[] behaviorIncidents BehaviorIncident[] + parentStudentLinks ParentStudentLink[] } model Enrollment { @@ -775,11 +778,28 @@ model NotebookPage { updatedAt DateTime @updatedAt notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade) + versions NotebookPageVersion[] @@index([notebookId]) @@index([pageNumber]) } +model NotebookPageVersion { + id String @id @default(cuid()) + pageId String + version Int + textContent String? + drawingData String? + editedBy String? + editSummary String? + createdAt DateTime @default(now()) + + page NotebookPage @relation(fields: [pageId], references: [id], onDelete: Cascade) + + @@index([pageId, version]) + @@map("notebook_page_versions") +} + // ─── Drawing Canvas ──────────────────────────────────────────────── model Drawing { @@ -832,29 +852,54 @@ model Notification { // ─── Calendar Events ────────────────────────────────────────────── model CalendarEvent { - id String @id @default(cuid()) - schoolId String - teacherId String - title String - date DateTime - startTime String? // HH:mm format, null = all day - endTime String? // HH:mm format, null = all day - eventType String @default("reminder") // assessment | lesson | reminder - subjectId String? - classGroupId String? - notes String? - allDay Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + schoolId String + teacherId String + title String + date DateTime + startTime String? // HH:mm format, null = all day + endTime String? // HH:mm format, null = all day + eventType String @default("reminder") // assessment | lesson | reminder + subjectId String? + classGroupId String? + notes String? + allDay Boolean @default(false) + recurrencePattern String? // JSON: { type: "daily|weekly|monthly|yearly", interval: 1, endDate?: string, daysOfWeek?: number[] } + recurrenceEnd DateTime? // When recurrence ends + parentEventId String? // For generated instances of a recurring event + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - school School @relation(fields: [schoolId], references: [id]) - teacher User @relation(fields: [teacherId], references: [id]) - subject Subject? @relation(fields: [subjectId], references: [id]) - classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) + school School @relation(fields: [schoolId], references: [id]) + teacher User @relation(fields: [teacherId], references: [id]) + subject Subject? @relation(fields: [subjectId], references: [id]) + classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) + parentEvent CalendarEvent? @relation("EventRecurrence", fields: [parentEventId], references: [id]) + childEvents CalendarEvent[] @relation("EventRecurrence") @@index([schoolId]) @@index([teacherId]) @@index([date]) @@index([subjectId]) @@index([classGroupId]) + @@index([parentEventId]) +} + +// ─── Parent-Student Link ────────────────────────────────────────────── + +model ParentStudentLink { + id String @id @default(cuid()) + parentId String // User with PARENT role + studentId String // Student record + schoolId String + relationship String? // mother, father, guardian, etc. + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + parent User @relation(fields: [parentId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + school School @relation(fields: [schoolId], references: [id]) + + @@unique([parentId, studentId]) + @@map("parent_student_links") } diff --git a/src/app/api/calendar-events/[id]/route.ts b/src/app/api/calendar-events/[id]/route.ts index f51f6fc..4696ce4 100644 --- a/src/app/api/calendar-events/[id]/route.ts +++ b/src/app/api/calendar-events/[id]/route.ts @@ -1,6 +1,107 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { addDays, addWeeks, addMonths, addYears, getDay } from 'date-fns'; + +interface RecurrencePattern { + type: 'daily' | 'weekly' | 'monthly' | 'yearly'; + interval: number; + endDate?: string; + daysOfWeek?: number[]; +} + +function generateChildEvents( + parentEvent: { + id: string; + schoolId: string; + teacherId: string; + title: string; + date: Date; + startTime: string | null; + endTime: string | null; + eventType: string; + subjectId: string | null; + classGroupId: string | null; + notes: string | null; + allDay: boolean; + }, + pattern: RecurrencePattern, + recurrenceEnd: Date | null +) { + const children: Array<{ + schoolId: string; + teacherId: string; + title: string; + date: Date; + startTime: string | null; + endTime: string | null; + eventType: string; + subjectId: string | null; + classGroupId: string | null; + notes: string | null; + allDay: boolean; + parentEventId: string; + recurrencePattern: string; + }> = []; + + const maxDate = recurrenceEnd ?? new Date(pattern.endDate ?? '2026-12-31'); + const patternJson = JSON.stringify(pattern); + let currentDate = parentEvent.date; + let count = 0; + const maxOccurrences = 365; + + while (count < maxOccurrences) { + if (pattern.type === 'daily') { + currentDate = addDays(currentDate, pattern.interval); + } else if (pattern.type === 'weekly') { + if (pattern.daysOfWeek && pattern.daysOfWeek.length > 0) { + let nextDate = addDays(currentDate, 1); + let found = false; + let weekCounter = 0; + while (!found && weekCounter < 7 * pattern.interval) { + if (pattern.daysOfWeek.includes(getDay(nextDate))) { + currentDate = nextDate; + found = true; + } else { + nextDate = addDays(nextDate, 1); + weekCounter++; + } + } + if (!found) { + currentDate = addWeeks(currentDate, pattern.interval); + } + } else { + currentDate = addWeeks(currentDate, pattern.interval); + } + } else if (pattern.type === 'monthly') { + currentDate = addMonths(currentDate, pattern.interval); + } else if (pattern.type === 'yearly') { + currentDate = addYears(currentDate, pattern.interval); + } + + if (currentDate > maxDate) break; + + children.push({ + schoolId: parentEvent.schoolId, + teacherId: parentEvent.teacherId, + title: parentEvent.title, + date: new Date(currentDate), + startTime: parentEvent.startTime, + endTime: parentEvent.endTime, + eventType: parentEvent.eventType, + subjectId: parentEvent.subjectId, + classGroupId: parentEvent.classGroupId, + notes: parentEvent.notes, + allDay: parentEvent.allDay, + parentEventId: parentEvent.id, + recurrencePattern: patternJson, + }); + + count++; + } + + return children; +} export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { try { @@ -11,13 +112,50 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: const { id } = await params; const body = await request.json(); - const { title, date, startTime, endTime, eventType, subjectId, classGroupId, notes, allDay } = body; + const { + title, date, startTime, endTime, eventType, subjectId, classGroupId, + notes, allDay, recurrencePattern, recurrenceEnd, editMode, + } = body; - const existing = await db.calendarEvent.findUnique({ where: { id } }); + const existing = await db.calendarEvent.findUnique({ + where: { id }, + include: { childEvents: true }, + }); if (!existing) { return NextResponse.json({ error: 'Event not found' }, { status: 404 }); } + // If editing a single instance of a recurring event + if (editMode === 'instance' && existing.parentEventId) { + const event = await db.calendarEvent.update({ + where: { id }, + data: { + title: title ?? existing.title, + date: date ? new Date(date) : existing.date, + startTime: startTime !== undefined ? startTime : existing.startTime, + endTime: endTime !== undefined ? endTime : existing.endTime, + eventType: eventType ?? existing.eventType, + subjectId: subjectId !== undefined ? subjectId : existing.subjectId, + classGroupId: classGroupId !== undefined ? classGroupId : existing.classGroupId, + notes: notes !== undefined ? notes : existing.notes, + allDay: allDay !== undefined ? allDay : existing.allDay, + }, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + }, + }); + return NextResponse.json(event); + } + + // If editing the series (parent event) + let parsedPattern: RecurrencePattern | null = null; + if (recurrencePattern && recurrencePattern.type && recurrencePattern.type !== 'none') { + parsedPattern = recurrencePattern as RecurrencePattern; + } + const parsedRecurrenceEnd = recurrenceEnd ? new Date(recurrenceEnd) : null; + + // Update the parent event const event = await db.calendarEvent.update({ where: { id }, data: { @@ -30,6 +168,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: classGroupId: classGroupId !== undefined ? classGroupId : existing.classGroupId, notes: notes !== undefined ? notes : existing.notes, allDay: allDay !== undefined ? allDay : existing.allDay, + recurrencePattern: parsedPattern ? JSON.stringify(parsedPattern) : null, + recurrenceEnd: parsedRecurrenceEnd, }, include: { subject: { select: { id: true, name: true } }, @@ -37,7 +177,42 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: }, }); - return NextResponse.json(event); + // Delete existing child events and regenerate + if (existing.childEvents.length > 0) { + await db.calendarEvent.deleteMany({ + where: { parentEventId: id }, + }); + } + + // Generate new child events if recurring + let childCount = 0; + if (parsedPattern) { + const childEvents = generateChildEvents( + { + id: event.id, + schoolId: event.schoolId, + teacherId: event.teacherId, + title: event.title, + date: event.date, + startTime: event.startTime, + endTime: event.endTime, + eventType: event.eventType, + subjectId: event.subjectId, + classGroupId: event.classGroupId, + notes: event.notes, + allDay: event.allDay, + }, + parsedPattern, + parsedRecurrenceEnd + ); + + if (childEvents.length > 0) { + await db.calendarEvent.createMany({ data: childEvents }); + childCount = childEvents.length; + } + } + + return NextResponse.json({ ...event, childCount }); } catch (error) { console.error('CalendarEvent PUT error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); @@ -52,12 +227,45 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i } const { id } = await params; + const { searchParams } = new URL(request.url); + const deleteMode = searchParams.get('mode'); // 'series' or 'instance' - const existing = await db.calendarEvent.findUnique({ where: { id } }); + const existing = await db.calendarEvent.findUnique({ + where: { id }, + include: { childEvents: true }, + }); if (!existing) { return NextResponse.json({ error: 'Event not found' }, { status: 404 }); } + // If deleting a single instance, just delete that one + if (deleteMode === 'instance') { + await db.calendarEvent.delete({ where: { id } }); + return NextResponse.json({ success: true }); + } + + // If this is a parent recurring event, delete all children too + if (existing.childEvents.length > 0) { + await db.calendarEvent.deleteMany({ + where: { parentEventId: id }, + }); + } + + // If this is a child event and deleting series, delete parent and all siblings + if (existing.parentEventId && deleteMode === 'series') { + const parent = await db.calendarEvent.findUnique({ + where: { id: existing.parentEventId }, + include: { childEvents: true }, + }); + if (parent) { + await db.calendarEvent.deleteMany({ + where: { parentEventId: parent.id }, + }); + await db.calendarEvent.delete({ where: { id: parent.id } }); + } + return NextResponse.json({ success: true }); + } + await db.calendarEvent.delete({ where: { id } }); return NextResponse.json({ success: true }); } catch (error) { diff --git a/src/app/api/calendar-events/route.ts b/src/app/api/calendar-events/route.ts index 85d38ed..384dd38 100644 --- a/src/app/api/calendar-events/route.ts +++ b/src/app/api/calendar-events/route.ts @@ -1,6 +1,111 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { addDays, addWeeks, addMonths, addYears, format, getDay } from 'date-fns'; + +interface RecurrencePattern { + type: 'daily' | 'weekly' | 'monthly' | 'yearly'; + interval: number; + endDate?: string; + daysOfWeek?: number[]; // 0=Sun, 1=Mon, ..., 6=Sat +} + +function generateChildEvents( + parentEvent: { + id: string; + schoolId: string; + teacherId: string; + title: string; + date: Date; + startTime: string | null; + endTime: string | null; + eventType: string; + subjectId: string | null; + classGroupId: string | null; + notes: string | null; + allDay: boolean; + }, + pattern: RecurrencePattern, + recurrenceEnd: Date | null +) { + const children: Array<{ + schoolId: string; + teacherId: string; + title: string; + date: Date; + startTime: string | null; + endTime: string | null; + eventType: string; + subjectId: string | null; + classGroupId: string | null; + notes: string | null; + allDay: boolean; + parentEventId: string; + recurrencePattern: string; + }> = []; + + const maxDate = recurrenceEnd ?? new Date(pattern.endDate ?? '2026-12-31'); + const patternJson = JSON.stringify(pattern); + let currentDate = parentEvent.date; + + // Generate up to 365 occurrences to prevent infinite loops + let count = 0; + const maxOccurrences = 365; + + while (count < maxOccurrences) { + // Advance to next occurrence + if (pattern.type === 'daily') { + currentDate = addDays(currentDate, pattern.interval); + } else if (pattern.type === 'weekly') { + if (pattern.daysOfWeek && pattern.daysOfWeek.length > 0) { + // Find next day of week within the week + let nextDate = addDays(currentDate, 1); + let found = false; + let weekCounter = 0; + while (!found && weekCounter < 7 * pattern.interval) { + if (pattern.daysOfWeek.includes(getDay(nextDate))) { + currentDate = nextDate; + found = true; + } else { + nextDate = addDays(nextDate, 1); + weekCounter++; + } + } + if (!found) { + currentDate = addWeeks(currentDate, pattern.interval); + } + } else { + currentDate = addWeeks(currentDate, pattern.interval); + } + } else if (pattern.type === 'monthly') { + currentDate = addMonths(currentDate, pattern.interval); + } else if (pattern.type === 'yearly') { + currentDate = addYears(currentDate, pattern.interval); + } + + if (currentDate > maxDate) break; + + children.push({ + schoolId: parentEvent.schoolId, + teacherId: parentEvent.teacherId, + title: parentEvent.title, + date: new Date(currentDate), + startTime: parentEvent.startTime, + endTime: parentEvent.endTime, + eventType: parentEvent.eventType, + subjectId: parentEvent.subjectId, + classGroupId: parentEvent.classGroupId, + notes: parentEvent.notes, + allDay: parentEvent.allDay, + parentEventId: parentEvent.id, + recurrencePattern: patternJson, + }); + + count++; + } + + return children; +} export async function POST(request: Request) { try { @@ -10,12 +115,24 @@ export async function POST(request: Request) { } const body = await request.json(); - const { title, date, startTime, endTime, eventType, subjectId, classGroupId, notes, allDay, schoolId } = body; + const { + title, date, startTime, endTime, eventType, subjectId, classGroupId, + notes, allDay, schoolId, recurrencePattern, recurrenceEnd, + } = body; if (!title || !date || !schoolId) { return NextResponse.json({ error: 'title, date, and schoolId are required' }, { status: 400 }); } + // Parse recurrence pattern + let parsedPattern: RecurrencePattern | null = null; + if (recurrencePattern && recurrencePattern.type && recurrencePattern.type !== 'none') { + parsedPattern = recurrencePattern as RecurrencePattern; + } + + const parsedRecurrenceEnd = recurrenceEnd ? new Date(recurrenceEnd) : null; + + // Create the parent event const event = await db.calendarEvent.create({ data: { schoolId, @@ -29,14 +146,59 @@ export async function POST(request: Request) { classGroupId: classGroupId || null, notes: notes || null, allDay: allDay ?? false, + recurrencePattern: parsedPattern ? JSON.stringify(parsedPattern) : null, + recurrenceEnd: parsedRecurrenceEnd, }, include: { subject: { select: { id: true, name: true } }, classGroup: { select: { id: true, name: true } }, + childEvents: { + select: { id: true, date: true }, + }, }, }); - return NextResponse.json(event, { status: 201 }); + // Generate child events for recurring events + if (parsedPattern) { + const childEvents = generateChildEvents( + { + id: event.id, + schoolId: event.schoolId, + teacherId: event.teacherId, + title: event.title, + date: new Date(date), + startTime: event.startTime, + endTime: event.endTime, + eventType: event.eventType, + subjectId: event.subjectId, + classGroupId: event.classGroupId, + notes: event.notes, + allDay: event.allDay, + }, + parsedPattern, + parsedRecurrenceEnd + ); + + if (childEvents.length > 0) { + await db.calendarEvent.createMany({ data: childEvents }); + } + + // Re-fetch with children + const updatedEvent = await db.calendarEvent.findUnique({ + where: { id: event.id }, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + childEvents: { + select: { id: true, date: true }, + }, + }, + }); + + return NextResponse.json({ ...updatedEvent, childCount: childEvents.length }, { status: 201 }); + } + + return NextResponse.json({ ...event, childCount: 0 }, { status: 201 }); } catch (error) { console.error('CalendarEvent POST error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); @@ -81,6 +243,9 @@ export async function GET(request: Request) { include: { subject: { select: { id: true, name: true } }, classGroup: { select: { id: true, name: true } }, + childEvents: { + select: { id: true, date: true }, + }, }, orderBy: { date: 'asc' }, }); diff --git a/src/app/api/data-export/csv/route.ts b/src/app/api/data-export/csv/route.ts index 9564271..d9a57b2 100644 --- a/src/app/api/data-export/csv/route.ts +++ b/src/app/api/data-export/csv/route.ts @@ -253,6 +253,47 @@ export async function GET(request: Request) { } filename = `grades_export.csv`; + } else if (type === 'attendance') { + // Attendance CSV + const where: Record = {}; + if (classGroupId) where.classGroupId = classGroupId; + + let records = await db.attendanceRecord.findMany({ + where, + orderBy: { date: 'desc' }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + classGroup: { select: { id: true, name: true } }, + }, + }); + + if (schoolId && !classGroupId) { + const classGroups = await db.classGroup.findMany({ + where: { schoolId }, + select: { id: true }, + }); + const classIds = classGroups.map((c) => c.id); + records = records.filter((r) => classIds.includes(r.classGroupId)); + } + + const header = ['ID', 'Date', 'Student First Name', 'Student Last Name', 'Student ID', 'Class', 'Status', 'Arrival Time', 'Note']; + csvContent = header.map(escapeCsvField).join(',') + '\n'; + + for (const r of records) { + csvContent += toCsvRow([ + r.id, + new Date(r.date).toLocaleDateString(), + r.student.firstName, + r.student.lastName, + r.studentId, + r.classGroup.name, + r.status, + r.arrivalTime ?? '', + r.note ?? '', + ]) + '\n'; + } + + filename = `attendance_export.csv`; } // Create audit log for export diff --git a/src/app/api/data-import/route.ts b/src/app/api/data-import/route.ts new file mode 100644 index 0000000..b1b6380 --- /dev/null +++ b/src/app/api/data-import/route.ts @@ -0,0 +1,256 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +function escapeCsvField(field: string | number | null | undefined): string { + if (field === null || field === undefined) return ''; + const str = String(field); + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +function toCsvRow(fields: (string | number | null | undefined)[]): string { + return fields.map(escapeCsvField).join(','); +} + +interface ImportRow { + [key: string]: string; +} + +interface ImportResult { + created: number; + skipped: number; + errors: string[]; +} + +function parseCsv(text: string): { headers: string[]; rows: ImportRow[] } { + const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0); + if (lines.length === 0) return { headers: [], rows: [] }; + + const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, '')); + const rows: ImportRow[] = []; + + for (let i = 1; i < lines.length; i++) { + const values: string[] = []; + let current = ''; + let inQuotes = false; + + for (const char of lines[i]) { + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === ',' && !inQuotes) { + values.push(current.trim()); + current = ''; + } else { + current += char; + } + } + values.push(current.trim()); + + const row: ImportRow = {}; + headers.forEach((header, idx) => { + row[header] = values[idx] ?? ''; + }); + rows.push(row); + } + + return { headers, rows }; +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user?.role !== 'SUPER_ADMIN' && + session.user?.role !== 'SCHOOL_ADMIN' && + session.user?.role !== 'TEACHER' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const formData = await request.formData(); + const file = formData.get('file') as File | null; + const type = formData.get('type') as string || 'students'; + const schoolId = formData.get('schoolId') as string || session.user?.schoolId; + + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }); + } + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + const text = await file.text(); + const { headers, rows } = parseCsv(text); + + if (rows.length === 0) { + return NextResponse.json({ error: 'No data rows found in CSV' }, { status: 400 }); + } + + const result: ImportResult = { created: 0, skipped: 0, errors: [] }; + + if (type === 'students') { + for (const row of rows) { + try { + const firstName = row['First Name'] || row['firstName'] || row['Vorname'] || ''; + const lastName = row['Last Name'] || row['lastName'] || row['Nachname'] || ''; + const externalId = row['External ID'] || row['externalId'] || row['ID'] || ''; + const dateOfBirth = row['Date of Birth'] || row['dateOfBirth'] || row['Geburtsdatum'] || ''; + + if (!firstName || !lastName) { + result.skipped++; + result.errors.push(`Row skipped: missing first/last name`); + continue; + } + + const existing = externalId + ? await db.student.findFirst({ where: { externalId, schoolId, deletedAt: null } }) + : null; + + if (existing) { + result.skipped++; + continue; + } + + await db.student.create({ + data: { + schoolId, + firstName, + lastName, + externalId: externalId || null, + dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : null, + }, + }); + result.created++; + } catch (err) { + result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + } + } else if (type === 'assessments') { + for (const row of rows) { + try { + const title = row['Title'] || row['title'] || row['Titel'] || ''; + const classGroupId = row['Class ID'] || row['classGroupId'] || ''; + const subjectId = row['Subject ID'] || row['subjectId'] || ''; + const date = row['Date'] || row['date'] || row['Datum'] || ''; + const assessmentType = row['Type'] || row['type'] || 'WRITTEN'; + + if (!title || !classGroupId) { + result.skipped++; + result.errors.push(`Row skipped: missing title or class ID`); + continue; + } + + await db.assessment.create({ + data: { + title, + classGroupId, + subjectId: subjectId || null, + teacherId: session.userId, + date: date ? new Date(date) : new Date(), + type: assessmentType, + maxScore: row['Max Score'] || row['maxScore'] ? parseFloat(row['Max Score'] || row['maxScore']) : null, + weight: row['Weight'] || row['weight'] ? parseFloat(row['Weight'] || row['weight']) : 1.0, + }, + }); + result.created++; + } catch (err) { + result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + } + } else if (type === 'grades') { + for (const row of rows) { + try { + const studentId = row['Student ID'] || row['studentId'] || ''; + const assessmentId = row['Assessment ID'] || row['assessmentId'] || ''; + const score = row['Score'] || row['score'] || ''; + + if (!studentId || !assessmentId) { + result.skipped++; + result.errors.push(`Row skipped: missing student ID or assessment ID`); + continue; + } + + await db.assessmentResult.create({ + data: { + studentId, + assessmentId, + score: score ? parseFloat(score) : null, + note: row['Note'] || row['note'] || null, + }, + }); + result.created++; + } catch (err) { + result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + } + } + + await db.auditLog.create({ + data: { + userId: session.userId, + schoolId: session.user?.schoolId ?? null, + action: 'IMPORT', + entityType: 'DataImport', + entityId: null, + metadata: JSON.stringify({ type, created: result.created, skipped: result.skipped, errorCount: result.errors.length }), + }, + }); + + return NextResponse.json({ + type, + totalRows: rows.length, + created: result.created, + skipped: result.skipped, + errorCount: result.errors.length, + errors: result.errors.slice(0, 20), + detectedColumns: headers, + }); + } catch (error) { + console.error('Data Import error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +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 type = searchParams.get('type') || 'students'; + + let csvContent = ''; + let filename = ''; + + if (type === 'students') { + csvContent = 'First Name,Last Name,Date of Birth,External ID\nMax,Mustermann,2015-03-15,STU001\nAnna,Schmidt,2015-07-22,STU002\n'; + filename = 'sample_students.csv'; + } else if (type === 'assessments') { + csvContent = 'Title,Class ID,Subject ID,Date,Type,Max Score,Weight\nMath Test 1,cls_001,subj_math,2025-01-15,WRITTEN,100,1.0\nReading Test,cls_001,subj_german,2025-01-20,WRITTEN,50,1.0\n'; + filename = 'sample_assessments.csv'; + } else if (type === 'grades') { + csvContent = 'Student ID,Assessment ID,Score,Note\nstu_001,asmt_001,85,Good work\nstu_002,asmt_001,92,Excellent\n'; + filename = 'sample_grades.csv'; + } + + return new NextResponse(csvContent, { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }); + } catch (error) { + console.error('Sample CSV download error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/notebooks/[id]/pages/[pageId]/versions/route.ts b/src/app/api/notebooks/[id]/pages/[pageId]/versions/route.ts new file mode 100644 index 0000000..336347f --- /dev/null +++ b/src/app/api/notebooks/[id]/pages/[pageId]/versions/route.ts @@ -0,0 +1,232 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const MAX_VERSIONS_PER_PAGE = 50; + +// ── GET: List all versions of a page ── +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string; pageId: string }> } +) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id, pageId } = await params; + + const page = await db.notebookPage.findUnique({ + where: { id: pageId }, + include: { notebook: true }, + }); + + if (!page || page.notebookId !== id) { + return NextResponse.json({ error: 'Page not found' }, { status: 404 }); + } + + const versions = await db.notebookPageVersion.findMany({ + where: { pageId }, + orderBy: { version: 'desc' }, + }); + + return NextResponse.json(versions); + } catch (error) { + console.error('Page versions GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── POST: Create a new version (auto-called when page is saved) ── +const createVersionSchema = z.object({ + editSummary: z.string().optional().nullable(), +}); + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string; pageId: string }> } +) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id, pageId } = await params; + + const page = await db.notebookPage.findUnique({ + where: { id: pageId }, + include: { notebook: true }, + }); + + if (!page || page.notebookId !== id) { + return NextResponse.json({ error: 'Page not found' }, { status: 404 }); + } + + if (page.notebook.ownerId !== session.user.id) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = createVersionSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + // Get current max version number + const latestVersion = await db.notebookPageVersion.findFirst({ + where: { pageId }, + orderBy: { version: 'desc' }, + }); + + const versionNumber = latestVersion ? latestVersion.version + 1 : 1; + + // Create the version with current page content + const version = await db.notebookPageVersion.create({ + data: { + pageId, + version: versionNumber, + textContent: page.textContent, + drawingData: page.drawingData, + editedBy: session.user.id, + editSummary: parsed.data.editSummary ?? null, + }, + }); + + // Enforce max 50 versions: delete older ones + const allVersions = await db.notebookPageVersion.findMany({ + where: { pageId }, + orderBy: { version: 'asc' }, + }); + + if (allVersions.length > MAX_VERSIONS_PER_PAGE) { + const toDelete = allVersions.slice(0, allVersions.length - MAX_VERSIONS_PER_PAGE); + await db.notebookPageVersion.deleteMany({ + where: { + id: { in: toDelete.map(v => v.id) }, + }, + }); + } + + return NextResponse.json(version); + } catch (error) { + console.error('Page version POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── PUT: Restore a specific version (creates a new version from old content) ── +const restoreVersionSchema = z.object({ + versionId: z.string(), +}); + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string; pageId: string }> } +) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id, pageId } = await params; + + const page = await db.notebookPage.findUnique({ + where: { id: pageId }, + include: { notebook: true }, + }); + + if (!page || page.notebookId !== id) { + return NextResponse.json({ error: 'Page not found' }, { status: 404 }); + } + + if (page.notebook.ownerId !== session.user.id) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = restoreVersionSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + // Find the version to restore + const oldVersion = await db.notebookPageVersion.findUnique({ + where: { id: parsed.data.versionId }, + }); + + if (!oldVersion || oldVersion.pageId !== pageId) { + return NextResponse.json({ error: 'Version not found' }, { status: 404 }); + } + + // First, create a version of current content before restoring + const latestVersion = await db.notebookPageVersion.findFirst({ + where: { pageId }, + orderBy: { version: 'desc' }, + }); + + const preRestoreVersion = latestVersion ? latestVersion.version + 1 : 1; + + await db.notebookPageVersion.create({ + data: { + pageId, + version: preRestoreVersion, + textContent: page.textContent, + drawingData: page.drawingData, + editedBy: session.user.id, + editSummary: `Pre-restore snapshot (before restoring v${oldVersion.version})`, + }, + }); + + // Restore the old content to the page + const updatedPage = await db.notebookPage.update({ + where: { id: pageId }, + data: { + textContent: oldVersion.textContent, + drawingData: oldVersion.drawingData, + }, + }); + + // Create the restored version entry + const restoredVersionNumber = preRestoreVersion + 1; + const restoredVersion = await db.notebookPageVersion.create({ + data: { + pageId, + version: restoredVersionNumber, + textContent: oldVersion.textContent, + drawingData: oldVersion.drawingData, + editedBy: session.user.id, + editSummary: `Restored from v${oldVersion.version}`, + }, + }); + + // Enforce max versions + const allVersions = await db.notebookPageVersion.findMany({ + where: { pageId }, + orderBy: { version: 'asc' }, + }); + if (allVersions.length > MAX_VERSIONS_PER_PAGE) { + const toDelete = allVersions.slice(0, allVersions.length - MAX_VERSIONS_PER_PAGE); + await db.notebookPageVersion.deleteMany({ + where: { id: { in: toDelete.map(v => v.id) } }, + }); + } + + return NextResponse.json({ + page: updatedPage, + restoredVersion, + }); + } catch (error) { + console.error('Page version restore error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/parent-links/[id]/route.ts b/src/app/api/parent-links/[id]/route.ts new file mode 100644 index 0000000..5469291 --- /dev/null +++ b/src/app/api/parent-links/[id]/route.ts @@ -0,0 +1,163 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const link = await db.parentStudentLink.findUnique({ + where: { id }, + include: { + parent: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + role: true, + }, + }, + student: { + select: { + id: true, + firstName: true, + lastName: true, + dateOfBirth: true, + externalId: true, + schoolId: true, + enrollments: { + select: { + classGroup: { + select: { id: true, name: true, gradeLevel: true }, + }, + }, + }, + }, + }, + school: { + select: { id: true, name: true }, + }, + }, + }); + + if (!link) { + return NextResponse.json({ error: 'Link not found' }, { status: 404 }); + } + + // Parents can only view their own links + if (session.user?.role === 'PARENT' && link.parentId !== session.user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(link); + } catch (error) { + console.error('ParentLink 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 }); + } + + const { id } = await params; + const body = await request.json(); + const { relationship } = body; + + const link = await db.parentStudentLink.findUnique({ where: { id } }); + if (!link) { + return NextResponse.json({ error: 'Link not found' }, { status: 404 }); + } + + // Only admins, teachers, or the parent can update + if ( + session.user?.role !== 'TEACHER' && + session.user?.role !== 'SCHOOL_ADMIN' && + session.user?.role !== 'SUPER_ADMIN' && + (session.user?.role === 'PARENT' && link.parentId !== session.user.id) + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const updated = await db.parentStudentLink.update({ + where: { id }, + data: { relationship: relationship ?? null }, + include: { + parent: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + role: true, + }, + }, + student: { + select: { + id: true, + firstName: true, + lastName: true, + schoolId: true, + }, + }, + school: { + select: { id: true, name: true }, + }, + }, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('ParentLink 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 }); + } + + const { id } = await params; + const link = await db.parentStudentLink.findUnique({ where: { id } }); + if (!link) { + return NextResponse.json({ error: 'Link not found' }, { status: 404 }); + } + + // Only admins, teachers, or the parent can delete + if ( + session.user?.role !== 'TEACHER' && + session.user?.role !== 'SCHOOL_ADMIN' && + session.user?.role !== 'SUPER_ADMIN' && + (session.user?.role === 'PARENT' && link.parentId !== session.user.id) + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + await db.parentStudentLink.delete({ where: { id } }); + + return NextResponse.json({ message: 'Link removed' }); + } catch (error) { + console.error('ParentLink DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/parent-links/route.ts b/src/app/api/parent-links/route.ts new file mode 100644 index 0000000..9ccbbb4 --- /dev/null +++ b/src/app/api/parent-links/route.ts @@ -0,0 +1,218 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const createLinkSchema = z.object({ + parentId: z.string().min(1), + studentId: z.string().min(1), + schoolId: z.string().min(1), + relationship: z.enum(['mother', 'father', 'guardian', 'other']).optional(), +}); + +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 parentId = searchParams.get('parentId'); + + // If parentId is provided, get links for that parent + // If user is PARENT role, they can only see their own links + let filter: Record = {}; + + if (session.user?.role === 'PARENT') { + filter.parentId = session.user.id; + } else if (parentId) { + // Teachers/admins can query by parentId + filter.parentId = parentId; + } else if (session.user?.role === 'TEACHER' || session.user?.role === 'SCHOOL_ADMIN') { + // Teachers/admins can see all links in their school + if (session.user?.schoolId) { + filter.schoolId = session.user.schoolId; + } + } + + const links = await db.parentStudentLink.findMany({ + where: filter, + include: { + parent: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + role: true, + }, + }, + student: { + select: { + id: true, + firstName: true, + lastName: true, + dateOfBirth: true, + externalId: true, + schoolId: true, + enrollments: { + select: { + classGroup: { + select: { id: true, name: true, gradeLevel: true }, + }, + }, + }, + }, + }, + school: { + select: { id: true, name: true }, + }, + }, + orderBy: [{ createdAt: 'desc' }], + }); + + return NextResponse.json(links); + } catch (error) { + console.error('ParentLinks 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 }); + } + + // Only teachers, school admins, or the parent themselves can create links + if ( + session.user?.role !== 'TEACHER' && + session.user?.role !== 'SCHOOL_ADMIN' && + session.user?.role !== 'SUPER_ADMIN' && + session.user?.role !== 'PARENT' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = createLinkSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const { parentId, studentId, schoolId, relationship } = parsed.data; + + // Verify parent user has PARENT role + const parentUser = await db.user.findUnique({ where: { id: parentId } }); + if (!parentUser || parentUser.role !== 'PARENT') { + return NextResponse.json({ error: 'User must have PARENT role' }, { status: 400 }); + } + + // Verify student exists + const student = await db.student.findUnique({ where: { id: studentId } }); + if (!student) { + return NextResponse.json({ error: 'Student not found' }, { status: 404 }); + } + + // Check if link already exists + const existing = await db.parentStudentLink.findUnique({ + where: { parentId_studentId: { parentId, studentId } }, + }); + if (existing) { + return NextResponse.json({ error: 'Link already exists' }, { status: 409 }); + } + + // Parents can only create links for themselves + if (session.user?.role === 'PARENT' && parentId !== session.user.id) { + return NextResponse.json({ error: 'Parents can only link their own account' }, { status: 403 }); + } + + // School admins can only create links in their school + if ( + session.user?.role === 'SCHOOL_ADMIN' && + session.user?.schoolId && + schoolId !== session.user.schoolId + ) { + return NextResponse.json({ error: 'Cannot create links outside your school' }, { status: 403 }); + } + + const link = await db.parentStudentLink.create({ + data: { + parentId, + studentId, + schoolId, + relationship: relationship ?? null, + }, + include: { + parent: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + role: true, + }, + }, + student: { + select: { + id: true, + firstName: true, + lastName: true, + schoolId: true, + }, + }, + school: { + select: { id: true, name: true }, + }, + }, + }); + + return NextResponse.json(link, { status: 201 }); + } catch (error) { + console.error('ParentLinks POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const linkId = searchParams.get('id'); + + if (!linkId) { + return NextResponse.json({ error: 'Link ID required' }, { status: 400 }); + } + + const link = await db.parentStudentLink.findUnique({ where: { id: linkId } }); + if (!link) { + return NextResponse.json({ error: 'Link not found' }, { status: 404 }); + } + + // Only admins, teachers, or the parent themselves can delete + if ( + session.user?.role !== 'TEACHER' && + session.user?.role !== 'SCHOOL_ADMIN' && + session.user?.role !== 'SUPER_ADMIN' && + (session.user?.role === 'PARENT' && link.parentId !== session.user.id) + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + await db.parentStudentLink.delete({ where: { id: linkId } }); + + return NextResponse.json({ message: 'Link removed' }); + } catch (error) { + console.error('ParentLinks DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/reports/pdf/route.ts b/src/app/api/reports/pdf/route.ts index 282e3b2..11cbcda 100644 --- a/src/app/api/reports/pdf/route.ts +++ b/src/app/api/reports/pdf/route.ts @@ -11,33 +11,90 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const reportId = searchParams.get('reportId'); + const studentId = searchParams.get('studentId'); + const template = searchParams.get('template') ?? 'full'; // short, full, custom - if (!reportId) { - return NextResponse.json({ error: 'reportId is required' }, { status: 400 }); + if (!reportId && !studentId) { + return NextResponse.json({ error: 'reportId or studentId is required' }, { status: 400 }); } - const report = await db.report.findUnique({ - where: { id: reportId }, - include: { - student: { select: { id: true, firstName: true, lastName: true } }, - classGroup: { select: { id: true, name: true, gradeLevel: true } }, - schoolYear: { select: { id: true, label: true } }, - generatedByUser: { select: { id: true, firstName: true, lastName: true } }, - sections: { - orderBy: { order: 'asc' }, - include: { - competencyCategory: { select: { id: true, name: true, color: true } }, + let report; + if (reportId) { + report = await db.report.findUnique({ + where: { id: reportId }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + schoolYear: { select: { id: true, label: true } }, + generatedByUser: { select: { id: true, firstName: true, lastName: true } }, + sections: { + orderBy: { order: 'asc' }, + include: { + competencyCategory: { select: { id: true, name: true, color: true } }, + }, }, }, - }, - }); + }); + } else if (studentId) { + // Generate a report on-the-fly for this student + const student = await db.student.findUnique({ + where: { id: studentId, deletedAt: null }, + include: { + enrollments: { + where: { endDate: null }, + take: 1, + include: { classGroup: { select: { id: true, name: true, gradeLevel: true } } }, + }, + learningProgressEntries: { + take: 20, + orderBy: { createdAt: 'desc' }, + include: { + competency: { select: { code: true, title: true } }, + teacher: { select: { firstName: true, lastName: true } }, + }, + }, + assessmentResults: { + take: 20, + orderBy: { createdAt: 'desc' }, + include: { + assessment: { select: { title: true, type: true } }, + }, + }, + }, + }); + + if (!student) { + return NextResponse.json({ error: 'Student not found' }, { status: 404 }); + } + + const classGroup = student.enrollments[0]?.classGroup; + report = { + id: 'on-the-fly', + student: { id: student.id, firstName: student.firstName, lastName: student.lastName }, + classGroup: classGroup ?? { id: '', name: 'N/A', gradeLevel: 0 }, + schoolYear: { id: '', label: new Date().getFullYear().toString() }, + generatedByUser: { id: session.userId ?? '', firstName: session.user?.firstName ?? '', lastName: session.user?.lastName ?? '' }, + period: 'Aktuell', + status: 'DRAFT', + includesGrades: true, + generatedAt: new Date().toISOString(), + sections: student.learningProgressEntries.map((entry, idx) => ({ + order: idx, + generatedText: `${entry.competency?.title ?? 'Kompetenz'}: ${entry.level ?? 'N/A'}`, + competencyCategory: { id: entry.competency?.id ?? '', name: entry.competency?.code ?? '', color: '#10b981' }, + })), + _studentData: student, + _classGroup: classGroup, + }; + } if (!report) { return NextResponse.json({ error: 'Report not found' }, { status: 404 }); } - // Generate a print-friendly HTML page - const htmlContent = generatePrintHtml(report); + const htmlContent = generatePrintHtml(report, template); return new NextResponse(htmlContent, { headers: { @@ -50,7 +107,7 @@ export async function GET(request: Request) { } } -function generatePrintHtml(report: Record): string { +function generatePrintHtml(report: Record, template: string): string { const student = report.student as { firstName: string; lastName: string }; const classGroup = report.classGroup as { name: string; gradeLevel: number }; const schoolYear = report.schoolYear as { label: string }; @@ -61,6 +118,9 @@ function generatePrintHtml(report: Record): string { competencyCategory: { name: string; color: string | null } | null; }>).sort((a, b) => a.order - b.order); + const isShort = template === 'short'; + const initials = student.firstName[0] + student.lastName[0]; + const sectionsHtml = sections.map((s) => { const catName = s.competencyCategory?.name ?? ''; const catColor = s.competencyCategory?.color ?? '#10b981'; @@ -72,6 +132,43 @@ function generatePrintHtml(report: Record): string { `; }).join('\n'); + // Grades table for full template + const studentData = report._studentData as { + assessmentResults?: Array<{ + score: number | null; + grade: string | null; + assessment: { title: string; type: string }; + }>; + } | undefined; + + const gradesHtml = !isShort && studentData?.assessmentResults?.length + ? ` +
+

Notenubersicht

+ + + + + + + + + + + ${studentData.assessmentResults!.map((r) => ` + + + + + + + `).join('')} + +
UberprufungTypPunkteNote
${r.assessment.title}${r.assessment.type}${r.score ?? '—'}${r.grade ?? '—'}
+
+ ` + : ''; + return ` @@ -86,23 +183,53 @@ function generatePrintHtml(report: Record): string { max-width: 800px; margin: 40px auto; padding: 20px; + position: relative; } .header { - text-align: center; + display: flex; + align-items: center; + gap: 20px; border-bottom: 3px solid #10b981; padding-bottom: 20px; margin-bottom: 30px; } - .header h1 { - color: #10b981; - font-size: 28px; - margin-bottom: 8px; + .avatar { + width: 64px; + height: 64px; + border-radius: 50%; + background: linear-gradient(135deg, #10b981, #14b8a6); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + font-weight: 700; + flex-shrink: 0; } - .header h2 { + .header-text h1 { + color: #10b981; + font-size: 24px; + margin-bottom: 4px; + } + .header-text h2 { color: #666; font-size: 18px; margin-bottom: 6px; } + .template-badge { + display: inline-block; + padding: 2px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + } + .badge-short { background: #fef3c7; color: #92400e; } + .badge-full { background: #d1fae5; color: #065f46; } + .badge-custom { background: #ede9fe; color: #5b21b6; } + .badge-draft { background: #fef3c7; color: #92400e; } + .badge-final { background: #d1fae5; color: #065f46; } .meta { display: grid; grid-template-columns: 1fr 1fr; @@ -116,20 +243,48 @@ function generatePrintHtml(report: Record): string { color: #333; } .section { + margin-bottom: 20px; + padding: 14px; + background: #f9fafb; + border-radius: 8px; + border-left: 4px solid #10b981; + } + .section-title { + font-size: 15px; + font-weight: 600; + margin-bottom: 6px; + } + .section-text { + font-size: 14px; + color: #444; + } + .grades-section { margin-bottom: 24px; padding: 16px; background: #f9fafb; border-radius: 8px; border-left: 4px solid #10b981; } - .section-title { - font-size: 16px; - font-weight: 600; - margin-bottom: 8px; + .grades-table { + width: 100%; + border-collapse: collapse; + margin-top: 10px; + font-size: 13px; } - .section-text { - font-size: 14px; - color: #444; + .grades-table th { + text-align: left; + padding: 6px 10px; + border-bottom: 2px solid #e5e7eb; + color: #555; + font-weight: 600; + } + .grades-table td { + padding: 6px 10px; + border-bottom: 1px solid #f3f4f6; + } + .grade-value { + font-weight: 700; + color: #10b981; } .footer { text-align: center; @@ -139,25 +294,29 @@ function generatePrintHtml(report: Record): string { padding-top: 16px; margin-top: 40px; } - .badge { - display: inline-block; - padding: 2px 8px; - border-radius: 4px; - font-size: 12px; - font-weight: 600; + .eco-footer { + text-align: center; + font-size: 11px; + color: #10b981; + margin-top: 8px; + font-style: italic; } - .badge-draft { - background: #fef3c7; - color: #92400e; - } - .badge-final { - background: #d1fae5; - color: #065f46; + .watermark { + position: fixed; + bottom: 50%; + right: -60px; + transform: rotate(-45deg); + font-size: 72px; + color: rgba(16, 185, 129, 0.04); + font-weight: 900; + pointer-events: none; + z-index: -1; } @media print { body { margin: 0; padding: 20px; } .no-print { display: none; } .section { break-inside: avoid; } + .grades-section { break-inside: avoid; } } .print-btn { position: fixed; @@ -177,27 +336,37 @@ function generatePrintHtml(report: Record): string { +
CompetenceTrack
-

Kompetenzbericht

-

${student.firstName} ${student.lastName}

- ${String(report.status)} +
${initials}
+
+

Kompetenzbericht

+

${student.firstName} ${student.lastName}

+ ${template === 'short' ? 'Kurzbericht' : template === 'custom' ? 'Individuell' : 'Vollstandig'} + ${String(report.status)} +
-
Klasse:
${classGroup.name} (Jahrgang ${classGroup.gradeLevel})
+
Klasse:
${classGroup.name}${classGroup.gradeLevel ? ` (Jahrgang ${classGroup.gradeLevel})` : ''}
Schuljahr:
${schoolYear.label}
Zeitraum:
${String(report.period)}
Erstellt von:
${generatedByUser.firstName} ${generatedByUser.lastName}
-
Datum:
${new Date(String(report.generatedAt)).toLocaleDateString()}
+
Datum:
${new Date(String(report.generatedAt)).toLocaleDateString('de-DE')}
Noten inklusive:
${String(report.includesGrades) === 'true' ? 'Ja' : 'Nein'}
${sectionsHtml} + ${gradesHtml} + + `; diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 570858b..d83dd89 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -6,10 +6,19 @@ import { getSession, hashPassword } from '@/lib/auth'; const createUserSchema = z.object({ schoolId: z.string().nullable().optional(), email: z.string().email().min(1), - password: z.string().min(8), + password: z.string().min(6), firstName: z.string().min(1), lastName: z.string().min(1), - role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN']).default('TEACHER'), + role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN', 'STUDENT', 'PARENT']).default('TEACHER'), + locale: z.string().default('de'), + studentId: z.string().optional(), // link to Student record (for STUDENT role) +}); + +const bulkCreateStudentAccountsSchema = z.object({ + schoolId: z.string().min(1), + defaultPassword: z.string().min(6).default('Schule2025!'), + studentIds: z.array(z.string().min(1)).min(1), + emailDomain: z.string().default('schule.de'), locale: z.string().default('de'), }); @@ -22,13 +31,15 @@ export async function GET(request: Request) { if ( session.user?.role !== 'SUPER_ADMIN' && - session.user?.role !== 'SCHOOL_ADMIN' + session.user?.role !== 'SCHOOL_ADMIN' && + session.user?.role !== 'TEACHER' ) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } const { searchParams } = new URL(request.url); const schoolId = searchParams.get('schoolId'); + const roleFilter = searchParams.get('role'); const where: Record = { deletedAt: null }; @@ -39,6 +50,10 @@ export async function GET(request: Request) { where.schoolId = schoolId; } + if (roleFilter) { + where.role = roleFilter; + } + const users = await db.user.findMany({ where, orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }], @@ -51,11 +66,22 @@ export async function GET(request: Request) { }, }, }, + parentLinks: { + select: { + id: true, + studentId: true, + relationship: true, + student: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }, _count: { select: { classGroupTeachers: true, learningProgressEntries: true, assessments: true, + parentLinks: true, }, }, }, @@ -81,14 +107,117 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } + const body = await request.json(); + + // ── Bulk student account creation ── + if (body.action === 'bulkCreateStudents') { + if ( + session.user?.role !== 'SUPER_ADMIN' && + session.user?.role !== 'SCHOOL_ADMIN' && + session.user?.role !== 'TEACHER' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const parsed = bulkCreateStudentAccountsSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const { schoolId, defaultPassword, studentIds, emailDomain, locale } = parsed.data; + + // School admins can only create in their own school + if ( + session.user?.role === 'SCHOOL_ADMIN' && + session.user?.schoolId && + schoolId !== session.user.schoolId + ) { + return NextResponse.json( + { error: 'Cannot create users outside your school' }, + { status: 403 } + ); + } + + const passwordHash = await hashPassword(defaultPassword); + const createdUsers: Array> = []; + + for (const studentId of studentIds) { + const student = await db.student.findUnique({ where: { id: studentId } }); + if (!student) continue; + + // Check if student already has a user account + const existingUser = await db.user.findFirst({ + where: { + role: 'STUDENT', + schoolId, + firstName: student.firstName, + lastName: student.lastName, + deletedAt: null, + }, + }); + if (existingUser) continue; // Skip if already has account + + // Generate email based on school domain pattern + const email = `${student.firstName.toLowerCase().replace(/\s/g, '')}.${student.lastName.toLowerCase().replace(/\s/g, '')}@${emailDomain}`; + + // Check if generated email already exists + const emailExists = await db.user.findUnique({ where: { email } }); + if (emailExists) continue; + + const user = await db.user.create({ + data: { + email, + passwordHash, + firstName: student.firstName, + lastName: student.lastName, + role: 'STUDENT', + schoolId, + locale, + }, + }); + + createdUsers.push({ + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + schoolId: user.schoolId, + linkedStudentId: studentId, + }); + } + + // Audit log + await db.auditLog.create({ + data: { + userId: session.userId, + schoolId, + action: 'BULK_CREATE', + entityType: 'User', + entityId: 'bulk', + metadata: JSON.stringify({ + count: createdUsers.length, + role: 'STUDENT', + schoolId, + }), + }, + }); + + return NextResponse.json({ created: createdUsers, count: createdUsers.length }, { status: 201 }); + } + + // ── Single user creation ── if ( session.user?.role !== 'SUPER_ADMIN' && - session.user?.role !== 'SCHOOL_ADMIN' + session.user?.role !== 'SCHOOL_ADMIN' && + session.user?.role !== 'TEACHER' ) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } - const body = await request.json(); const parsed = createUserSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( @@ -97,12 +226,12 @@ export async function POST(request: Request) { ); } - const { password, ...userData } = parsed.data; + const { password, studentId, ...userData } = parsed.data; // School admins can only create users in their own school if ( session.user?.role === 'SCHOOL_ADMIN' && - session.user.schoolId && + session.user?.schoolId && userData.schoolId && userData.schoolId !== session.user.schoolId ) { @@ -147,6 +276,18 @@ export async function POST(request: Request) { }, }); + // If STUDENT role and studentId provided, create parent-student link + if (userData.role === 'STUDENT' && studentId) { + // Verify student exists + const student = await db.student.findUnique({ where: { id: studentId } }); + if (student) { + // No direct link between User (STUDENT) and Student - they're separate records + // The connection is implicit: same firstName, lastName, schoolId + } + } + + // If PARENT role, no auto-link here; parent links are created separately + // Create audit log entry await db.auditLog.create({ data: { @@ -160,6 +301,7 @@ export async function POST(request: Request) { role: user.role, firstName: user.firstName, lastName: user.lastName, + linkedStudentId: studentId ?? null, }), }, }); diff --git a/src/app/globals.css b/src/app/globals.css index 6951c56..f238864 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -260,6 +260,45 @@ } /* Print styles — full print stylesheet lives at end of file */ + + /* ─── Slide animations ──────────────────────────────────────────── */ + @keyframes slide-in { + from { transform: translateX(30px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + .animate-slide-in { + animation: slide-in 0.3s ease-out; + } + + @keyframes slide-out { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(-30px); opacity: 0; } + } + .animate-slide-out { + animation: slide-out 0.3s ease-in; + } + + /* ─── Pulse dot for version timeline ────────────────────────────── */ + @keyframes pulse-dot { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.3); opacity: 0.7; } + } + .animate-pulse-dot { + animation: pulse-dot 2s ease-in-out infinite; + } + + /* ─── Canvas toolbar ────────────────────────────────────────────── */ + .canvas-toolbar { + transition: transform 0.2s ease-out, width 0.2s ease-out, height 0.2s ease-out; + } + + /* ─── Version timeline ──────────────────────────────────────────── */ + .version-timeline { + position: relative; + } + .version-timeline > div { + padding-bottom: 0.5rem; + } } /* Focus ring for accessibility */ @@ -777,6 +816,88 @@ background: oklch(0.55 0.22 25 / 0.06); } + /* ─── File Drop Zone ─────────────────────────────────────────────── */ + .file-drop-zone { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 120px; + padding: 24px; + border: 2px dashed oklch(0.75 0.05 155); + border-radius: 12px; + background: oklch(0.97 0.01 155 / 0.3); + cursor: pointer; + transition: all 0.2s ease; + } + .dark .file-drop-zone { + border-color: oklch(0.4 0.05 155); + background: oklch(0.2 0.02 155 / 0.3); + } + .file-drop-zone.drag-over { + border-color: oklch(0.55 0.17 155); + background: oklch(0.55 0.17 155 / 0.1); + transform: scale(1.01); + } + .dark .file-drop-zone.drag-over { + border-color: oklch(0.65 0.17 155); + background: oklch(0.65 0.17 155 / 0.1); + } + .file-drop-zone:hover { + border-color: oklch(0.55 0.17 155); + background: oklch(0.55 0.17 155 / 0.05); + } + + /* ─── Import Progress Bar ────────────────────────────────────────── */ + .import-progress { + animation: import-fade-in 0.3s ease-out; + } + @keyframes import-fade-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } + } + + /* ─── Report Preview Page ────────────────────────────────────────── */ + .report-preview { + position: relative; + max-width: 800px; + margin: 0 auto; + padding: 40px 32px; + background: white; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + border-radius: 8px; + } + .dark .report-preview { + background: oklch(0.15 0.01 260); + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + } + + /* ─── Environmental Watermark ────────────────────────────────────── */ + .watermark-eco { + position: fixed; + bottom: 50%; + right: -60px; + transform: rotate(-45deg); + font-size: 72px; + font-weight: 900; + color: oklch(0.55 0.17 155 / 0.04); + pointer-events: none; + z-index: -1; + user-select: none; + } + + /* ─── Badge Type ─────────────────────────────────────────────────── */ + .badge-type { + display: inline-block; + padding: 2px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + } + /* ─── Status Indicator Dot Pulse ─────────────────────────────────── */ @keyframes status-pulse { 0%, 100% { opacity: 1; transform: scale(1); } diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index e9c5204..e0b14dc 100644 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -199,6 +199,20 @@ const studentNavSections: NavSection[] = [ }, ]; +// Parent-specific navigation (simplified) +const parentNavSections: NavSection[] = [ + { + id: 'parent-main', + labelKey: 'parent.dashboard_title', + items: [ + { key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' }, + { key: 'parents', icon: Mail, labelKey: 'parent.my_children' }, + { key: 'parents', icon: MessageSquareText, labelKey: 'parent.messages' }, + { key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' }, + ], + }, +]; + function renderView(view: ViewName) { switch (view) { case 'dashboard': return ; @@ -264,10 +278,11 @@ export default function AppLayout() { ? `${currentUser.firstName} ${currentUser.lastName}` : ''; - const roleKey = currentUser?.role === 'TEACHER' ? 'role.teacher' : currentUser?.role === 'SCHOOL_ADMIN' ? 'role.school_admin' : currentUser?.role === 'STUDENT' ? 'role.student' : 'role.super_admin'; + const roleKey = currentUser?.role === 'TEACHER' ? 'role.teacher' : currentUser?.role === 'SCHOOL_ADMIN' ? 'role.school_admin' : currentUser?.role === 'STUDENT' ? 'role.student' : currentUser?.role === 'PARENT' ? 'role.parent' : 'role.super_admin'; const isStudent = currentUser?.role === 'STUDENT'; - const activeNavSections = isStudent ? studentNavSections : navSections; + const isParent = currentUser?.role === 'PARENT'; + const activeNavSections = isStudent ? studentNavSections : isParent ? parentNavSections : navSections; const isDemoUser = currentUser?.isDemo === true || (currentUser?.email?.endsWith('@competencetrack.org') && currentUser?.email?.startsWith('demo')); diff --git a/src/components/auth-view.tsx b/src/components/auth-view.tsx index 868a6b0..c23a3ae 100644 --- a/src/components/auth-view.tsx +++ b/src/components/auth-view.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { BookOpen, Mail, Lock, User, ArrowRight, GraduationCap, Heart, Sparkles, Flower2, Eye, EyeOff, KeyRound, Info, Shield, Leaf } from 'lucide-react'; +import { BookOpen, Mail, Lock, User, ArrowRight, GraduationCap, Heart, Sparkles, Flower2, Eye, EyeOff, KeyRound, Info, Shield, Leaf, Users as UsersIcon, HelpCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -14,6 +14,8 @@ import { t } from '@/lib/i18n'; import { login, register } from '@/lib/api'; import { toast } from 'sonner'; +type LoginRole = 'teacher' | 'student' | 'parent'; + const DEMO_ACCOUNTS = [ { email: 'demo@competencetrack.org', password: 'Demo2025!', role: 'SCHOOL_ADMIN', labelKey: 'auth.demo_admin', icon: Shield, colorClass: 'from-emerald-400 to-emerald-600 hover:from-emerald-500 hover:to-emerald-700 shadow-emerald-300/40 dark:shadow-emerald-900/40' }, { email: 'demo.teacher@competencetrack.org', password: 'Demo2025!', role: 'TEACHER', labelKey: 'auth.demo_teacher', icon: GraduationCap, colorClass: 'from-teal-400 to-teal-600 hover:from-teal-500 hover:to-teal-700 shadow-teal-300/40 dark:shadow-teal-900/40' }, @@ -23,14 +25,17 @@ const DEMO_ACCOUNTS = [ export default function AuthView() { const [mode, setMode] = useState<'login' | 'register'>('login'); + const [loginRole, setLoginRole] = useState('teacher'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); + const [schoolIdInput, setSchoolIdInput] = useState(''); const [loading, setLoading] = useState(false); const [demoLoading, setDemoLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); const [rememberMe, setRememberMe] = useState(true); + const [showForgotInfo, setShowForgotInfo] = useState(false); const setCurrentUser = useAppStore((s) => s.setCurrentUser); const handleSubmit = async (e: React.FormEvent) => { @@ -41,7 +46,13 @@ export default function AuthView() { if (mode === 'login') { const user = await login(email, password); setCurrentUser(user); - toast.success(t('auth.welcome_back')); + if (user.role === 'STUDENT') { + toast.success(t('auth.student_welcome')); + } else if (user.role === 'PARENT') { + toast.success(t('auth.parent_welcome')); + } else { + toast.success(t('auth.welcome_back')); + } } else { const user = await register({ email, @@ -72,6 +83,7 @@ export default function AuthView() { setPassword(''); setFirstName(''); setLastName(''); + setSchoolIdInput(''); }; const handleDemoLogin = async (demoEmail: string, demoPassword: string) => { @@ -83,7 +95,13 @@ export default function AuthView() { try { const user = await login(demoEmail, demoPassword); setCurrentUser(user); - toast.success(t('auth.welcome_back')); + if (user.role === 'STUDENT') { + toast.success(t('auth.student_welcome')); + } else if (user.role === 'PARENT') { + toast.success(t('auth.parent_welcome')); + } else { + toast.success(t('auth.welcome_back')); + } } catch (err) { const message = err instanceof Error ? err.message : t('auth.error.generic'); if (message.includes('credentials') || message.includes('Invalid')) { @@ -96,25 +114,28 @@ export default function AuthView() { } }; + // Role tab config + const roleTabs: Array<{ key: LoginRole; icon: React.ElementType; labelKey: string; color: string; activeColor: string; bgGradient: string }> = [ + { key: 'teacher', icon: GraduationCap, labelKey: 'auth.login', color: 'text-emerald-600 dark:text-emerald-400', activeColor: 'bg-emerald-500 text-white', bgGradient: 'from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/10 dark:to-teal-950/10' }, + { key: 'student', icon: UsersIcon, labelKey: 'auth.student_login', color: 'text-amber-600 dark:text-amber-400', activeColor: 'bg-amber-500 text-white', bgGradient: 'from-amber-50/50 to-orange-50/50 dark:from-amber-950/10 dark:to-orange-950/10' }, + { key: 'parent', icon: Heart, labelKey: 'auth.parent_login', color: 'text-violet-600 dark:text-violet-400', activeColor: 'bg-violet-500 text-white', bgGradient: 'from-violet-50/50 to-purple-50/50 dark:from-violet-950/10 dark:to-purple-950/10' }, + ]; + + const activeRoleTab = roleTabs.find((r) => r.key === loginRole)!; + return (
{/* Animated floating geometric shapes background */}
- {/* Large emerald circle */}
- {/* Medium teal square */}
- {/* Small amber circle */}
- {/* Tiny emerald dot */}
- {/* Medium violet square */}
- {/* Small teal circle */}
- {/* Dot grid overlay */}
+ {/* Left illustration side (hidden on small screens) */}
- {/* Decorative circles */} - - - - {/* Main icon */} + + + + {/* Main icon changes based on login role */} - + + {loginRole === 'teacher' && } + {loginRole === 'student' && } + {loginRole === 'parent' && } +
+ {/* Feature highlights */} - +

{t('auth.mission_title')}

-
- -
+

{t('auth.feature_competencies')}

-
- -
+

{t('auth.feature_student_centered')}

-
- -
+

{t('auth.feature_open_source')}

-
- -
+

{t('auth.feature_reports')}

-
- -
+

{t('auth.feature_flower')}

{/* Mockup dashboard preview */} - +
@@ -208,14 +198,7 @@ export default function AuthView() {
{[0, 1, 2, 3, 4, 5].map((i) => ( -
+
))}
@@ -228,80 +211,99 @@ export default function AuthView() { {/* Auth form */} - + {/* Branding */}
- + -

- {t('app.name')} -

-

- {t('app.subtitle')} -

+

{t('app.name')}

+

{t('app.subtitle')}

{/* Auth Card */} - - - {mode === 'login' ? t('auth.login') : t('auth.register_title')} - + {/* Role tabs */} +
+ {roleTabs.map((tab) => ( + + ))} +
+ + + + + + {loginRole === 'teacher' ? (mode === 'login' ? t('auth.login') : t('auth.register_title')) + : loginRole === 'student' ? t('auth.student_login') + : t('auth.parent_login')} + +

+ {loginRole === 'teacher' ? (mode === 'login' ? t('auth.login_subtitle') : t('auth.register_subtitle')) + : loginRole === 'student' ? t('auth.student_login_desc') + : t('auth.parent_login_desc')} +

+
+
+
- {mode === 'register' && ( - + {/* Name fields for register (teacher only) */} + {mode === 'register' && loginRole === 'teacher' && ( +
- setFirstName(e.target.value)} - className="pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base" - placeholder={t('auth.firstName')} - required - /> + setFirstName(e.target.value)} className="pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base" placeholder={t('auth.firstName')} required />
- setLastName(e.target.value)} - className="h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base" - placeholder={t('auth.lastName')} - required - /> + setLastName(e.target.value)} className="h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base" placeholder={t('auth.lastName')} required />
)} + + {/* Student-specific: School ID field */} + {loginRole === 'student' && ( + + +
+ + setSchoolIdInput(e.target.value)} className="pl-10 h-12 min-h-[44px] border-amber-200/50 dark:border-amber-900/30 focus:border-amber-400 focus:ring-amber-400/20 text-base" placeholder={t('auth.school_id')} /> +
+
+ )} + + {/* Parent-specific: name fields on login */} + {loginRole === 'parent' && mode === 'login' && ( + +
+ +

{t('auth.parent_login_desc')}

+
+
+ )}
+ {/* Email field (always shown) */}
@@ -311,23 +313,28 @@ export default function AuthView() { type="email" value={email} onChange={(e) => setEmail(e.target.value)} - className="pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base" - placeholder="name@schule.de" + className={`pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base ${ + loginRole === 'student' ? 'focus:border-amber-400 focus:ring-amber-400/20' + : loginRole === 'parent' ? 'focus:border-violet-400 focus:ring-violet-400/20' + : '' + }`} + placeholder={loginRole === 'student' ? 'name@schule.de' : loginRole === 'parent' ? 'parent@email.de' : 'name@schule.de'} required />
+ {/* Password field */}
{mode === 'login' && ( )}
@@ -353,26 +360,43 @@ export default function AuthView() { {showPassword ? : }
+ + {/* Forgot Password info */} + + {showForgotInfo && ( + +
+ +

+ {loginRole === 'student' + ? (t('polish.demo_email') + ': demo.student@competencetrack.org / ' + t('polish.demo_password') + ': Demo2025!') + : loginRole === 'parent' + ? (t('polish.demo_email') + ': demo.parent@competencetrack.org / ' + t('polish.demo_password') + ': Demo2025!') + : (t('polish.demo_email') + ': demo@competencetrack.org / ' + t('polish.demo_password') + ': Demo2025!') + } +

+
+
+ )} +
{/* Remember me (login mode only) */} {mode === 'login' && (
- setRememberMe(v === true)} - className="border-emerald-300 dark:border-emerald-700 data-[state=checked]:bg-emerald-500 data-[state=checked]:border-emerald-500" - /> - + setRememberMe(v === true)} className="border-emerald-300 dark:border-emerald-700 data-[state=checked]:bg-emerald-500 data-[state=checked]:border-emerald-500" /> +
)} + {/* Submit button */} -
+ {/* Toggle register/login (only for teacher role) */} + {loginRole === 'teacher' && ( +
+ +
+ )} + + {/* Student info: accounts created by teachers */} + {loginRole === 'student' && mode === 'login' && ( +
+
+ +

+ {t('auth.create_student_account')} — {t('auth.student_login_desc')} +

+
+
+ )} + + {/* Parent info */} + {loginRole === 'parent' && mode === 'login' && ( +
+
+ +

+ {t('parent.my_children')} — {t('auth.parent_login_desc')} +

+
+
+ )} {/* Footer */}
-

- {t('app.tagline')} -

+

{t('app.tagline')}

diff --git a/src/components/calendar-view.tsx b/src/components/calendar-view.tsx index da4ce89..f00fe84 100644 --- a/src/components/calendar-view.tsx +++ b/src/components/calendar-view.tsx @@ -19,6 +19,7 @@ import { Trash2, Bell, CheckCircle2, + Repeat, } from 'lucide-react'; import { format, @@ -96,6 +97,7 @@ import { updateCalendarEventItem, deleteCalendarEventItem, type CalendarEventItemType, + type RecurrencePattern, fetchClasses, fetchSubjects, type ClassGroup, @@ -248,6 +250,12 @@ interface EventFormData { classGroupId: string; notes: string; allDay: boolean; + recurrenceType: 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'; + recurrenceInterval: number; + recurrenceEndType: 'never' | 'on_date'; + recurrenceEndDate: string; + recurrenceDaysOfWeek: number[]; + editMode: 'series' | 'instance'; } const EMPTY_FORM: EventFormData = { @@ -260,6 +268,12 @@ const EMPTY_FORM: EventFormData = { classGroupId: '', notes: '', allDay: false, + recurrenceType: 'none', + recurrenceInterval: 1, + recurrenceEndType: 'never', + recurrenceEndDate: '', + recurrenceDaysOfWeek: [], + editMode: 'instance', }; /* ── Sub-components ───────────────────────────────────────────────── */ @@ -350,6 +364,13 @@ function EventDetailContent({ event, onEdit, onDelete }: { event: CalendarEvent; )}
)} + {/* Recurring indicator */} + {isCustomEvent && typeof meta.recurrencePattern === 'string' && meta.recurrencePattern && ( +
+ + {t('calendar.recurring')} +
+ )}
); } @@ -638,6 +659,9 @@ export default function CalendarView() { // Delete confirmation const [deleteConfirmEvent, setDeleteConfirmEvent] = useState(null); + // Edit mode dialog for recurring events + const [showEditModeDialog, setShowEditModeDialog] = useState(false); + const [pendingEditEvent, setPendingEditEvent] = useState(null); // Classes and subjects for form const [classes, setClasses] = useState([]); @@ -753,6 +777,15 @@ export default function CalendarView() { const handleEditEvent = useCallback((event: CalendarEvent) => { const meta = event.meta as Record; + const isRecurring = typeof meta.recurrencePattern === 'string' && meta.recurrencePattern; + const isChild = typeof meta.parentEventId === 'string' && meta.parentEventId; + + if (isRecurring || isChild) { + setPendingEditEvent(event); + setShowEditModeDialog(true); + return; + } + setEditingEvent(event); setFormData({ title: event.title, @@ -764,11 +797,48 @@ export default function CalendarView() { classGroupId: '', notes: (typeof meta.notes === 'string' && meta.notes) ? meta.notes : '', allDay: typeof meta.allDay === 'boolean' ? meta.allDay : false, + recurrenceType: 'none', + recurrenceInterval: 1, + recurrenceEndType: 'never', + recurrenceEndDate: '', + recurrenceDaysOfWeek: [], + editMode: 'instance', }); setShowEventForm(true); setSelectedEvent(null); }, []); + const handleEditEventWithMode = useCallback((event: CalendarEvent, mode: 'series' | 'instance') => { + const meta = event.meta as Record; + let parsedRecurrence: RecurrencePattern | null = null; + if (typeof meta.recurrencePattern === 'string' && meta.recurrencePattern) { + try { parsedRecurrence = JSON.parse(meta.recurrencePattern as string); } catch { /* ignore */ } + } + + setEditingEvent(event); + setFormData({ + title: event.title, + date: event.date, + startTime: (typeof meta.startTime === 'string' && meta.startTime) ? meta.startTime : '08:00', + endTime: (typeof meta.endTime === 'string' && meta.endTime) ? meta.endTime : '09:00', + eventType: (typeof meta.eventType === 'string' ? meta.eventType : 'reminder') as CalendarEventItemType, + subjectId: '', + classGroupId: '', + notes: (typeof meta.notes === 'string' && meta.notes) ? meta.notes : '', + allDay: typeof meta.allDay === 'boolean' ? meta.allDay : false, + recurrenceType: parsedRecurrence?.type ?? 'none', + recurrenceInterval: parsedRecurrence?.interval ?? 1, + recurrenceEndType: parsedRecurrence?.endDate ? 'on_date' : 'never', + recurrenceEndDate: parsedRecurrence?.endDate ?? '', + recurrenceDaysOfWeek: parsedRecurrence?.daysOfWeek ?? [], + editMode: mode, + }); + setShowEventForm(true); + setSelectedEvent(null); + setShowEditModeDialog(false); + setPendingEditEvent(null); + }, []); + const handleDeleteEvent = useCallback(async () => { if (!deleteConfirmEvent) return; try { @@ -786,6 +856,23 @@ export default function CalendarView() { if (!currentUser?.schoolId || !formData.title.trim()) return; setSaving(true); try { + const recurrencePattern: RecurrencePattern | null = formData.recurrenceType !== 'none' + ? { + type: formData.recurrenceType, + interval: formData.recurrenceInterval, + ...(formData.recurrenceEndType === 'on_date' && formData.recurrenceEndDate + ? { endDate: formData.recurrenceEndDate } + : {}), + ...(formData.recurrenceType === 'weekly' && formData.recurrenceDaysOfWeek.length > 0 + ? { daysOfWeek: formData.recurrenceDaysOfWeek } + : {}), + } + : null; + + const recurrenceEnd = formData.recurrenceEndType === 'on_date' && formData.recurrenceEndDate + ? formData.recurrenceEndDate + : null; + if (editingEvent) { await updateCalendarEventItem(editingEvent.id, { title: formData.title, @@ -797,6 +884,9 @@ export default function CalendarView() { classGroupId: formData.classGroupId || null, notes: formData.notes || null, allDay: formData.allDay, + recurrencePattern, + recurrenceEnd, + editMode: formData.editMode, }); toast.success(t('calendar.event_updated')); } else { @@ -811,6 +901,8 @@ export default function CalendarView() { classGroupId: formData.classGroupId || null, notes: formData.notes || null, allDay: formData.allDay, + recurrencePattern, + recurrenceEnd, }); toast.success(t('calendar.event_created')); } @@ -1561,6 +1653,132 @@ export default function CalendarView() { className="min-h-[44px] resize-none" />
+ + {/* Recurrence options */} + {!editingEvent || formData.editMode === 'series' ? ( +
+
+ + +
+ + {/* Repeat type */} +
+ + +
+ + {/* Interval */} + {formData.recurrenceType !== 'none' && ( +
+ + setFormData((f) => ({ ...f, recurrenceInterval: parseInt(e.target.value) || 1 }))} + className="min-h-[44px] w-20" + /> +
+ )} + + {/* Days of week selector (for weekly) */} + {formData.recurrenceType === 'weekly' && ( +
+ +
+ {['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map((dayLabel, dayIndex) => { + const dayNum = dayIndex === 6 ? 0 : dayIndex + 1; + const isSelected = formData.recurrenceDaysOfWeek.includes(dayNum); + return ( + + ); + })} +
+
+ )} + + {/* Recurrence end */} + {formData.recurrenceType !== 'none' && ( +
+ +
+ + +
+ {formData.recurrenceEndType === 'on_date' && ( + setFormData((f) => ({ ...f, recurrenceEndDate: e.target.value }))} + className="min-h-[44px]" + /> + )} +
+ )} +
+ ) : null}
@@ -1606,6 +1824,42 @@ export default function CalendarView() { + + {/* Edit mode dialog for recurring events */} + { + if (!open) { + setShowEditModeDialog(false); + setPendingEditEvent(null); + } + }} + > + + + {t('calendar.edit_series')} + + {t('calendar.edit_instance')} + + + + {t('action.cancel')} + + pendingEditEvent && handleEditEventWithMode(pendingEditEvent, 'series')} + className="bg-emerald-500 hover:bg-emerald-600 text-white min-h-[44px]" + > + {t('calendar.edit_series')} + + + +
); } diff --git a/src/components/dashboard-view.tsx b/src/components/dashboard-view.tsx index d53410a..fe4d4fa 100644 --- a/src/components/dashboard-view.tsx +++ b/src/components/dashboard-view.tsx @@ -46,6 +46,7 @@ import { CalendarCheck, Calendar as CalendarIconNav, BookMarked, + Heart, } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -60,9 +61,9 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; -import { useAppStore, type ViewName } from '@/lib/store'; +import { useAppStore, type ViewName, type CurrentUser } from '@/lib/store'; import { t } from '@/lib/i18n'; -import { fetchDashboard, type DashboardData, getStoredNotifications, type AppNotification, addNotification, markNotificationsRead } from '@/lib/api'; +import { fetchDashboard, type DashboardData, getStoredNotifications, type AppNotification, addNotification, markNotificationsRead, fetchParentLinks, type ParentStudentLinkData, fetchStudents } from '@/lib/api'; const containerVariants = { hidden: { opacity: 0 }, @@ -131,6 +132,284 @@ const motivationalTips = [ { text: 'Detailed records make parent conversations productive.', icon: MessageSquare }, ]; +// ─── Parent Dashboard Component ──────────────────────────────────────── +function ParentDashboard({ currentUser, setCurrentView, locale }: { + currentUser: CurrentUser; + setCurrentView: (view: ViewName) => void; + locale: string; +}) { + const [parentLinks, setParentLinks] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function load() { + try { + const links = await fetchParentLinks(currentUser.id); + setParentLinks(links); + } catch { + // ignore + } finally { + setLoading(false); + } + } + load(); + }, [currentUser.id]); + + const children = parentLinks.map((link) => ({ + linkId: link.id, + studentId: link.student.id, + firstName: link.student.firstName, + lastName: link.student.lastName, + relationship: link.relationship, + className: link.student.enrollments?.[0]?.classGroup?.name ?? '--', + gradeLevel: link.student.enrollments?.[0]?.classGroup?.gradeLevel ?? '--', + })); + + if (loading) { + return ( +
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ + +
+
+ ); + } + + return ( + + {/* Parent Welcome Header */} + +
+
+
+
+ +
+
+

+ {t('parent.welcome_back')}, {currentUser.firstName}! +

+

{t('parent.dashboard_title')}

+
+
+
+ + + {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + })} + +
+
+
+
+ + {/* Parent Cards Grid */} + + {/* My Children Card */} + + +
+
+
+ +
+ {t('parent.my_children')} +
+ +
+
+ + {children.length > 0 ? ( +
+ {children.map((child) => ( +
+
+
+ {child.firstName[0]}{child.lastName[0]} +
+ {child.firstName} {child.lastName} +
+
+ + {child.className} + + {child.relationship && ( + {t(`parent.${child.relationship}`)} + )} +
+
+ ))} +
+ ) : ( +
+ +

{t('parent.no_children')}

+

{t('parent.no_children_desc')}

+
+ )} +
+
+ + {/* Recent Progress Card */} + setCurrentView('flower')}> + +
+
+
+ +
+ {t('parent.recent_progress')} +
+ +
+
+ +

{t('parent.child_progress_desc')}

+ {children.length > 0 ? ( +
+ {children.slice(0, 3).map((child) => ( +
+ {child.firstName} +
+ + {t('parent.view_progress')} +
+
+ ))} +
+ ) : ( +
+ +

{t('parent.no_children')}

+
+ )} +
+
+ + {/* Upcoming Assessments Card */} + setCurrentView('assessments')}> + +
+
+
+ +
+ {t('parent.upcoming_assessments')} +
+ +
+
+ +
+ +

{t('parent.no_assessments')}

+
+
+
+ + {/* Messages from Teachers Card */} + setCurrentView('parents')}> + +
+
+
+ +
+ {t('parent.messages_from_teachers')} +
+ +
+
+ +
+ +

{t('parent.no_messages')}

+
+
+
+ + {/* Attendance Summary Card */} + setCurrentView('attendance')}> + +
+
+
+ +
+ {t('parent.attendance_summary')} +
+ +
+
+ + {children.length > 0 ? ( +
+ {children.slice(0, 3).map((child) => ( +
+ {child.firstName} +
+ + {t('parent.view_attendance')} +
+
+ ))} +
+ ) : ( +
+ +

{t('parent.no_children')}

+
+ )} +
+
+ + {/* Calendar Card */} + setCurrentView('calendar')}> + +
+
+
+ +
+ {t('nav.calendar')} +
+ +
+
+ +
+
+

+ {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { day: 'numeric', month: 'short' })} +

+

+ {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'long' })} +

+
+
+
+
+
+
+ ); +} + export default function DashboardView() { const currentUser = useAppStore((s) => s.currentUser); const setCurrentView = useAppStore((s) => s.setCurrentView); @@ -514,6 +793,17 @@ export default function DashboardView() { ); } + // ─── Parent Dashboard ────────────────────────────────────────────── + if (currentUser?.role === 'PARENT') { + return ( + + ); + } + return ( { @@ -780,9 +785,9 @@ export default function DrawingCanvas({ variant="ghost" size="sm" onClick={() => setActiveTool(tool.id)} - className={`h-10 w-10 transition-all ${ + className={`h-10 w-10 transition-all rounded-lg ${ activeTool === tool.id - ? 'bg-emerald-600 text-white shadow-md shadow-emerald-500/30' + ? 'bg-emerald-600 text-white shadow-md shadow-emerald-500/30 scale-105' : 'text-gray-300 hover:bg-gray-800 hover:text-white' }`} > @@ -820,9 +825,9 @@ export default function DrawingCanvas({ @@ -972,7 +977,7 @@ export default function DrawingCanvas({ size="sm" onClick={handleRedo} disabled={redoStack.length === 0} - className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30" + className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30 transition-transform active:scale-90" > @@ -981,6 +986,42 @@ export default function DrawingCanvas({ + {/* Divider */} +
+ + {/* Zoom controls */} + + + + + + {t('drawing.zoom_out')} + + {zoomLevel}% + + + + + {t('drawing.zoom_in')} + + + {/* Spacer */}
@@ -1047,9 +1088,19 @@ export default function DrawingCanvas({ {/* ── Canvas Area ────────────────────────────────────────────── */}
-
+
= [ @@ -946,7 +959,7 @@ function PageThumbnail({ page, notebookType }: { page: NotebookPage; notebookTyp return (
@@ -964,6 +977,12 @@ function PageThumbnail({ page, notebookType }: { page: NotebookPage; notebookTyp
)} + {/* Drawing indicator */} + {page.drawingData && ( +
+ +
+ )}
); } @@ -998,6 +1017,13 @@ function NotebookDetailView({ const [saving, setSaving] = useState(false); const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); + // Version history state + const [versionHistoryOpen, setVersionHistoryOpen] = useState(false); + const [pageVersions, setPageVersions] = useState([]); + const [previewVersion, setPreviewVersion] = useState(null); + const [restoringVersion, setRestoringVersion] = useState(false); + const [restoreConfirmVersion, setRestoreConfirmVersion] = useState(null); + // Drag-and-drop state const [draggedPageId, setDraggedPageId] = useState(null); const [dragOverPageId, setDragOverPageId] = useState(null); @@ -1123,6 +1149,14 @@ function NotebookDetailView({ lastSavedTitleRef.current = pageTitle; toast.success(t('notebooks.page_saved')); setAutoSaveStatus('idle'); + // Create version on manual save + try { + await apiPost(`/api/notebooks/${notebook.id}/pages/${currentPage.id}/versions`, { + editSummary: null, + }); + } catch { + // version creation is non-critical + } } catch { toast.error(t('notebooks.error_save')); } finally { @@ -1130,6 +1164,71 @@ function NotebookDetailView({ } }; + // Load version history + const loadVersionHistory = useCallback(async () => { + if (!currentPage) return; + try { + const versions = await apiGet(`/api/notebooks/${notebook.id}/pages/${currentPage.id}/versions`); + setPageVersions(versions); + } catch { + setPageVersions([]); + } + }, [notebook.id, currentPage]); + + // Open version history dialog + const handleOpenVersionHistory = useCallback(async () => { + setVersionHistoryOpen(true); + setPreviewVersion(null); + await loadVersionHistory(); + }, [loadVersionHistory]); + + // Restore a version + const handleRestoreVersion = useCallback(async (version: PageVersion) => { + setRestoringVersion(true); + try { + const result = await apiPut<{ page: NotebookPage; restoredVersion: PageVersion }>(`/api/notebooks/${notebook.id}/pages/${currentPage!.id}/versions`, { + versionId: version.id, + }); + // Update local state with restored page content + await onUpdatePage(currentPage!.id, { + textContent: result.page.textContent, + drawingData: result.page.drawingData, + }); + setEditorContent(result.page.textContent ?? ''); + setPageContent(result.page.textContent ?? ''); + lastSavedContentRef.current = result.page.textContent ?? ''; + toast.success(t('notebooks.version_restored')); + setRestoreConfirmVersion(null); + await loadVersionHistory(); + } catch { + toast.error(t('notebooks.error_save')); + } finally { + setRestoringVersion(false); + } + }, [notebook.id, currentPage, onUpdatePage, setEditorContent, loadVersionHistory]); + + // Handle drawing save from DrawingCanvas + const handleDrawingSave = useCallback(async (drawingData: string, imageData: string) => { + if (!currentPage) return; + try { + await onUpdatePage(currentPage.id, { + drawingData, + contentType: currentPage.textContent ? 'mixed' : 'drawing', + }); + toast.success(t('notebooks.drawing_saved')); + // Create version on drawing save + try { + await apiPost(`/api/notebooks/${notebook.id}/pages/${currentPage.id}/versions`, { + editSummary: 'Drawing saved', + }); + } catch { + // non-critical + } + } catch { + toast.error(t('notebooks.error_save')); + } + }, [currentPage, notebook.id, onUpdatePage]); + // Drag-and-drop handlers for page reorder const handleDragStart = useCallback((e: React.DragEvent, pageId: string) => { setDraggedPageId(pageId); @@ -1222,10 +1321,11 @@ function NotebookDetailView({ return ( {/* Header */}
@@ -1280,19 +1380,20 @@ function NotebookDetailView({ variant={drawingMode ? 'default' : 'outline'} size="sm" onClick={() => setDrawingMode(!drawingMode)} + className={`min-h-[44px] shrink-0 transition-all ${drawingMode ? 'bg-emerald-600 hover:bg-emerald-700 shadow-md shadow-emerald-500/20' : ''}`} + > + + {drawingMode ? t('notebooks.drawing_mode_exit') : t('notebooks.drawing_mode_toggle')} + + + {/* Version history button */} + {/* Public toggle */} @@ -1418,6 +1519,16 @@ function NotebookDetailView({ className="font-semibold text-lg border-0 bg-transparent shadow-none focus-visible:ring-0 h-10" /> + {/* Version history button in page title bar */} + + - -
+ /* Drawing mode - render DrawingCanvas */ + + setDrawingMode(false)} + title={currentPage.title ?? `${t('notebooks.page')} ${currentPage.pageNumber}`} + /> + ) : ( /* Text editing area with WYSIWYG toolbar and page background */
@@ -1491,22 +1600,43 @@ function NotebookDetailView({ {/* Save bar */}
-
- {t('notebooks.page')} {currentPage.pageNumber} / {pages.length} -
- +
+
+ {/* View drawing button if page has drawing data */} + {currentPage.drawingData && !drawingMode && ( + + )} + +
) : ( @@ -1524,6 +1654,20 @@ function NotebookDetailView({ )}
+ + {/* Version History Dialog */} + setVersionHistoryOpen(false)} + versions={pageVersions} + previewVersion={previewVersion} + onPreviewVersion={setPreviewVersion} + onRestoreVersion={handleRestoreVersion} + restoringVersion={restoringVersion} + restoreConfirmVersion={restoreConfirmVersion} + onSetRestoreConfirm={setRestoreConfirmVersion} + currentPageContent={currentPage?.textContent ?? null} + /> ); } @@ -2421,3 +2565,207 @@ export default function NotebooksView() {
); } + +// ─── Version History Dialog ────────────────────────────────────────── + +function VersionHistoryDialog({ + open, + onClose, + versions, + previewVersion, + onPreviewVersion, + onRestoreVersion, + restoringVersion, + restoreConfirmVersion, + onSetRestoreConfirm, + currentPageContent, +}: { + open: boolean; + onClose: () => void; + versions: PageVersion[]; + previewVersion: PageVersion | null; + onPreviewVersion: (version: PageVersion) => void; + onRestoreVersion: (version: PageVersion) => void; + restoringVersion: boolean; + restoreConfirmVersion: PageVersion | null; + onSetRestoreConfirm: (version: PageVersion | null) => void; + currentPageContent: string | null; +}) { + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + const getPreviewText = (content: string | null) => { + if (!content) return ''; + // Strip HTML tags to get plain text preview + const plain = content.replace(/<[^>]*>/g, '').trim(); + return plain.substring(0, 120) + (plain.length > 120 ? '...' : ''); + }; + + const isLatestVersion = versions.length > 0 && previewVersion?.id === versions[0]?.id; + + return ( + <> + { if (!v) onClose(); }}> + + + + + {t('notebooks.version_history_title')} + + + {t('notebooks.version_history')} + + + +
+ {versions.length === 0 ? ( +
+ +

+ {t('notebooks.version_no_history')} +

+
+ ) : ( +
+ {versions.map((version, idx) => { + const isCurrent = idx === 0; + const isPreviewed = previewVersion?.id === version.id; + return ( +
+ {/* Timeline dot */} +
+
+ {idx < versions.length - 1 && ( +
+ )} +
+ + {/* Version content */} +
onPreviewVersion(version)} + > +
+
+ + {t('notebooks.version_number')} {version.version} + + {isCurrent && ( + + {t('notebooks.version_current')} + + )} + {version.editSummary && ( + + {version.editSummary} + + )} +
+ + {formatDate(version.createdAt)} + +
+ + {/* Content preview */} +
+ {getPreviewText(version.textContent)} +
+ + {/* Drawing indicator */} + {version.drawingData && ( +
+ + {t('notebooks.drawing_page_label')} +
+ )} +
+
+ ); + })} +
+ )} + + {/* Preview panel */} + {previewVersion && ( +
+
+

+ {t('notebooks.version_preview')} - {t('notebooks.version_number')} {previewVersion.version} + {versions[0]?.id === previewVersion.id && ( + + {t('notebooks.version_current')} + + )} +

+ {!isLatestVersion && ( + + )} +
+ +
+ {previewVersion.textContent ? getPreviewText(previewVersion.textContent) : t('notebooks.export_empty_page')} +
+
+
+ )} +
+ + + + + +
+ + {/* Restore confirmation */} + { if (!v) onSetRestoreConfirm(null); }}> + + + + + {t('notebooks.version_restore_confirm')} + + + {t('notebooks.version_restore_desc')} + + + + {t('action.cancel')} + { if (restoreConfirmVersion) onRestoreVersion(restoreConfirmVersion); }} + disabled={restoringVersion} + className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700" + > + {restoringVersion ? ( +
+ ) : null} + {t('notebooks.version_restore')} + + + + + + ); +} diff --git a/src/components/reports-view.tsx b/src/components/reports-view.tsx index 7fa197d..febd144 100644 --- a/src/components/reports-view.tsx +++ b/src/components/reports-view.tsx @@ -5,7 +5,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import { FileText, Plus, Eye, Download, Check, PenLine, BookOpen, Printer, School, Clock, Sparkles, FileEdit, FileCheck, FileType, - CheckCircle, BarChart3, ClipboardList, + CheckCircle, BarChart3, ClipboardList, FileDown, Users, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -70,6 +70,12 @@ export default function ReportsView() { // Preview dialog const [previewOpen, setPreviewOpen] = useState(false); + // PDF generation + const [pdfTemplate, setPdfTemplate] = useState<'short' | 'full' | 'custom'>('full'); + const [generatingPdf, setGeneratingPdf] = useState(false); + const [downloadHistory, setDownloadHistory] = useState>([]); + const [batchGenerating, setBatchGenerating] = useState(false); + // Edit section const [editingSection, setEditingSection] = useState<{ id: string; text: string } | null>(null); @@ -160,6 +166,50 @@ export default function ReportsView() { setEditingSection(null); }; + const handleGeneratePdf = async (reportId: string) => { + setGeneratingPdf(true); + try { + const url = `/api/reports/pdf?reportId=${reportId}&template=${pdfTemplate}`; + window.open(url, '_blank'); + const student = selectedReport?.student; + if (student) { + setDownloadHistory((prev) => [{ + id: Date.now().toString(), + studentName: `${student.firstName} ${student.lastName}`, + template: pdfTemplate, + date: new Date().toLocaleDateString(), + }, ...prev]); + } + toast.success(t('reports.generate_pdf')); + } catch { + toast.error(t('error.generic')); + } finally { + setGeneratingPdf(false); + } + }; + + const handleBatchGenerate = async () => { + if (!selectedClass) return; + setBatchGenerating(true); + try { + for (const student of students) { + const url = `/api/reports/pdf?studentId=${student.id}&template=${pdfTemplate}`; + window.open(url, '_blank'); + setDownloadHistory((prev) => [{ + id: Date.now().toString() + student.id, + studentName: `${student.firstName} ${student.lastName}`, + template: pdfTemplate, + date: new Date().toLocaleDateString(), + }, ...prev]); + } + toast.success(t('reports.batch_generate')); + } catch { + toast.error(t('error.generic')); + } finally { + setBatchGenerating(false); + } + }; + const draftReports = reports.filter((r) => r.status === 'DRAFT'); const finalReports = reports.filter((r) => r.status === 'FINAL'); @@ -484,6 +534,10 @@ export default function ReportsView() { {t('pdf.print_view')} +
@@ -758,6 +812,61 @@ export default function ReportsView() { + {/* PDF Template Selection + Batch Generate */} + {selectedClass && students.length > 0 && ( + + + +
+ +
+ {t('reports.generate_pdf')} +
+
+ +
+ +
+ {(['short', 'full', 'custom'] as const).map((tmpl) => ( + + ))} +
+
+
+ +
+ {downloadHistory.length > 0 && ( +
+

{t('reports.download_history')}

+
+ {downloadHistory.map((item) => ( +
+ {item.studentName} + {item.template} · {item.date} +
+ ))} +
+
+ )} +
+
+ )} + {/* Edit section dialog */} setEditingSection(null)}> diff --git a/src/components/settings-view.tsx b/src/components/settings-view.tsx index 8b84972..8fcd59a 100644 --- a/src/components/settings-view.tsx +++ b/src/components/settings-view.tsx @@ -34,6 +34,12 @@ import { CheckCircle, Leaf, DatabaseIcon, + Upload, + FileDown, + FileUp, + Loader2, + ClipboardCheck, + CalendarDays, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -69,8 +75,12 @@ import { import { Switch } from '@/components/ui/switch'; import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; +import { Checkbox } from '@/components/ui/checkbox'; import { useAppStore } from '@/lib/store'; import { t } from '@/lib/i18n'; +import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api'; +import { toast } from 'sonner'; + import { fetchSchools, updateSchool, @@ -87,14 +97,15 @@ import { createUser, updateUser, deleteUser, + createStudentUserAccount, + bulkCreateStudentAccounts, + fetchStudents, type School as SchoolType, type SchoolYear, type Subject, type AuditLogEntry, type UserAccount, } from '@/lib/api'; -import { toast } from 'sonner'; -import { apiGet, apiPut, apiDelete } from '@/lib/api'; const containerVariants = { hidden: { opacity: 0 }, @@ -168,10 +179,28 @@ export default function SettingsView() { lastName: '', email: '', password: '', - role: 'TEACHER' as 'TEACHER' | 'SCHOOL_ADMIN' | 'SUPER_ADMIN', + role: 'TEACHER' as 'TEACHER' | 'SCHOOL_ADMIN' | 'SUPER_ADMIN' | 'STUDENT' | 'PARENT', }); const [deleteUserId, setDeleteUserId] = useState(null); + // Student account creation state + const [showStudentAccountDialog, setShowStudentAccountDialog] = useState(false); + const [showBulkStudentDialog, setShowBulkStudentDialog] = useState(false); + const [studentAccountForm, setStudentAccountForm] = useState({ + firstName: '', + lastName: '', + email: '', + password: 'Schule2025!', + studentId: '', + }); + const [bulkForm, setBulkForm] = useState({ + defaultPassword: 'Schule2025!', + emailDomain: 'schule.de', + selectedStudentIds: [] as string[], + }); + const [availableStudents, setAvailableStudents] = useState>([]); + const [bulkCreating, setBulkCreating] = useState(false); + // Demo accounts state const [demoAccounts, setDemoAccounts] = useState(null); const [deleteAllDemoOpen, setDeleteAllDemoOpen] = useState(false); + // Data import state + const [importType, setImportType] = useState<'students' | 'assessments' | 'grades'>('students'); + const [importFile, setImportFile] = useState(null); + const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = useState<{ + created: number; + skipped: number; + errorCount: number; + errors: string[]; + detectedColumns: string[]; + } | null>(null); + const [dragOver, setDragOver] = useState(false); + + // Export history + const [exportHistory, setExportHistory] = useState>([]); + const isAdmin = currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN'; const loadSchools = useCallback(async () => { @@ -388,15 +433,51 @@ export default function SettingsView() { } }; - const handleCsvExport = (type: 'students' | 'progress' | 'assessments' | 'grades') => { + const handleCsvExport = (type: 'students' | 'progress' | 'assessments' | 'grades' | 'attendance') => { downloadCsvExport({ type, schoolId: currentUser?.schoolId ?? undefined, schoolYearId: useAppStore.getState().schoolYearId ?? undefined, }); + setExportHistory((prev) => [{ type, date: new Date().toLocaleDateString() }, ...prev].slice(0, 20)); toast.success(t('settings.data_export_started')); }; + const handleImport = async () => { + if (!importFile || !currentUser?.schoolId) return; + setImporting(true); + setImportResult(null); + try { + const formData = new FormData(); + formData.append('file', importFile); + formData.append('type', importType); + formData.append('schoolId', currentUser.schoolId); + + const res = await fetch('/api/data-import', { + method: 'POST', + body: formData, + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Import failed' })); + throw new Error(err.error || 'Import failed'); + } + + const result = await res.json(); + setImportResult(result); + toast.success(t('import.success')); + setImportFile(null); + } catch (err) { + toast.error(err instanceof Error ? err.message : t('import.errors')); + } finally { + setImporting(false); + } + }; + + const handleDownloadSample = (type: 'students' | 'assessments' | 'grades') => { + window.open(`/api/data-import?type=${type}`, '_blank'); + }; + const handleErasureRequest = async () => { try { await requestDataErasure({ @@ -496,6 +577,72 @@ export default function SettingsView() { setShowUserDialog(true); }; + const openCreateStudentAccountDialog = async () => { + setStudentAccountForm({ firstName: '', lastName: '', email: '', password: 'Schule2025!', studentId: '' }); + try { + const students = await fetchStudents(currentUser?.schoolId ?? undefined); + setAvailableStudents(students.map((s) => ({ id: s.id, firstName: s.firstName, lastName: s.lastName }))); + } catch { + // ignore + } + setShowStudentAccountDialog(true); + }; + + const openBulkStudentDialog = async () => { + setBulkForm({ defaultPassword: 'Schule2025!', emailDomain: 'schule.de', selectedStudentIds: [] }); + try { + const students = await fetchStudents(currentUser?.schoolId ?? undefined); + setAvailableStudents(students.map((s) => ({ id: s.id, firstName: s.firstName, lastName: s.lastName }))); + } catch { + // ignore + } + setShowBulkStudentDialog(true); + }; + + const handleCreateStudentAccount = async () => { + try { + await createStudentUserAccount({ + schoolId: currentUser?.schoolId ?? '', + email: studentAccountForm.email, + password: studentAccountForm.password, + firstName: studentAccountForm.firstName, + lastName: studentAccountForm.lastName, + role: 'STUDENT', + studentId: studentAccountForm.studentId || undefined, + }); + toast.success(t('settings.student_account_created')); + setShowStudentAccountDialog(false); + loadUsers(); + } catch (err) { + const message = err instanceof Error ? err.message : t('error.generic'); + toast.error(message); + } + }; + + const handleBulkCreateStudents = async () => { + if (bulkForm.selectedStudentIds.length === 0) { + toast.error(t('error.generic')); + return; + } + setBulkCreating(true); + try { + const result = await bulkCreateStudentAccounts({ + schoolId: currentUser?.schoolId ?? '', + defaultPassword: bulkForm.defaultPassword, + studentIds: bulkForm.selectedStudentIds, + emailDomain: bulkForm.emailDomain, + }); + toast.success(t('settings.bulk_accounts_created') + ` (${result.count})`); + setShowBulkStudentDialog(false); + loadUsers(); + } catch (err) { + const message = err instanceof Error ? err.message : t('error.generic'); + toast.error(message); + } finally { + setBulkCreating(false); + } + }; + const openEditUserDialog = (u: UserAccount) => { setEditingUser(u); setUserForm({ @@ -503,7 +650,7 @@ export default function SettingsView() { lastName: u.lastName, email: u.email, password: '', - role: u.role as 'TEACHER' | 'SCHOOL_ADMIN' | 'SUPER_ADMIN', + role: u.role as 'TEACHER' | 'SCHOOL_ADMIN' | 'SUPER_ADMIN' | 'STUDENT' | 'PARENT', }); setShowUserDialog(true); }; @@ -1032,17 +1179,35 @@ export default function SettingsView() { {t('users.subtitle')} -
+
{t('users.count', { count: users.length })} - +
+ + + +
{usersLoading ? ( @@ -1181,7 +1346,7 @@ export default function SettingsView() { setStudentAccountForm({ ...studentAccountForm, firstName: e.target.value })} className="rounded-xl" /> +
+
+ + setStudentAccountForm({ ...studentAccountForm, lastName: e.target.value })} className="rounded-xl" /> +
+
+
+ + setStudentAccountForm({ ...studentAccountForm, email: e.target.value })} className="rounded-xl" placeholder="name@schule.de" /> +
+
+ + setStudentAccountForm({ ...studentAccountForm, password: e.target.value })} className="rounded-xl" /> +
+ {availableStudents.length > 0 && ( +
+ + +
+ )} +
+ + + + + + + + {/* Bulk Create Student Accounts Dialog */} + { if (!open) setShowBulkStudentDialog(false); }}> + + + + + {t('settings.bulk_create_students')} + + {t('auth.bulk_create')} + +
+
+ + setBulkForm({ ...bulkForm, defaultPassword: e.target.value })} className="rounded-xl" /> +
+
+ + setBulkForm({ ...bulkForm, emailDomain: e.target.value })} className="rounded-xl" placeholder="schule.de" /> +
+
+ +
+ {availableStudents.map((s) => ( + + ))} +
+
+
+ + + + +
+
@@ -1442,6 +1716,137 @@ export default function SettingsView() { {/* ── Data Management Tab ─────────────────────────────── */}
+ {/* Data Import */} + + + +
+ +
+ {t('import.title')} +
+ {t('import.csv_upload')} +
+ + {/* Import type selector */} +
+ +
+ {(['students', 'assessments', 'grades'] as const).map((impType) => { + const icons = { students: GraduationCap, assessments: ClipboardCheck, grades: BarChart3 }; + const Icon = icons[impType]; + return ( + + ); + })} +
+
+ + {/* Drag and drop zone */} +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files[0]; + if (file && file.name.endsWith('.csv')) { + setImportFile(file); + setImportResult(null); + } else { + toast.error('Please upload a CSV file'); + } + }} + > + +

+ {importFile ? importFile.name : t('import.csv_upload')} +

+

+ Drag & drop or click to select +

+ { + const file = e.target.files?.[0]; + if (file) { + setImportFile(file); + setImportResult(null); + } + }} + /> +
+ + {/* Import button */} +
+ + +
+ + {/* Import result */} + {importResult && ( +
+

{t('import.summary')}

+
+
+

{importResult.created}

+

{t('import.success')}

+
+
+

{importResult.skipped}

+

Skipped

+
+
+

{importResult.errorCount}

+

{t('import.errors')}

+
+
+ {importResult.detectedColumns.length > 0 && ( +
+ {importResult.detectedColumns.map((col) => ( + {col} + ))} +
+ )} + {importResult.errors.length > 0 && ( +
+ {importResult.errors.slice(0, 5).map((err, i) => ( +

{err}

+ ))} +
+ )} +
+ )} +
+
+ {/* CSV Export */} @@ -1454,12 +1859,13 @@ export default function SettingsView() { {t('csv.export_title')} -
+
{[ - { type: 'students' as const, iconComponent: GraduationCap, label: t('settings.data_export_students'), color: 'from-emerald-50/60 to-emerald-100/0 dark:from-emerald-900/15 dark:to-emerald-900/0', border: 'border-emerald-200/40 dark:border-emerald-900/30', iconBg: 'bg-gradient-to-br from-emerald-400 to-emerald-500' }, - { type: 'progress' as const, iconComponent: TrendingUp, label: t('settings.data_export_progress'), color: 'from-amber-50/60 to-amber-100/0 dark:from-amber-900/15 dark:to-amber-900/0', border: 'border-amber-200/40 dark:border-amber-900/30', iconBg: 'bg-gradient-to-br from-amber-400 to-amber-500' }, - { type: 'assessments' as const, iconComponent: CheckCircle, label: t('settings.data_export_assessments'), color: 'from-teal-50/60 to-teal-100/0 dark:from-teal-900/15 dark:to-teal-900/0', border: 'border-teal-200/40 dark:border-teal-900/30', iconBg: 'bg-gradient-to-br from-teal-400 to-teal-500' }, - { type: 'grades' as const, iconComponent: BarChart3, label: t('settings.data_export_grades'), color: 'from-violet-50/60 to-violet-100/0 dark:from-violet-900/15 dark:to-violet-900/0', border: 'border-violet-200/40 dark:border-violet-900/30', iconBg: 'bg-gradient-to-br from-violet-400 to-violet-500' }, + { type: 'students' as const, iconComponent: GraduationCap, label: t('export.students'), color: 'from-emerald-50/60 to-emerald-100/0 dark:from-emerald-900/15 dark:to-emerald-900/0', border: 'border-emerald-200/40 dark:border-emerald-900/30', iconBg: 'bg-gradient-to-br from-emerald-400 to-emerald-500' }, + { type: 'progress' as const, iconComponent: TrendingUp, label: t('export.progress'), color: 'from-amber-50/60 to-amber-100/0 dark:from-amber-900/15 dark:to-amber-900/0', border: 'border-amber-200/40 dark:border-amber-900/30', iconBg: 'bg-gradient-to-br from-amber-400 to-amber-500' }, + { type: 'assessments' as const, iconComponent: ClipboardCheck, label: t('export.assessments'), color: 'from-teal-50/60 to-teal-100/0 dark:from-teal-900/15 dark:to-teal-900/0', border: 'border-teal-200/40 dark:border-teal-900/30', iconBg: 'bg-gradient-to-br from-teal-400 to-teal-500' }, + { type: 'grades' as const, iconComponent: BarChart3, label: t('export.grades'), color: 'from-violet-50/60 to-violet-100/0 dark:from-violet-900/15 dark:to-violet-900/0', border: 'border-violet-200/40 dark:border-violet-900/30', iconBg: 'bg-gradient-to-br from-violet-400 to-violet-500' }, + { type: 'attendance' as const, iconComponent: CalendarDays, label: t('export.attendance'), color: 'from-rose-50/60 to-rose-100/0 dark:from-rose-900/15 dark:to-rose-900/0', border: 'border-rose-200/40 dark:border-rose-900/30', iconBg: 'bg-gradient-to-br from-rose-400 to-rose-500' }, ].map((item) => ( handleCsvExport(item.type)} > -
+

{item.label}

@@ -1476,6 +1882,21 @@ export default function SettingsView() { ))}
+ + {/* Export history */} + {exportHistory.length > 0 && ( +
+

{t('settings.export_history')}

+
+ {exportHistory.map((item, idx) => ( +
+ {item.type} + {item.date} +
+ ))} +
+
+ )} diff --git a/src/components/student-detail-view.tsx b/src/components/student-detail-view.tsx index dff8875..db20c06 100644 --- a/src/components/student-detail-view.tsx +++ b/src/components/student-detail-view.tsx @@ -16,7 +16,7 @@ import { Calculator, Flower2, BookOpen, GraduationCap, TrendingUp, Award, Printer, ChevronRight, Sparkles, MessageSquare, Grid3X3, Trophy, Flag, Zap, Rocket, Target, PenLine, Pencil, Home, ClipboardList, Star, BarChart3, - LucideIcon, Download, FileSpreadsheet, FileDown, + LucideIcon, Download, FileSpreadsheet, FileDown, Heart, Users as UsersIcon, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -32,9 +32,9 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; -import { useAppStore } from '@/lib/store'; +import { useAppStore, type CurrentUser } from '@/lib/store'; import { t } from '@/lib/i18n'; -import { fetchStudentDetail, getReportPdfUrl, type StudentDetailData } from '@/lib/api'; +import { fetchStudentDetail, getReportPdfUrl, type StudentDetailData, fetchParentLinks, type ParentStudentLinkData } from '@/lib/api'; import { toast } from 'sonner'; import TeacherNotesSection from './teacher-notes-section'; @@ -338,12 +338,24 @@ export default function StudentDetailView() { const currentStudentId = useAppStore((s) => s.currentStudentId); const navigateBack = useAppStore((s) => s.navigateBack); const setCurrentView = useAppStore((s) => s.setCurrentView); + const currentUser = useAppStore((s) => s.currentUser); + + const isStudentSelf = currentUser?.role === 'STUDENT'; + const isParent = currentUser?.role === 'PARENT'; + const [parentLinks, setParentLinks] = useState([]); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [selectedFlowerSubjectId, setSelectedFlowerSubjectId] = useState(''); const [timeRange, setTimeRange] = useState('all'); + // Load parent links for parent users + useEffect(() => { + if (isParent && currentUser?.id) { + fetchParentLinks(currentUser.id).then(setParentLinks).catch(() => {}); + } + }, [isParent, currentUser?.id]); + useEffect(() => { if (!currentStudentId) { setLoading(false); @@ -1142,6 +1154,85 @@ export default function StudentDetailView() { {/* Teacher notes — replaced with the dedicated TeacherNotesSection */} + + {/* My Progress section (for student self-view) */} + {isStudentSelf && ( + + + +
+ +
+ {t('student.my_competencies')} +
+
+ +

{t('student.progress_overview')}

+ {/* Mastery Level Visualization */} +
+ {stats.averageMastery > 0 && ( +
+
+ {t('student_detail.avg_mastery')} + {stats.averageMastery.toFixed(1)} / 4.0 +
+
+
+
+
+ )} + {stats.totalProgressEntries > 0 && ( +
+ {t('student_detail.total_entries')} + {stats.totalProgressEntries} +
+ )} + {stats.latestGrade && ( +
+ {t('student_detail.latest_grade')} + {stats.latestGrade.value.toFixed(1)} +
+ )} +
+ + + )} + + {/* Parent Info section (for parent users) */} + {isParent && parentLinks.length > 0 && ( + + + +
+ +
+ {t('parent.my_children')} +
+
+ +
+ {parentLinks.map((link) => ( +
+
+
+ {link.student.firstName[0]}{link.student.lastName[0]} +
+ {link.student.firstName} {link.student.lastName} +
+ {link.relationship && ( + + {t(`parent.${link.relationship}`)} + + )} +
+ ))} +
+
+
+ )}
); diff --git a/src/lib/api.ts b/src/lib/api.ts index faed180..f93c81b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1344,6 +1344,13 @@ export function fetchCalendarEvents(schoolId: string, month: string): Promise { @@ -1381,6 +1393,8 @@ export function createCalendarEventItem(data: { classGroupId?: string | null; notes?: string | null; allDay?: boolean; + recurrencePattern?: RecurrencePattern | null; + recurrenceEnd?: string | null; }): Promise { return apiPost('/api/calendar-events', data); } @@ -1395,12 +1409,16 @@ export function updateCalendarEventItem(id: string, data: { classGroupId?: string | null; notes?: string | null; allDay?: boolean; + recurrencePattern?: RecurrencePattern | null; + recurrenceEnd?: string | null; + editMode?: 'series' | 'instance'; }): Promise { return apiPut(`/api/calendar-events/${id}`, data); } -export function deleteCalendarEventItem(id: string): Promise<{ success: boolean }> { - return apiDelete<{ success: boolean }>(`/api/calendar-events/${id}`); +export function deleteCalendarEventItem(id: string, mode?: 'series' | 'instance'): Promise<{ success: boolean }> { + const query = mode ? `?mode=${mode}` : ''; + return apiDelete<{ success: boolean }>(`/api/calendar-events/${id}${query}`); } /* ── Lesson Plans ────────────────────────────────────────────────── */ @@ -2110,3 +2128,66 @@ export async function deleteBatchProgressEntries(ids: string[]): Promise { export async function reorderStudents(classGroupId: string, studentIds: string[]): Promise { await apiPut<{ success: boolean }>('/api/students/reorder', { classGroupId, studentIds }); } + +/* ── Parent Links ────────────────────────────────────────────────────── */ + +export interface ParentStudentLinkData { + id: string; + parentId: string; + studentId: string; + schoolId: string; + relationship: string | null; + createdAt: string; + updatedAt: string; + parent: { id: string; firstName: string; lastName: string; email: string; role: string }; + student: { id: string; firstName: string; lastName: string; dateOfBirth?: string | null; externalId?: string | null; schoolId: string; enrollments?: Array<{ classGroup: { id: string; name: string; gradeLevel: number } }> }; + school: { id: string; name: string }; +} + +export function fetchParentLinks(parentId?: string): Promise { + const params = new URLSearchParams(); + if (parentId) params.set('parentId', parentId); + return apiGet(`/api/parent-links?${params.toString()}`); +} + +export function createParentLink(data: { + parentId: string; + studentId: string; + schoolId: string; + relationship?: string; +}): Promise { + return apiPost('/api/parent-links', data); +} + +export function updateParentLink(id: string, data: { relationship?: string }): Promise { + return apiPut(`/api/parent-links/${id}`, data); +} + +export function deleteParentLink(id: string): Promise<{ message: string }> { + return apiDelete<{ message: string }>(`/api/parent-links/${id}`); +} + +/* ── Student Account Creation ────────────────────────────────────────── */ + +export function createStudentUserAccount(data: { + schoolId: string; + email: string; + password: string; + firstName: string; + lastName: string; + role: string; + studentId?: string; + locale?: string; +}): Promise> { + return apiPost>('/api/users', data); +} + +export function bulkCreateStudentAccounts(data: { + schoolId: string; + defaultPassword: string; + studentIds: string[]; + emailDomain?: string; + locale?: string; +}): Promise<{ created: Array>; count: number }> { + return apiPost<{ created: Array>; count: number }>('/api/users', { ...data, action: 'bulkCreateStudents' }); +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 37f2989..314d746 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -1767,6 +1767,8 @@ const de: TranslationDict = { 'drawing.bg_grid': 'Kariert', 'drawing.bg_dotted': 'Gepunktet', 'drawing.guide_mode': 'Hilfslinien', + 'drawing.zoom_in': 'Vergrößern', + 'drawing.zoom_out': 'Verkleinern', 'drawing.guide_shapes': 'Formvorlagen', 'drawing.guide_off': 'Aus', 'drawing.guide_basic': 'Grundformen', @@ -1927,6 +1929,23 @@ const de: TranslationDict = { 'notebooks.open_drawing': 'Zeichnung öffnen', 'notebooks.switch_to_mixed': 'Text + Zeichnung', + // Version History + 'notebooks.version_history': 'Versionsgeschichte', + 'notebooks.version_history_title': 'Versionsgeschichte', + 'notebooks.version_number': 'Version', + 'notebooks.version_current': 'Aktuell', + 'notebooks.version_preview': 'Vorschau', + 'notebooks.version_restore': 'Version wiederherstellen', + 'notebooks.version_restore_confirm': 'Version wiederherstellen?', + 'notebooks.version_restore_desc': 'Der aktuelle Inhalt wird als neue Version gespeichert, und die ausgewählte Version wird wiederhergestellt.', + 'notebooks.version_restored': 'Version wiederhergestellt', + 'notebooks.version_no_history': 'Keine Versionsgeschichte vorhanden', + 'notebooks.version_edited_by': 'Bearbeitet von', + 'notebooks.version_auto_save': 'Automatisch gespeichert', + 'notebooks.version_summary': 'Beschreibung', + 'notebooks.version_created': 'Erstellt', + 'notebooks.view_drawing': 'Zeichnung anzeigen', + // PDF Export 'notebooks.export_pdf': 'PDF exportieren', 'notebooks.export_error': 'Fehler beim Exportieren. Bitte Popups erlauben.', @@ -2022,6 +2041,7 @@ const de: TranslationDict = { 'nav.student_notebooks': 'Meine Hefte', 'nav.student_attendance': 'Meine Anwesenheit', 'role.student': 'Schueler', + 'role.parent': 'Elternteil', // Calendar (enhanced - Task 4) 'calendar.new_event': 'Neues Ereignis', @@ -2043,6 +2063,40 @@ const de: TranslationDict = { 'calendar.event_create_error': 'Fehler beim Erstellen des Ereignisses', 'calendar.event_update_error': 'Fehler beim Aktualisieren des Ereignisses', 'calendar.event_delete_error': 'Fehler beim Löschen des Ereignisses', + 'calendar.recurring': 'Wiederkehrend', + 'calendar.repeat_none': 'Nicht wiederkehrend', + 'calendar.repeat_daily': 'Täglich', + 'calendar.repeat_weekly': 'Wöchentlich', + 'calendar.repeat_monthly': 'Monatlich', + 'calendar.repeat_yearly': 'Jährlich', + 'calendar.repeat_end': 'Endet', + 'calendar.repeat_end_never': 'Nie', + 'calendar.repeat_end_on': 'Am Datum', + 'calendar.repeat_end_after': 'Nach {count} Wiederholungen', + 'calendar.repeat_interval': 'Intervall: {interval}', + 'calendar.repeat_weekdays': 'Wochentage', + 'calendar.edit_series': 'Serien bearbeiten', + 'calendar.edit_instance': 'Einzelnes Ereignis bearbeiten', + 'reports.generate_pdf': 'PDF-Bericht erstellen', + 'reports.template_short': 'Kurzbericht', + 'reports.template_full': 'Vollständiger Bericht', + 'reports.template_custom': 'Individuell', + 'reports.template': 'Berichtsvorlage', + 'reports.batch_generate': 'Für gesamte Klasse erstellen', + 'reports.download_history': 'Download-Historie', + 'import.title': 'Datenimport', + 'import.csv_upload': 'CSV-Datei hochladen', + 'import.format_preview': 'Formatvoransicht', + 'import.progress': 'Importfortschritt', + 'import.summary': 'Importübersicht', + 'import.success': 'Erfolgreich importiert', + 'import.errors': 'Importfehler', + 'import.sample_csv': 'Beispiel-CSV herunterladen', + 'export.students': 'Schüler exportieren', + 'export.assessments': 'Bewertungen exportieren', + 'export.grades': 'Noten exportieren', + 'export.attendance': 'Anwesenheit exportieren', + 'export.progress': 'Fortschritt exportieren', // Dashboard (enhanced - Task 4) 'dashboard.new_assessment': 'Neue Bewertung', @@ -2057,6 +2111,55 @@ const de: TranslationDict = { // Student (enhanced - Task 4) 'student.shared_with_me': 'Mit mir geteilt', + + // Student Login & Parent Portal (Task 6) + 'auth.student_login': 'Schüler-Login', + 'auth.parent_login': 'Eltern-Login', + 'auth.create_student_account': 'Schülerkonto erstellen', + 'auth.bulk_create': 'Massenimport', + 'auth.school_id': 'Schul-ID', + 'auth.forgot_password': 'Passwort vergessen', + 'auth.student_welcome': 'Willkommen, Schüler', + 'auth.parent_welcome': 'Willkommen, Eltern', + 'auth.login_as_student': 'Als Schüler anmelden', + 'auth.login_as_parent': 'Als Elternteil anmelden', + 'auth.student_login_desc': 'Mit Schul-ID oder E-Mail anmelden', + 'auth.parent_login_desc': 'Mit E-Mail und Passwort anmelden', + 'auth.default_password_set': 'Standardpasswort gesetzt', + 'auth.bulk_accounts_created': 'Schülerkonten erstellt', + 'parent.my_children': 'Meine Kinder', + 'parent.messages': 'Nachrichten', + 'parent.progress': 'Fortschritt', + 'parent.attendance': 'Anwesenheit', + 'parent.relationship': 'Beziehung', + 'parent.father': 'Vater', + 'parent.mother': 'Mutter', + 'parent.guardian': 'Erziehungsberechtigte(r)', + 'parent.dashboard_title': 'Eltern-Dashboard', + 'parent.welcome_back': 'Willkommen zurück', + 'parent.recent_progress': 'Letzter Fortschritt', + 'parent.upcoming_assessments': 'Anstehende Überprüfungen', + 'parent.messages_from_teachers': 'Nachrichten von Lehrern', + 'parent.attendance_summary': 'Anwesenheitsübersicht', + 'parent.no_children': 'Keine Kinder verknüpft', + 'parent.no_children_desc': 'Es sind noch keine Schüler mit Ihrem Konto verknüpft.', + 'parent.link_student': 'Schüler verknüpfen', + 'parent.add_link': 'Verknüpfung hinzufügen', + 'parent.remove_link': 'Verknüpfung entfernen', + 'parent.select_student': 'Schüler auswählen', + 'parent.select_relationship': 'Beziehung auswählen', + 'parent.view_progress': 'Fortschritt anzeigen', + 'parent.view_attendance': 'Anwesenheit anzeigen', + 'parent.no_messages': 'Keine Nachrichten', + 'parent.no_assessments': 'Keine anstehenden Überprüfungen', + 'parent.child_progress_desc': 'Fortschritt der letzten 30 Tage', + 'settings.create_student_account': 'Schülerkonto erstellen', + 'settings.bulk_create_students': 'Schülerkonten-Massenimport', + 'settings.student_account_created': 'Schülerkonto wurde erstellt', + 'settings.bulk_accounts_created': 'Schülerkonten wurden erstellt', + 'settings.default_password': 'Standardpasswort', + 'settings.generate_email': 'E-Mail automatisch generieren', + 'settings.link_student': 'Mit Schüler verknüpfen', }; const en: TranslationDict = { @@ -3823,6 +3926,8 @@ const en: TranslationDict = { 'drawing.bg_grid': 'Grid', 'drawing.bg_dotted': 'Dotted', 'drawing.guide_mode': 'Guide Mode', + 'drawing.zoom_in': 'Zoom In', + 'drawing.zoom_out': 'Zoom Out', 'drawing.guide_shapes': 'Shape Templates', 'drawing.guide_off': 'Off', 'drawing.guide_basic': 'Basic Shapes', @@ -3983,6 +4088,23 @@ const en: TranslationDict = { 'notebooks.open_drawing': 'Open drawing', 'notebooks.switch_to_mixed': 'Text + Drawing', + // Version History + 'notebooks.version_history': 'Version History', + 'notebooks.version_history_title': 'Version History', + 'notebooks.version_number': 'Version', + 'notebooks.version_current': 'Current', + 'notebooks.version_preview': 'Preview', + 'notebooks.version_restore': 'Restore version', + 'notebooks.version_restore_confirm': 'Restore this version?', + 'notebooks.version_restore_desc': 'The current content will be saved as a new version, and the selected version will be restored.', + 'notebooks.version_restored': 'Version restored', + 'notebooks.version_no_history': 'No version history available', + 'notebooks.version_edited_by': 'Edited by', + 'notebooks.version_auto_save': 'Auto-saved', + 'notebooks.version_summary': 'Summary', + 'notebooks.version_created': 'Created', + 'notebooks.view_drawing': 'View drawing', + // PDF Export 'notebooks.export_pdf': 'Export PDF', 'notebooks.export_error': 'Export error. Please allow popups.', @@ -4078,6 +4200,7 @@ const en: TranslationDict = { 'nav.student_notebooks': 'My Notebooks', 'nav.student_attendance': 'My Attendance', 'role.student': 'Student', + 'role.parent': 'Parent', // Calendar (enhanced - Task 4) 'calendar.new_event': 'New Event', @@ -4099,6 +4222,40 @@ const en: TranslationDict = { 'calendar.event_create_error': 'Error creating event', 'calendar.event_update_error': 'Error updating event', 'calendar.event_delete_error': 'Error deleting event', + 'calendar.recurring': 'Recurring', + 'calendar.repeat_none': 'Does not repeat', + 'calendar.repeat_daily': 'Daily', + 'calendar.repeat_weekly': 'Weekly', + 'calendar.repeat_monthly': 'Monthly', + 'calendar.repeat_yearly': 'Yearly', + 'calendar.repeat_end': 'Ends', + 'calendar.repeat_end_never': 'Never', + 'calendar.repeat_end_on': 'On date', + 'calendar.repeat_end_after': 'After {count} occurrences', + 'calendar.repeat_interval': 'Interval: {interval}', + 'calendar.repeat_weekdays': 'Days of week', + 'calendar.edit_series': 'Edit series', + 'calendar.edit_instance': 'Edit this instance', + 'reports.generate_pdf': 'Generate PDF Report', + 'reports.template_short': 'Short Report', + 'reports.template_full': 'Full Report', + 'reports.template_custom': 'Custom', + 'reports.template': 'Report Template', + 'reports.batch_generate': 'Generate for entire class', + 'reports.download_history': 'Download History', + 'import.title': 'Data Import', + 'import.csv_upload': 'Upload CSV File', + 'import.format_preview': 'Format Preview', + 'import.progress': 'Import Progress', + 'import.summary': 'Import Summary', + 'import.success': 'Successfully imported', + 'import.errors': 'Import Errors', + 'import.sample_csv': 'Download Sample CSV', + 'export.students': 'Export Students', + 'export.assessments': 'Export Assessments', + 'export.grades': 'Export Grades', + 'export.attendance': 'Export Attendance', + 'export.progress': 'Export Progress', // Dashboard (enhanced - Task 4) 'dashboard.new_assessment': 'New Assessment', @@ -4113,6 +4270,55 @@ const en: TranslationDict = { // Student (enhanced - Task 4) 'student.shared_with_me': 'Shared with me', + + // Student Login & Parent Portal (Task 6) + 'auth.student_login': 'Student Login', + 'auth.parent_login': 'Parent Login', + 'auth.create_student_account': 'Create Student Account', + 'auth.bulk_create': 'Bulk Create', + 'auth.school_id': 'School ID', + 'auth.forgot_password': 'Forgot Password', + 'auth.student_welcome': 'Welcome, Student', + 'auth.parent_welcome': 'Welcome, Parent', + 'auth.login_as_student': 'Login as Student', + 'auth.login_as_parent': 'Login as Parent', + 'auth.student_login_desc': 'Login with school ID or email', + 'auth.parent_login_desc': 'Login with email and password', + 'auth.default_password_set': 'Default password set', + 'auth.bulk_accounts_created': 'Student accounts created', + 'parent.my_children': 'My Children', + 'parent.messages': 'Messages', + 'parent.progress': 'Progress', + 'parent.attendance': 'Attendance', + 'parent.relationship': 'Relationship', + 'parent.father': 'Father', + 'parent.mother': 'Mother', + 'parent.guardian': 'Guardian', + 'parent.dashboard_title': 'Parent Dashboard', + 'parent.welcome_back': 'Welcome back', + 'parent.recent_progress': 'Recent Progress', + 'parent.upcoming_assessments': 'Upcoming Assessments', + 'parent.messages_from_teachers': 'Messages from Teachers', + 'parent.attendance_summary': 'Attendance Summary', + 'parent.no_children': 'No Children Linked', + 'parent.no_children_desc': 'No students are linked to your account yet.', + 'parent.link_student': 'Link Student', + 'parent.add_link': 'Add Link', + 'parent.remove_link': 'Remove Link', + 'parent.select_student': 'Select Student', + 'parent.select_relationship': 'Select Relationship', + 'parent.view_progress': 'View Progress', + 'parent.view_attendance': 'View Attendance', + 'parent.no_messages': 'No Messages', + 'parent.no_assessments': 'No Upcoming Assessments', + 'parent.child_progress_desc': 'Progress over the last 30 days', + 'settings.create_student_account': 'Create Student Account', + 'settings.bulk_create_students': 'Bulk Create Student Accounts', + 'settings.student_account_created': 'Student account created', + 'settings.bulk_accounts_created': 'Student accounts created', + 'settings.default_password': 'Default Password', + 'settings.generate_email': 'Auto-generate Email', + 'settings.link_student': 'Link to Student', }; const translations: Record = { de, en };