Round 11: DrawingCanvas Integration, Notebook Versioning, Student Auth, Parent Portal, Calendar Recurring, Grade PDF, Data Import/Export

This commit is contained in:
Mustafa Cetin committed 2026-07-29 09:15:59 +00:00
1 parent b90ad85b91
commit c3d36aac67
22 files changed
+3918 -248

No files matched your search

+45
View File
@@ -39,6 +39,7 @@ model School {
drawings Drawing[] drawings Drawing[]
notifications Notification[] notifications Notification[]
calendarEvents CalendarEvent[] calendarEvents CalendarEvent[]
parentStudentLinks ParentStudentLink[]
} }
model User { model User {
@@ -75,6 +76,7 @@ model User {
drawings Drawing[] drawings Drawing[]
notifications Notification[] notifications Notification[]
calendarEvents CalendarEvent[] calendarEvents CalendarEvent[]
parentLinks ParentStudentLink[]
} }
model SchoolYear { model SchoolYear {
@@ -156,6 +158,7 @@ model Student {
parentContacts ParentContact[] parentContacts ParentContact[]
parentMessages ParentMessage[] parentMessages ParentMessage[]
behaviorIncidents BehaviorIncident[] behaviorIncidents BehaviorIncident[]
parentStudentLinks ParentStudentLink[]
} }
model Enrollment { model Enrollment {
@@ -775,11 +778,28 @@ model NotebookPage {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade) notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade)
versions NotebookPageVersion[]
@@index([notebookId]) @@index([notebookId])
@@index([pageNumber]) @@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 ──────────────────────────────────────────────── // ─── Drawing Canvas ────────────────────────────────────────────────
model Drawing { model Drawing {
@@ -844,6 +864,9 @@ model CalendarEvent {
classGroupId String? classGroupId String?
notes String? notes String?
allDay Boolean @default(false) 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -851,10 +874,32 @@ model CalendarEvent {
teacher User @relation(fields: [teacherId], references: [id]) teacher User @relation(fields: [teacherId], references: [id])
subject Subject? @relation(fields: [subjectId], references: [id]) subject Subject? @relation(fields: [subjectId], references: [id])
classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) classGroup ClassGroup? @relation(fields: [classGroupId], references: [id])
parentEvent CalendarEvent? @relation("EventRecurrence", fields: [parentEventId], references: [id])
childEvents CalendarEvent[] @relation("EventRecurrence")
@@index([schoolId]) @@index([schoolId])
@@index([teacherId]) @@index([teacherId])
@@index([date]) @@index([date])
@@index([subjectId]) @@index([subjectId])
@@index([classGroupId]) @@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")
} }
+212 -4
View File
@@ -1,6 +1,107 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getSession } from '@/lib/auth'; 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 }> }) { export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
try { try {
@@ -11,13 +112,21 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
const { id } = await params; const { id } = await params;
const body = await request.json(); 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) { if (!existing) {
return NextResponse.json({ error: 'Event not found' }, { status: 404 }); 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({ const event = await db.calendarEvent.update({
where: { id }, where: { id },
data: { data: {
@@ -36,8 +145,74 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
classGroup: { select: { id: true, name: true } }, classGroup: { select: { id: true, name: true } },
}, },
}); });
return NextResponse.json(event); 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: {
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,
recurrencePattern: parsedPattern ? JSON.stringify(parsedPattern) : null,
recurrenceEnd: parsedRecurrenceEnd,
},
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true } },
},
});
// 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) { } catch (error) {
console.error('CalendarEvent PUT error:', error); console.error('CalendarEvent PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 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 { 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) { if (!existing) {
return NextResponse.json({ error: 'Event not found' }, { status: 404 }); 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 } }); await db.calendarEvent.delete({ where: { id } });
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
+167 -2
View File
@@ -1,6 +1,111 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getSession } from '@/lib/auth'; 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) { export async function POST(request: Request) {
try { try {
@@ -10,12 +115,24 @@ export async function POST(request: Request) {
} }
const body = await request.json(); 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) { if (!title || !date || !schoolId) {
return NextResponse.json({ error: 'title, date, and schoolId are required' }, { status: 400 }); 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({ const event = await db.calendarEvent.create({
data: { data: {
schoolId, schoolId,
@@ -29,14 +146,59 @@ export async function POST(request: Request) {
classGroupId: classGroupId || null, classGroupId: classGroupId || null,
notes: notes || null, notes: notes || null,
allDay: allDay ?? false, allDay: allDay ?? false,
recurrencePattern: parsedPattern ? JSON.stringify(parsedPattern) : null,
recurrenceEnd: parsedRecurrenceEnd,
}, },
include: { include: {
subject: { select: { id: true, name: true } }, subject: { select: { id: true, name: true } },
classGroup: { 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) { } catch (error) {
console.error('CalendarEvent POST error:', error); console.error('CalendarEvent POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
@@ -81,6 +243,9 @@ export async function GET(request: Request) {
include: { include: {
subject: { select: { id: true, name: true } }, subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true } }, classGroup: { select: { id: true, name: true } },
childEvents: {
select: { id: true, date: true },
},
}, },
orderBy: { date: 'asc' }, orderBy: { date: 'asc' },
}); });
+41
View File
@@ -253,6 +253,47 @@ export async function GET(request: Request) {
} }
filename = `grades_export.csv`; filename = `grades_export.csv`;
} else if (type === 'attendance') {
// Attendance CSV
const where: Record<string, unknown> = {};
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 // Create audit log for export
+256
View File
@@ -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 });
}
}
@@ -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 });
}
}
+163
View File
@@ -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 });
}
}
+218
View File
@@ -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<string, unknown> = {};
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 });
}
}
+206 -37
View File
@@ -11,15 +11,21 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const reportId = searchParams.get('reportId'); const reportId = searchParams.get('reportId');
const studentId = searchParams.get('studentId');
const template = searchParams.get('template') ?? 'full'; // short, full, custom
if (!reportId) { if (!reportId && !studentId) {
return NextResponse.json({ error: 'reportId is required' }, { status: 400 }); return NextResponse.json({ error: 'reportId or studentId is required' }, { status: 400 });
} }
const report = await db.report.findUnique({ let report;
if (reportId) {
report = await db.report.findUnique({
where: { id: reportId }, where: { id: reportId },
include: { include: {
student: { select: { id: true, firstName: true, lastName: true } }, student: {
select: { id: true, firstName: true, lastName: true },
},
classGroup: { select: { id: true, name: true, gradeLevel: true } }, classGroup: { select: { id: true, name: true, gradeLevel: true } },
schoolYear: { select: { id: true, label: true } }, schoolYear: { select: { id: true, label: true } },
generatedByUser: { select: { id: true, firstName: true, lastName: true } }, generatedByUser: { select: { id: true, firstName: true, lastName: true } },
@@ -31,13 +37,64 @@ export async function GET(request: Request) {
}, },
}, },
}); });
} 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) { if (!report) {
return NextResponse.json({ error: 'Report not found' }, { status: 404 }); return NextResponse.json({ error: 'Report not found' }, { status: 404 });
} }
// Generate a print-friendly HTML page const htmlContent = generatePrintHtml(report, template);
const htmlContent = generatePrintHtml(report);
return new NextResponse(htmlContent, { return new NextResponse(htmlContent, {
headers: { headers: {
@@ -50,7 +107,7 @@ export async function GET(request: Request) {
} }
} }
function generatePrintHtml(report: Record<string, unknown>): string { function generatePrintHtml(report: Record<string, unknown>, template: string): string {
const student = report.student as { firstName: string; lastName: string }; const student = report.student as { firstName: string; lastName: string };
const classGroup = report.classGroup as { name: string; gradeLevel: number }; const classGroup = report.classGroup as { name: string; gradeLevel: number };
const schoolYear = report.schoolYear as { label: string }; const schoolYear = report.schoolYear as { label: string };
@@ -61,6 +118,9 @@ function generatePrintHtml(report: Record<string, unknown>): string {
competencyCategory: { name: string; color: string | null } | null; competencyCategory: { name: string; color: string | null } | null;
}>).sort((a, b) => a.order - b.order); }>).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 sectionsHtml = sections.map((s) => {
const catName = s.competencyCategory?.name ?? ''; const catName = s.competencyCategory?.name ?? '';
const catColor = s.competencyCategory?.color ?? '#10b981'; const catColor = s.competencyCategory?.color ?? '#10b981';
@@ -72,6 +132,43 @@ function generatePrintHtml(report: Record<string, unknown>): string {
`; `;
}).join('\n'); }).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
? `
<div class="grades-section">
<h3 class="section-title" style="color: #10b981">Notenubersicht</h3>
<table class="grades-table">
<thead>
<tr>
<th>Uberprufung</th>
<th>Typ</th>
<th>Punkte</th>
<th>Note</th>
</tr>
</thead>
<tbody>
${studentData.assessmentResults!.map((r) => `
<tr>
<td>${r.assessment.title}</td>
<td>${r.assessment.type}</td>
<td>${r.score ?? '—'}</td>
<td>${r.grade ?? '—'}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`
: '';
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="de"> <html lang="de">
<head> <head>
@@ -86,23 +183,53 @@ function generatePrintHtml(report: Record<string, unknown>): string {
max-width: 800px; max-width: 800px;
margin: 40px auto; margin: 40px auto;
padding: 20px; padding: 20px;
position: relative;
} }
.header { .header {
text-align: center; display: flex;
align-items: center;
gap: 20px;
border-bottom: 3px solid #10b981; border-bottom: 3px solid #10b981;
padding-bottom: 20px; padding-bottom: 20px;
margin-bottom: 30px; margin-bottom: 30px;
} }
.header h1 { .avatar {
color: #10b981; width: 64px;
font-size: 28px; height: 64px;
margin-bottom: 8px; 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; color: #666;
font-size: 18px; font-size: 18px;
margin-bottom: 6px; 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 { .meta {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -116,20 +243,48 @@ function generatePrintHtml(report: Record<string, unknown>): string {
color: #333; color: #333;
} }
.section { .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; margin-bottom: 24px;
padding: 16px; padding: 16px;
background: #f9fafb; background: #f9fafb;
border-radius: 8px; border-radius: 8px;
border-left: 4px solid #10b981; border-left: 4px solid #10b981;
} }
.section-title { .grades-table {
font-size: 16px; width: 100%;
font-weight: 600; border-collapse: collapse;
margin-bottom: 8px; margin-top: 10px;
font-size: 13px;
} }
.section-text { .grades-table th {
font-size: 14px; text-align: left;
color: #444; 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 { .footer {
text-align: center; text-align: center;
@@ -139,25 +294,29 @@ function generatePrintHtml(report: Record<string, unknown>): string {
padding-top: 16px; padding-top: 16px;
margin-top: 40px; margin-top: 40px;
} }
.badge { .eco-footer {
display: inline-block; text-align: center;
padding: 2px 8px; font-size: 11px;
border-radius: 4px; color: #10b981;
font-size: 12px; margin-top: 8px;
font-weight: 600; font-style: italic;
} }
.badge-draft { .watermark {
background: #fef3c7; position: fixed;
color: #92400e; bottom: 50%;
} right: -60px;
.badge-final { transform: rotate(-45deg);
background: #d1fae5; font-size: 72px;
color: #065f46; color: rgba(16, 185, 129, 0.04);
font-weight: 900;
pointer-events: none;
z-index: -1;
} }
@media print { @media print {
body { margin: 0; padding: 20px; } body { margin: 0; padding: 20px; }
.no-print { display: none; } .no-print { display: none; }
.section { break-inside: avoid; } .section { break-inside: avoid; }
.grades-section { break-inside: avoid; }
} }
.print-btn { .print-btn {
position: fixed; position: fixed;
@@ -177,27 +336,37 @@ function generatePrintHtml(report: Record<string, unknown>): string {
</style> </style>
</head> </head>
<body> <body>
<div class="watermark">CompetenceTrack</div>
<button class="print-btn no-print" onclick="window.print()">Drucken / Print</button> <button class="print-btn no-print" onclick="window.print()">Drucken / Print</button>
<div class="header"> <div class="header">
<div class="avatar">${initials}</div>
<div class="header-text">
<h1>Kompetenzbericht</h1> <h1>Kompetenzbericht</h1>
<h2>${student.firstName} ${student.lastName}</h2> <h2>${student.firstName} ${student.lastName}</h2>
<span class="badge ${String(report.status) === 'FINAL' ? 'badge-final' : 'badge-draft'}">${String(report.status)}</span> <span class="template-badge ${template === 'short' ? 'badge-short' : template === 'custom' ? 'badge-custom' : 'badge-full'}">${template === 'short' ? 'Kurzbericht' : template === 'custom' ? 'Individuell' : 'Vollstandig'}</span>
<span class="template-badge ${String(report.status) === 'FINAL' ? 'badge-final' : 'badge-draft'}" style="margin-left: 6px">${String(report.status)}</span>
</div>
</div> </div>
<div class="meta"> <div class="meta">
<div><dt>Klasse:</dt> <dd>${classGroup.name} (Jahrgang ${classGroup.gradeLevel})</dd></div> <div><dt>Klasse:</dt> <dd>${classGroup.name}${classGroup.gradeLevel ? ` (Jahrgang ${classGroup.gradeLevel})` : ''}</dd></div>
<div><dt>Schuljahr:</dt> <dd>${schoolYear.label}</dd></div> <div><dt>Schuljahr:</dt> <dd>${schoolYear.label}</dd></div>
<div><dt>Zeitraum:</dt> <dd>${String(report.period)}</dd></div> <div><dt>Zeitraum:</dt> <dd>${String(report.period)}</dd></div>
<div><dt>Erstellt von:</dt> <dd>${generatedByUser.firstName} ${generatedByUser.lastName}</dd></div> <div><dt>Erstellt von:</dt> <dd>${generatedByUser.firstName} ${generatedByUser.lastName}</dd></div>
<div><dt>Datum:</dt> <dd>${new Date(String(report.generatedAt)).toLocaleDateString()}</dd></div> <div><dt>Datum:</dt> <dd>${new Date(String(report.generatedAt)).toLocaleDateString('de-DE')}</dd></div>
<div><dt>Noten inklusive:</dt> <dd>${String(report.includesGrades) === 'true' ? 'Ja' : 'Nein'}</dd></div> <div><dt>Noten inklusive:</dt> <dd>${String(report.includesGrades) === 'true' ? 'Ja' : 'Nein'}</dd></div>
</div> </div>
${sectionsHtml} ${sectionsHtml}
${gradesHtml}
<div class="footer"> <div class="footer">
<p>CompetenceTrack — Kompetenzbericht · ${new Date().toLocaleDateString()}</p> <p>CompetenceTrack — Kompetenzbericht · ${new Date().toLocaleDateString('de-DE')}</p>
</div>
<div class="eco-footer">
Digital erstellt — Papier sparen, Umwelt schutzen
</div> </div>
</body> </body>
</html>`; </html>`;
+149 -7
View File
@@ -6,10 +6,19 @@ import { getSession, hashPassword } from '@/lib/auth';
const createUserSchema = z.object({ const createUserSchema = z.object({
schoolId: z.string().nullable().optional(), schoolId: z.string().nullable().optional(),
email: z.string().email().min(1), email: z.string().email().min(1),
password: z.string().min(8), password: z.string().min(6),
firstName: z.string().min(1), firstName: z.string().min(1),
lastName: 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'), locale: z.string().default('de'),
}); });
@@ -22,13 +31,15 @@ export async function GET(request: Request) {
if ( if (
session.user?.role !== 'SUPER_ADMIN' && 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 }); return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId'); const schoolId = searchParams.get('schoolId');
const roleFilter = searchParams.get('role');
const where: Record<string, unknown> = { deletedAt: null }; const where: Record<string, unknown> = { deletedAt: null };
@@ -39,6 +50,10 @@ export async function GET(request: Request) {
where.schoolId = schoolId; where.schoolId = schoolId;
} }
if (roleFilter) {
where.role = roleFilter;
}
const users = await db.user.findMany({ const users = await db.user.findMany({
where, where,
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }], 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: { _count: {
select: { select: {
classGroupTeachers: true, classGroupTeachers: true,
learningProgressEntries: true, learningProgressEntries: true,
assessments: true, assessments: true,
parentLinks: true,
}, },
}, },
}, },
@@ -81,14 +107,117 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
} }
const body = await request.json();
// ── Bulk student account creation ──
if (body.action === 'bulkCreateStudents') {
if ( if (
session.user?.role !== 'SUPER_ADMIN' && 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 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<Record<string, unknown>> = [];
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 !== 'TEACHER'
) { ) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
} }
const body = await request.json();
const parsed = createUserSchema.safeParse(body); const parsed = createUserSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json( 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 // School admins can only create users in their own school
if ( if (
session.user?.role === 'SCHOOL_ADMIN' && session.user?.role === 'SCHOOL_ADMIN' &&
session.user.schoolId && session.user?.schoolId &&
userData.schoolId && userData.schoolId &&
userData.schoolId !== session.user.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 // Create audit log entry
await db.auditLog.create({ await db.auditLog.create({
data: { data: {
@@ -160,6 +301,7 @@ export async function POST(request: Request) {
role: user.role, role: user.role,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
linkedStudentId: studentId ?? null,
}), }),
}, },
}); });
+121
View File
@@ -260,6 +260,45 @@
} }
/* Print styles — full print stylesheet lives at end of file */ /* 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 */ /* Focus ring for accessibility */
@@ -777,6 +816,88 @@
background: oklch(0.55 0.22 25 / 0.06); 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 ─────────────────────────────────── */ /* ─── Status Indicator Dot Pulse ─────────────────────────────────── */
@keyframes status-pulse { @keyframes status-pulse {
0%, 100% { opacity: 1; transform: scale(1); } 0%, 100% { opacity: 1; transform: scale(1); }
+17 -2
View File
@@ -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) { function renderView(view: ViewName) {
switch (view) { switch (view) {
case 'dashboard': return <DashboardView />; case 'dashboard': return <DashboardView />;
@@ -264,10 +278,11 @@ export default function AppLayout() {
? `${currentUser.firstName} ${currentUser.lastName}` ? `${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 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')); const isDemoUser = currentUser?.isDemo === true || (currentUser?.email?.endsWith('@competencetrack.org') && currentUser?.email?.startsWith('demo'));
+176 -132
View File
@@ -2,7 +2,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; 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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -14,6 +14,8 @@ import { t } from '@/lib/i18n';
import { login, register } from '@/lib/api'; import { login, register } from '@/lib/api';
import { toast } from 'sonner'; import { toast } from 'sonner';
type LoginRole = 'teacher' | 'student' | 'parent';
const DEMO_ACCOUNTS = [ 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@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' }, { 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() { export default function AuthView() {
const [mode, setMode] = useState<'login' | 'register'>('login'); const [mode, setMode] = useState<'login' | 'register'>('login');
const [loginRole, setLoginRole] = useState<LoginRole>('teacher');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [firstName, setFirstName] = useState(''); const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState(''); const [lastName, setLastName] = useState('');
const [schoolIdInput, setSchoolIdInput] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [demoLoading, setDemoLoading] = useState(false); const [demoLoading, setDemoLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [rememberMe, setRememberMe] = useState(true); const [rememberMe, setRememberMe] = useState(true);
const [showForgotInfo, setShowForgotInfo] = useState(false);
const setCurrentUser = useAppStore((s) => s.setCurrentUser); const setCurrentUser = useAppStore((s) => s.setCurrentUser);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@@ -41,7 +46,13 @@ export default function AuthView() {
if (mode === 'login') { if (mode === 'login') {
const user = await login(email, password); const user = await login(email, password);
setCurrentUser(user); setCurrentUser(user);
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')); toast.success(t('auth.welcome_back'));
}
} else { } else {
const user = await register({ const user = await register({
email, email,
@@ -72,6 +83,7 @@ export default function AuthView() {
setPassword(''); setPassword('');
setFirstName(''); setFirstName('');
setLastName(''); setLastName('');
setSchoolIdInput('');
}; };
const handleDemoLogin = async (demoEmail: string, demoPassword: string) => { const handleDemoLogin = async (demoEmail: string, demoPassword: string) => {
@@ -83,7 +95,13 @@ export default function AuthView() {
try { try {
const user = await login(demoEmail, demoPassword); const user = await login(demoEmail, demoPassword);
setCurrentUser(user); setCurrentUser(user);
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')); toast.success(t('auth.welcome_back'));
}
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : t('auth.error.generic'); const message = err instanceof Error ? err.message : t('auth.error.generic');
if (message.includes('credentials') || message.includes('Invalid')) { 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 ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50 dark:from-gray-950 dark:via-gray-900 dark:to-emerald-950 p-4 md:p-8 relative overflow-hidden"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50 dark:from-gray-950 dark:via-gray-900 dark:to-emerald-950 p-4 md:p-8 relative overflow-hidden">
{/* Animated floating geometric shapes background */} {/* Animated floating geometric shapes background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none"> <div className="absolute inset-0 overflow-hidden pointer-events-none">
{/* Large emerald circle */}
<div className="absolute top-[15%] left-[10%] w-32 h-32 rounded-full bg-emerald-200/30 dark:bg-emerald-800/10 animate-float-slow" /> <div className="absolute top-[15%] left-[10%] w-32 h-32 rounded-full bg-emerald-200/30 dark:bg-emerald-800/10 animate-float-slow" />
{/* Medium teal square */}
<div className="absolute top-[60%] right-[20%] w-20 h-20 rounded-xl bg-teal-200/25 dark:bg-teal-800/10 animate-float" /> <div className="absolute top-[60%] right-[20%] w-20 h-20 rounded-xl bg-teal-200/25 dark:bg-teal-800/10 animate-float" />
{/* Small amber circle */}
<div className="absolute bottom-[25%] left-[25%] w-14 h-14 rounded-full bg-amber-200/20 dark:bg-amber-800/10 animate-float-reverse" /> <div className="absolute bottom-[25%] left-[25%] w-14 h-14 rounded-full bg-amber-200/20 dark:bg-amber-800/10 animate-float-reverse" />
{/* Tiny emerald dot */}
<div className="absolute top-[40%] right-[40%] w-8 h-8 rounded-full bg-emerald-300/15 dark:bg-emerald-700/10 animate-float" /> <div className="absolute top-[40%] right-[40%] w-8 h-8 rounded-full bg-emerald-300/15 dark:bg-emerald-700/10 animate-float" />
{/* Medium violet square */}
<div className="absolute top-[80%] left-[60%] w-16 h-16 rounded-lg bg-violet-200/20 dark:bg-violet-800/10 animate-float-reverse" /> <div className="absolute top-[80%] left-[60%] w-16 h-16 rounded-lg bg-violet-200/20 dark:bg-violet-800/10 animate-float-reverse" />
{/* Small teal circle */}
<div className="absolute top-[30%] right-[70%] w-12 h-12 rounded-full bg-teal-200/20 dark:bg-teal-800/10 animate-float-slow" /> <div className="absolute top-[30%] right-[70%] w-12 h-12 rounded-full bg-teal-200/20 dark:bg-teal-800/10 animate-float-slow" />
{/* Dot grid overlay */}
<div className="absolute inset-0 bg-dots opacity-30 dark:opacity-10" /> <div className="absolute inset-0 bg-dots opacity-30 dark:opacity-10" />
</div> </div>
{/* Left illustration side (hidden on small screens) */} {/* Left illustration side (hidden on small screens) */}
<motion.div <motion.div
initial={{ opacity: 0, x: -30 }} initial={{ opacity: 0, x: -30 }}
@@ -123,83 +144,52 @@ export default function AuthView() {
className="hidden lg:flex flex-col items-center justify-center w-1/2 max-w-md mr-8" className="hidden lg:flex flex-col items-center justify-center w-1/2 max-w-md mr-8"
> >
<div className="relative"> <div className="relative">
{/* Decorative circles */} <motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ duration: 0.5, delay: 0.3 }} className="absolute -top-6 -left-6 w-20 h-20 rounded-full bg-emerald-200/50 dark:bg-emerald-800/30" />
<motion.div <motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ duration: 0.5, delay: 0.4 }} className="absolute -bottom-8 -right-8 w-24 h-24 rounded-full bg-teal-200/50 dark:bg-teal-800/30" />
initial={{ scale: 0 }} <motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ duration: 0.5, delay: 0.5 }} className="absolute top-12 -right-4 w-12 h-12 rounded-full bg-amber-200/50 dark:bg-amber-800/30" />
animate={{ scale: 1 }} {/* Main icon changes based on login role */}
transition={{ duration: 0.5, delay: 0.3 }}
className="absolute -top-6 -left-6 w-20 h-20 rounded-full bg-emerald-200/50 dark:bg-emerald-800/30"
/>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: 0.4 }}
className="absolute -bottom-8 -right-8 w-24 h-24 rounded-full bg-teal-200/50 dark:bg-teal-800/30"
/>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: 0.5 }}
className="absolute top-12 -right-4 w-12 h-12 rounded-full bg-amber-200/50 dark:bg-amber-800/30"
/>
{/* Main icon */}
<motion.div <motion.div
initial={{ scale: 0.8 }} initial={{ scale: 0.8 }}
animate={{ scale: 1 }} animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: 0.1 }} transition={{ duration: 0.5, delay: 0.1 }}
className="flex items-center justify-center w-28 h-28 rounded-3xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-2xl shadow-emerald-300/40 dark:shadow-emerald-900/40" className="flex items-center justify-center w-28 h-28 rounded-3xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-2xl shadow-emerald-300/40 dark:shadow-emerald-900/40"
> >
<GraduationCap className="w-14 h-14" /> <AnimatePresence mode="wait">
{loginRole === 'teacher' && <motion.div key="teacher" initial={{ rotate: -90, opacity: 0 }} animate={{ rotate: 0, opacity: 1 }} exit={{ rotate: 90, opacity: 0 }} transition={{ duration: 0.3 }}><GraduationCap className="w-14 h-14" /></motion.div>}
{loginRole === 'student' && <motion.div key="student" initial={{ rotate: -90, opacity: 0 }} animate={{ rotate: 0, opacity: 1 }} exit={{ rotate: 90, opacity: 0 }} transition={{ duration: 0.3 }}><UsersIcon className="w-14 h-14" /></motion.div>}
{loginRole === 'parent' && <motion.div key="parent" initial={{ rotate: -90, opacity: 0 }} animate={{ rotate: 0, opacity: 1 }} exit={{ rotate: 90, opacity: 0 }} transition={{ duration: 0.3 }}><Heart className="w-14 h-14" /></motion.div>}
</AnimatePresence>
</motion.div> </motion.div>
</div> </div>
{/* Feature highlights */} {/* Feature highlights */}
<motion.div <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.6 }} className="mt-10 space-y-4 text-center">
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.6 }}
className="mt-10 space-y-4 text-center"
>
<h3 className="text-xl font-bold text-gray-800 dark:text-gray-200">{t('auth.mission_title')}</h3> <h3 className="text-xl font-bold text-gray-800 dark:text-gray-200">{t('auth.mission_title')}</h3>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400"> <div className="flex items-center justify-center w-8 h-8 rounded-full bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400"><Sparkles className="w-4 h-4" /></div>
<Sparkles className="w-4 h-4" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_competencies')}</p> <p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_competencies')}</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400"> <div className="flex items-center justify-center w-8 h-8 rounded-full bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400"><Heart className="w-4 h-4" /></div>
<Heart className="w-4 h-4" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_student_centered')}</p> <p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_student_centered')}</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400"> <div className="flex items-center justify-center w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400"><BookOpen className="w-4 h-4" /></div>
<BookOpen className="w-4 h-4" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_open_source')}</p> <p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_open_source')}</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400"> <div className="flex items-center justify-center w-8 h-8 rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400"><GraduationCap className="w-4 h-4" /></div>
<GraduationCap className="w-4 h-4" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_reports')}</p> <p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_reports')}</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400"> <div className="flex items-center justify-center w-8 h-8 rounded-full bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400"><Flower2 className="w-4 h-4" /></div>
<Flower2 className="w-4 h-4" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_flower')}</p> <p className="text-sm text-gray-600 dark:text-gray-400">{t('auth.feature_flower')}</p>
</div> </div>
</div> </div>
{/* Mockup dashboard preview */} {/* Mockup dashboard preview */}
<motion.div <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.8 }} className="mt-6 p-4 rounded-2xl bg-white/70 dark:bg-gray-800/50 backdrop-blur-sm border border-emerald-200/40 dark:border-emerald-900/30 shadow-lg shadow-emerald-100/30 dark:shadow-emerald-900/10">
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.8 }}
className="mt-6 p-4 rounded-2xl bg-white/70 dark:bg-gray-800/50 backdrop-blur-sm border border-emerald-200/40 dark:border-emerald-900/30 shadow-lg shadow-emerald-100/30 dark:shadow-emerald-900/10"
>
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<div className="w-2 h-2 rounded-full bg-red-400" /> <div className="w-2 h-2 rounded-full bg-red-400" />
<div className="w-2 h-2 rounded-full bg-amber-400" /> <div className="w-2 h-2 rounded-full bg-amber-400" />
@@ -208,14 +198,7 @@ export default function AuthView() {
</div> </div>
<div className="grid grid-cols-3 gap-1.5"> <div className="grid grid-cols-3 gap-1.5">
{[0, 1, 2, 3, 4, 5].map((i) => ( {[0, 1, 2, 3, 4, 5].map((i) => (
<div <div key={i} className={`h-8 rounded-md ${i % 3 === 0 ? 'bg-gradient-to-br from-emerald-100 to-emerald-200/50 dark:from-emerald-900/30 dark:to-emerald-800/20' : i % 3 === 1 ? 'bg-gradient-to-br from-teal-100 to-teal-200/50 dark:from-teal-900/30 dark:to-teal-800/20' : 'bg-gradient-to-br from-amber-100 to-amber-200/50 dark:from-amber-900/30 dark:to-amber-800/20'}`} />
key={i}
className={`h-8 rounded-md ${
i % 3 === 0 ? 'bg-gradient-to-br from-emerald-100 to-emerald-200/50 dark:from-emerald-900/30 dark:to-emerald-800/20'
: i % 3 === 1 ? 'bg-gradient-to-br from-teal-100 to-teal-200/50 dark:from-teal-900/30 dark:to-teal-800/20'
: 'bg-gradient-to-br from-amber-100 to-amber-200/50 dark:from-amber-900/30 dark:to-amber-800/20'
}`}
/>
))} ))}
</div> </div>
<div className="mt-3 space-y-1.5"> <div className="mt-3 space-y-1.5">
@@ -228,80 +211,99 @@ export default function AuthView() {
</motion.div> </motion.div>
{/* Auth form */} {/* Auth form */}
<motion.div <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} className="w-full max-w-md md:max-w-lg">
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="w-full max-w-md md:max-w-lg"
>
{/* Branding */} {/* Branding */}
<div className="text-center mb-8 lg:hidden"> <div className="text-center mb-8 lg:hidden">
<motion.div <motion.div initial={{ scale: 0.8 }} animate={{ scale: 1 }} transition={{ duration: 0.5, delay: 0.1 }} className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white mb-4 shadow-lg shadow-emerald-200 dark:shadow-emerald-900">
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white mb-4 shadow-lg shadow-emerald-200 dark:shadow-emerald-900"
>
<BookOpen className="w-8 h-8" /> <BookOpen className="w-8 h-8" />
</motion.div> </motion.div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100"> <h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{t('app.name')}</h1>
{t('app.name')} <p className="mt-2 text-sm text-emerald-600/70 dark:text-emerald-400/50">{t('app.subtitle')}</p>
</h1>
<p className="mt-2 text-sm text-emerald-600/70 dark:text-emerald-400/50">
{t('app.subtitle')}
</p>
</div> </div>
{/* Auth Card */} {/* Auth Card */}
<Card className="shadow-xl border-0 shadow-emerald-100/50 dark:shadow-emerald-900/20 rounded-2xl overflow-hidden bg-white dark:bg-gray-950"> <Card className="shadow-xl border-0 shadow-emerald-100/50 dark:shadow-emerald-900/20 rounded-2xl overflow-hidden bg-white dark:bg-gray-950">
<CardHeader className="text-center pb-2 pt-8 bg-gradient-to-r from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/10 dark:to-teal-950/10"> {/* Role tabs */}
<div className="flex border-b border-gray-100 dark:border-gray-800">
{roleTabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => { setLoginRole(tab.key); setShowForgotInfo(false); }}
className={`flex-1 min-h-[44px] flex items-center justify-center gap-2 px-3 py-3 text-sm font-semibold transition-all duration-200 ${
loginRole === tab.key
? `${tab.activeColor} shadow-sm`
: `${tab.color} bg-transparent hover:bg-gray-50 dark:hover:bg-gray-900/30`
}`}
>
<tab.icon className="h-4 w-4" />
<span>{t(tab.labelKey)}</span>
</button>
))}
</div>
<CardHeader className={`text-center pb-2 pt-6 bg-gradient-to-r ${activeRoleTab.bgGradient}`}>
<AnimatePresence mode="wait">
<motion.div key={loginRole} initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} transition={{ duration: 0.2 }}>
<CardTitle className="text-xl font-bold text-gray-900 dark:text-gray-100"> <CardTitle className="text-xl font-bold text-gray-900 dark:text-gray-100">
{mode === 'login' ? t('auth.login') : t('auth.register_title')} {loginRole === 'teacher' ? (mode === 'login' ? t('auth.login') : t('auth.register_title'))
: loginRole === 'student' ? t('auth.student_login')
: t('auth.parent_login')}
</CardTitle> </CardTitle>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{loginRole === 'teacher' ? (mode === 'login' ? t('auth.login_subtitle') : t('auth.register_subtitle'))
: loginRole === 'student' ? t('auth.student_login_desc')
: t('auth.parent_login_desc')}
</p>
</motion.div>
</AnimatePresence>
</CardHeader> </CardHeader>
<CardContent className="pt-6 pb-6"> <CardContent className="pt-6 pb-6">
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{mode === 'register' && ( {/* Name fields for register (teacher only) */}
<motion.div {mode === 'register' && loginRole === 'teacher' && (
key="name-fields" <motion.div key="name-fields" initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.2 }} className="space-y-4">
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="space-y-4"
>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="firstName" className="text-sm font-medium">{t('auth.firstName')}</Label> <Label htmlFor="firstName" className="text-sm font-medium">{t('auth.firstName')}</Label>
<div className="relative"> <div className="relative">
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 h-5 w-5 text-emerald-400" /> <User className="absolute left-3.5 top-1/2 -translate-y-1/2 h-5 w-5 text-emerald-400" />
<Input <Input id="firstName" value={firstName} onChange={(e) => 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 />
id="firstName"
value={firstName}
onChange={(e) => 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
/>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="lastName" className="text-sm font-medium">{t('auth.lastName')}</Label> <Label htmlFor="lastName" className="text-sm font-medium">{t('auth.lastName')}</Label>
<Input <Input id="lastName" value={lastName} onChange={(e) => 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 />
id="lastName"
value={lastName}
onChange={(e) => 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
/>
</div> </div>
</div> </div>
</motion.div> </motion.div>
)} )}
{/* Student-specific: School ID field */}
{loginRole === 'student' && (
<motion.div key="school-id" initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.2 }} className="space-y-2">
<Label htmlFor="schoolId" className="text-sm font-medium">{t('auth.school_id')}</Label>
<div className="relative">
<Info className="absolute left-3.5 top-1/2 -translate-y-1/2 h-5 w-5 text-amber-400" />
<Input id="schoolId" value={schoolIdInput} onChange={(e) => 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')} />
</div>
</motion.div>
)}
{/* Parent-specific: name fields on login */}
{loginRole === 'parent' && mode === 'login' && (
<motion.div key="parent-name" initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.2 }}>
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-violet-50/50 dark:bg-violet-900/10 border border-violet-100/50 dark:border-violet-900/20 mb-4">
<Heart className="h-4 w-4 text-violet-500" />
<p className="text-xs text-violet-700 dark:text-violet-300 font-medium">{t('auth.parent_login_desc')}</p>
</div>
</motion.div>
)}
</AnimatePresence> </AnimatePresence>
{/* Email field (always shown) */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">{t('auth.email')}</Label> <Label htmlFor="email" className="text-sm font-medium">{t('auth.email')}</Label>
<div className="relative"> <div className="relative">
@@ -311,23 +313,28 @@ export default function AuthView() {
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} 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" 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" 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 required
/> />
</div> </div>
</div> </div>
{/* Password field */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="password" className="text-sm font-medium">{t('auth.password')}</Label> <Label htmlFor="password" className="text-sm font-medium">{t('auth.password')}</Label>
{mode === 'login' && ( {mode === 'login' && (
<button <button
type="button" type="button"
onClick={() => toast.info('Demo: ' + t('polish.demo_email') + ' / ' + t('polish.demo_password'))} onClick={() => setShowForgotInfo(!showForgotInfo)}
className="text-xs text-emerald-600 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 font-medium transition-colors" className="text-xs text-emerald-600 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 font-medium transition-colors"
> >
{t('polish.forgot_password')} {t('auth.forgot_password')}
</button> </button>
)} )}
</div> </div>
@@ -353,26 +360,43 @@ export default function AuthView() {
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />} {showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button> </button>
</div> </div>
{/* Forgot Password info */}
<AnimatePresence>
{showForgotInfo && (
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.2 }} className="mt-2">
<div className="flex items-start gap-2 px-3 py-3 rounded-lg bg-emerald-50/50 dark:bg-emerald-900/10 border border-emerald-200/30 dark:border-emerald-900/20">
<HelpCircle className="h-4 w-4 text-emerald-500 shrink-0 mt-0.5" />
<p className="text-xs text-emerald-700 dark:text-emerald-300">
{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!')
}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div> </div>
{/* Remember me (login mode only) */} {/* Remember me (login mode only) */}
{mode === 'login' && ( {mode === 'login' && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox id="remember" checked={rememberMe} onCheckedChange={(v) => setRememberMe(v === true)} className="border-emerald-300 dark:border-emerald-700 data-[state=checked]:bg-emerald-500 data-[state=checked]:border-emerald-500" />
id="remember" <Label htmlFor="remember" className="text-xs text-gray-600 dark:text-gray-400 cursor-pointer">{t('polish.remember_me')}</Label>
checked={rememberMe}
onCheckedChange={(v) => setRememberMe(v === true)}
className="border-emerald-300 dark:border-emerald-700 data-[state=checked]:bg-emerald-500 data-[state=checked]:border-emerald-500"
/>
<Label htmlFor="remember" className="text-xs text-gray-600 dark:text-gray-400 cursor-pointer">
{t('polish.remember_me')}
</Label>
</div> </div>
)} )}
{/* Submit button */}
<Button <Button
type="submit" type="submit"
className="w-full h-12 min-h-[44px] bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-lg shadow-emerald-300/30 dark:shadow-emerald-900/30 rounded-xl text-base" className={`w-full h-12 min-h-[44px] text-white shadow-lg rounded-xl text-base ${
loginRole === 'teacher' ? 'bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 shadow-emerald-300/30 dark:shadow-emerald-900/30'
: loginRole === 'student' ? 'bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 shadow-amber-300/30 dark:shadow-amber-900/30'
: 'bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 shadow-violet-300/30 dark:shadow-violet-900/30'
}`}
disabled={loading || demoLoading} disabled={loading || demoLoading}
> >
{loading || demoLoading ? ( {loading || demoLoading ? (
@@ -391,12 +415,7 @@ export default function AuthView() {
{/* Role-specific demo login buttons */} {/* Role-specific demo login buttons */}
{mode === 'login' && ( {mode === 'login' && (
<motion.div <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }} className="mt-5">
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="mt-5"
>
<div className="text-center mb-3"> <div className="text-center mb-3">
<div className="flex items-center justify-center gap-1.5"> <div className="flex items-center justify-center gap-1.5">
<KeyRound className="h-3.5 w-3.5 text-amber-500" /> <KeyRound className="h-3.5 w-3.5 text-amber-500" />
@@ -423,6 +442,8 @@ export default function AuthView() {
</motion.div> </motion.div>
)} )}
{/* Toggle register/login (only for teacher role) */}
{loginRole === 'teacher' && (
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<button <button
type="button" type="button"
@@ -432,15 +453,38 @@ export default function AuthView() {
{mode === 'login' ? t('auth.no_account') : t('auth.has_account')} {mode === 'login' ? t('auth.no_account') : t('auth.has_account')}
</button> </button>
</div> </div>
)}
{/* Student info: accounts created by teachers */}
{loginRole === 'student' && mode === 'login' && (
<div className="mt-5 text-center">
<div className="flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-amber-50/50 dark:bg-amber-900/10 border border-amber-200/30 dark:border-amber-900/20">
<Info className="h-4 w-4 text-amber-500" />
<p className="text-xs text-amber-700 dark:text-amber-300 font-medium">
{t('auth.create_student_account')} — {t('auth.student_login_desc')}
</p>
</div>
</div>
)}
{/* Parent info */}
{loginRole === 'parent' && mode === 'login' && (
<div className="mt-5 text-center">
<div className="flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-violet-50/50 dark:bg-violet-900/10 border border-violet-200/30 dark:border-violet-900/20">
<Heart className="h-4 w-4 text-violet-500" />
<p className="text-xs text-violet-700 dark:text-violet-300 font-medium">
{t('parent.my_children')} — {t('auth.parent_login_desc')}
</p>
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* Footer */} {/* Footer */}
<div className="mt-8 text-center flex items-center justify-center gap-1.5"> <div className="mt-8 text-center flex items-center justify-center gap-1.5">
<Leaf className="h-3.5 w-3.5 text-emerald-500/40 dark:text-emerald-400/30" /> <Leaf className="h-3.5 w-3.5 text-emerald-500/40 dark:text-emerald-400/30" />
<p className="text-xs text-emerald-600/40 dark:text-emerald-400/30"> <p className="text-xs text-emerald-600/40 dark:text-emerald-400/30">{t('app.tagline')}</p>
{t('app.tagline')}
</p>
</div> </div>
</motion.div> </motion.div>
</div> </div>
+254
View File
@@ -19,6 +19,7 @@ import {
Trash2, Trash2,
Bell, Bell,
CheckCircle2, CheckCircle2,
Repeat,
} from 'lucide-react'; } from 'lucide-react';
import { import {
format, format,
@@ -96,6 +97,7 @@ import {
updateCalendarEventItem, updateCalendarEventItem,
deleteCalendarEventItem, deleteCalendarEventItem,
type CalendarEventItemType, type CalendarEventItemType,
type RecurrencePattern,
fetchClasses, fetchClasses,
fetchSubjects, fetchSubjects,
type ClassGroup, type ClassGroup,
@@ -248,6 +250,12 @@ interface EventFormData {
classGroupId: string; classGroupId: string;
notes: string; notes: string;
allDay: boolean; 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 = { const EMPTY_FORM: EventFormData = {
@@ -260,6 +268,12 @@ const EMPTY_FORM: EventFormData = {
classGroupId: '', classGroupId: '',
notes: '', notes: '',
allDay: false, allDay: false,
recurrenceType: 'none',
recurrenceInterval: 1,
recurrenceEndType: 'never',
recurrenceEndDate: '',
recurrenceDaysOfWeek: [],
editMode: 'instance',
}; };
/* ── Sub-components ───────────────────────────────────────────────── */ /* ── Sub-components ───────────────────────────────────────────────── */
@@ -350,6 +364,13 @@ function EventDetailContent({ event, onEdit, onDelete }: { event: CalendarEvent;
)} )}
</div> </div>
)} )}
{/* Recurring indicator */}
{isCustomEvent && typeof meta.recurrencePattern === 'string' && meta.recurrencePattern && (
<div className="flex items-center gap-1.5 pt-2 border-t border-gray-100 dark:border-gray-800">
<Repeat className="h-3.5 w-3.5 text-emerald-500" />
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">{t('calendar.recurring')}</span>
</div>
)}
</div> </div>
); );
} }
@@ -638,6 +659,9 @@ export default function CalendarView() {
// Delete confirmation // Delete confirmation
const [deleteConfirmEvent, setDeleteConfirmEvent] = useState<CalendarEvent | null>(null); const [deleteConfirmEvent, setDeleteConfirmEvent] = useState<CalendarEvent | null>(null);
// Edit mode dialog for recurring events
const [showEditModeDialog, setShowEditModeDialog] = useState(false);
const [pendingEditEvent, setPendingEditEvent] = useState<CalendarEvent | null>(null);
// Classes and subjects for form // Classes and subjects for form
const [classes, setClasses] = useState<ClassGroup[]>([]); const [classes, setClasses] = useState<ClassGroup[]>([]);
@@ -753,6 +777,15 @@ export default function CalendarView() {
const handleEditEvent = useCallback((event: CalendarEvent) => { const handleEditEvent = useCallback((event: CalendarEvent) => {
const meta = event.meta as Record<string, unknown>; const meta = event.meta as Record<string, unknown>;
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); setEditingEvent(event);
setFormData({ setFormData({
title: event.title, title: event.title,
@@ -764,11 +797,48 @@ export default function CalendarView() {
classGroupId: '', classGroupId: '',
notes: (typeof meta.notes === 'string' && meta.notes) ? meta.notes : '', notes: (typeof meta.notes === 'string' && meta.notes) ? meta.notes : '',
allDay: typeof meta.allDay === 'boolean' ? meta.allDay : false, allDay: typeof meta.allDay === 'boolean' ? meta.allDay : false,
recurrenceType: 'none',
recurrenceInterval: 1,
recurrenceEndType: 'never',
recurrenceEndDate: '',
recurrenceDaysOfWeek: [],
editMode: 'instance',
}); });
setShowEventForm(true); setShowEventForm(true);
setSelectedEvent(null); setSelectedEvent(null);
}, []); }, []);
const handleEditEventWithMode = useCallback((event: CalendarEvent, mode: 'series' | 'instance') => {
const meta = event.meta as Record<string, unknown>;
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 () => { const handleDeleteEvent = useCallback(async () => {
if (!deleteConfirmEvent) return; if (!deleteConfirmEvent) return;
try { try {
@@ -786,6 +856,23 @@ export default function CalendarView() {
if (!currentUser?.schoolId || !formData.title.trim()) return; if (!currentUser?.schoolId || !formData.title.trim()) return;
setSaving(true); setSaving(true);
try { 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) { if (editingEvent) {
await updateCalendarEventItem(editingEvent.id, { await updateCalendarEventItem(editingEvent.id, {
title: formData.title, title: formData.title,
@@ -797,6 +884,9 @@ export default function CalendarView() {
classGroupId: formData.classGroupId || null, classGroupId: formData.classGroupId || null,
notes: formData.notes || null, notes: formData.notes || null,
allDay: formData.allDay, allDay: formData.allDay,
recurrencePattern,
recurrenceEnd,
editMode: formData.editMode,
}); });
toast.success(t('calendar.event_updated')); toast.success(t('calendar.event_updated'));
} else { } else {
@@ -811,6 +901,8 @@ export default function CalendarView() {
classGroupId: formData.classGroupId || null, classGroupId: formData.classGroupId || null,
notes: formData.notes || null, notes: formData.notes || null,
allDay: formData.allDay, allDay: formData.allDay,
recurrencePattern,
recurrenceEnd,
}); });
toast.success(t('calendar.event_created')); toast.success(t('calendar.event_created'));
} }
@@ -1561,6 +1653,132 @@ export default function CalendarView() {
className="min-h-[44px] resize-none" className="min-h-[44px] resize-none"
/> />
</div> </div>
{/* Recurrence options */}
{!editingEvent || formData.editMode === 'series' ? (
<div className="space-y-3 pt-2 border-t border-gray-100 dark:border-gray-800">
<div className="flex items-center gap-2">
<Repeat className="h-4 w-4 text-emerald-500" />
<Label className="text-xs font-semibold text-emerald-700 dark:text-emerald-400">
{t('calendar.recurring')}
</Label>
</div>
{/* Repeat type */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-gray-700 dark:text-gray-300">
{t('calendar.repeat_none')}
</Label>
<Select
value={formData.recurrenceType}
onValueChange={(value) => setFormData((f) => ({
...f,
recurrenceType: value as 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly',
recurrenceDaysOfWeek: value === 'weekly' ? [1] : [],
}))}
>
<SelectTrigger className="min-h-[44px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('calendar.repeat_none')}</SelectItem>
<SelectItem value="daily">{t('calendar.repeat_daily')}</SelectItem>
<SelectItem value="weekly">{t('calendar.repeat_weekly')}</SelectItem>
<SelectItem value="monthly">{t('calendar.repeat_monthly')}</SelectItem>
<SelectItem value="yearly">{t('calendar.repeat_yearly')}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Interval */}
{formData.recurrenceType !== 'none' && (
<div className="space-y-1.5">
<Label className="text-xs font-medium text-gray-700 dark:text-gray-300">
{t('calendar.repeat_interval', { interval: formData.recurrenceInterval })}
</Label>
<Input
type="number"
min={1}
max={30}
value={formData.recurrenceInterval}
onChange={(e) => setFormData((f) => ({ ...f, recurrenceInterval: parseInt(e.target.value) || 1 }))}
className="min-h-[44px] w-20"
/>
</div>
)}
{/* Days of week selector (for weekly) */}
{formData.recurrenceType === 'weekly' && (
<div className="space-y-1.5">
<Label className="text-xs font-medium text-gray-700 dark:text-gray-300">
{t('calendar.repeat_weekdays')}
</Label>
<div className="flex gap-1">
{['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map((dayLabel, dayIndex) => {
const dayNum = dayIndex === 6 ? 0 : dayIndex + 1;
const isSelected = formData.recurrenceDaysOfWeek.includes(dayNum);
return (
<button
key={dayNum}
type="button"
onClick={() => {
setFormData((f) => ({
...f,
recurrenceDaysOfWeek: isSelected
? f.recurrenceDaysOfWeek.filter((d) => d !== dayNum)
: [...f.recurrenceDaysOfWeek, dayNum].sort(),
}));
}}
className={`w-10 h-10 rounded-lg text-xs font-semibold flex items-center justify-center transition-colors ${
isSelected
? 'bg-emerald-500 text-white shadow-sm'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20'
}`}
>
{dayLabel}
</button>
);
})}
</div>
</div>
)}
{/* Recurrence end */}
{formData.recurrenceType !== 'none' && (
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-700 dark:text-gray-300">
{t('calendar.repeat_end')}
</Label>
<div className="flex gap-3">
<Button
variant={formData.recurrenceEndType === 'never' ? 'default' : 'outline'}
size="sm"
className={formData.recurrenceEndType === 'never' ? 'bg-emerald-500 hover:bg-emerald-600 text-white min-h-[44px]' : 'min-h-[44px]'}
onClick={() => setFormData((f) => ({ ...f, recurrenceEndType: 'never' }))}
>
{t('calendar.repeat_end_never')}
</Button>
<Button
variant={formData.recurrenceEndType === 'on_date' ? 'default' : 'outline'}
size="sm"
className={formData.recurrenceEndType === 'on_date' ? 'bg-emerald-500 hover:bg-emerald-600 text-white min-h-[44px]' : 'min-h-[44px]'}
onClick={() => setFormData((f) => ({ ...f, recurrenceEndType: 'on_date' }))}
>
{t('calendar.repeat_end_on')}
</Button>
</div>
{formData.recurrenceEndType === 'on_date' && (
<Input
type="date"
value={formData.recurrenceEndDate}
onChange={(e) => setFormData((f) => ({ ...f, recurrenceEndDate: e.target.value }))}
className="min-h-[44px]"
/>
)}
</div>
)}
</div>
) : null}
</div> </div>
<DialogFooter className="mt-4 gap-2"> <DialogFooter className="mt-4 gap-2">
@@ -1606,6 +1824,42 @@ export default function CalendarView() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* Edit mode dialog for recurring events */}
<AlertDialog
open={showEditModeDialog}
onOpenChange={(open) => {
if (!open) {
setShowEditModeDialog(false);
setPendingEditEvent(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('calendar.edit_series')}</AlertDialogTitle>
<AlertDialogDescription>
{t('calendar.edit_instance')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex flex-col sm:flex-row gap-2">
<AlertDialogCancel className="min-h-[44px]">{t('action.cancel')}</AlertDialogCancel>
<Button
variant="outline"
onClick={() => pendingEditEvent && handleEditEventWithMode(pendingEditEvent, 'instance')}
className="border-emerald-200/60 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300 min-h-[44px]"
>
{t('calendar.edit_instance')}
</Button>
<AlertDialogAction
onClick={() => pendingEditEvent && handleEditEventWithMode(pendingEditEvent, 'series')}
className="bg-emerald-500 hover:bg-emerald-600 text-white min-h-[44px]"
>
{t('calendar.edit_series')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }
+292 -2
View File
@@ -46,6 +46,7 @@ import {
CalendarCheck, CalendarCheck,
Calendar as CalendarIconNav, Calendar as CalendarIconNav,
BookMarked, BookMarked,
Heart,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -60,9 +61,9 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } 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 { 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 = { const containerVariants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
@@ -131,6 +132,284 @@ const motivationalTips = [
{ text: 'Detailed records make parent conversations productive.', icon: MessageSquare }, { 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<ParentStudentLinkData[]>([]);
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 (
<div className="space-y-6">
<Skeleton className="h-10 w-64" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-40 rounded-xl" />
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Skeleton className="h-64 rounded-xl" />
<Skeleton className="h-64 rounded-xl" />
</div>
</div>
);
}
return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-6 pb-8"
>
{/* Parent Welcome Header */}
<motion.div variants={itemVariants}>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-3.5">
<div className="flex items-center justify-center w-12 h-12 rounded-2xl bg-gradient-to-br from-violet-400 to-purple-500 text-white shadow-lg shadow-violet-300/30 ring-2 ring-violet-200/30 dark:ring-violet-800/20">
<Heart className="w-6 h-6" />
</div>
<div>
<h2 className="text-2xl font-bold bg-gradient-to-r from-violet-600 via-purple-600 to-violet-500 dark:from-violet-400 dark:via-purple-400 dark:to-violet-300 bg-clip-text text-transparent">
{t('parent.welcome_back')}, {currentUser.firstName}!
</h2>
<p className="text-violet-600/60 dark:text-violet-400/40 mt-0.5 text-sm">{t('parent.dashboard_title')}</p>
</div>
</div>
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-gradient-to-r from-violet-50 to-purple-50/50 dark:from-violet-900/20 dark:to-purple-900/10 border border-violet-200/40 dark:border-violet-900/30 text-xs font-medium text-violet-700 dark:text-violet-300 shadow-sm">
<CalendarDays className="h-3.5 w-3.5" />
<span className="font-semibold">
{new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
</motion.div>
{/* Parent Cards Grid */}
<motion.div variants={itemVariants} className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{/* My Children Card */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-violet-200/40 dark:hover:ring-violet-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-violet-200/60 dark:hover:shadow-violet-800/30 cursor-pointer min-h-[44px]">
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-violet-400 to-violet-500 text-white shadow-sm">
<Users className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('parent.my_children')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-violet-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
{children.length > 0 ? (
<div className="space-y-2">
{children.map((child) => (
<div key={child.studentId} className="flex items-center justify-between px-3 py-2 rounded-lg bg-violet-50/50 dark:bg-violet-900/10 border border-violet-100/50 dark:border-violet-900/20">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-violet-200 dark:bg-violet-800 text-violet-700 dark:text-violet-300 text-[10px] font-bold">
{child.firstName[0]}{child.lastName[0]}
</div>
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">{child.firstName} {child.lastName}</span>
</div>
<div className="flex items-center gap-1.5">
<Badge variant="outline" className="text-[10px] px-1.5 py-0 bg-violet-50/50 dark:bg-violet-900/10 border-violet-200/50 dark:border-violet-900/30 text-violet-700 dark:text-violet-300 shrink-0">
{child.className}
</Badge>
{child.relationship && (
<span className="text-[10px] text-gray-400 dark:text-gray-500">{t(`parent.${child.relationship}`)}</span>
)}
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-24 rounded-lg bg-gray-50 dark:bg-gray-900/20 border border-gray-200/30 dark:border-gray-800/20">
<Users className="h-8 w-8 text-gray-300 dark:text-gray-600 mb-1" />
<p className="text-xs text-gray-400 dark:text-gray-500">{t('parent.no_children')}</p>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{t('parent.no_children_desc')}</p>
</div>
)}
</CardContent>
</Card>
{/* Recent Progress Card */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-emerald-200/40 dark:hover:ring-emerald-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-emerald-200/60 dark:hover:shadow-emerald-800/30 cursor-pointer min-h-[44px]" onClick={() => setCurrentView('flower')}>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-500 text-white shadow-sm">
<TrendingUp className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('parent.recent_progress')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-emerald-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">{t('parent.child_progress_desc')}</p>
{children.length > 0 ? (
<div className="space-y-2">
{children.slice(0, 3).map((child) => (
<div key={child.studentId} className="flex items-center justify-between px-3 py-2 rounded-lg bg-emerald-50/50 dark:bg-emerald-900/10 border border-emerald-100/50 dark:border-emerald-900/20">
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">{child.firstName}</span>
<div className="flex items-center gap-1">
<Sprout className="h-3.5 w-3.5 text-emerald-500" />
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">{t('parent.view_progress')}</span>
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-24 rounded-lg bg-gray-50 dark:bg-gray-900/20 border border-gray-200/30 dark:border-gray-800/20">
<TrendingUp className="h-8 w-8 text-gray-300 dark:text-gray-600 mb-1" />
<p className="text-xs text-gray-400 dark:text-gray-500">{t('parent.no_children')}</p>
</div>
)}
</CardContent>
</Card>
{/* Upcoming Assessments Card */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-amber-200/40 dark:hover:ring-amber-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-amber-200/60 dark:hover:shadow-amber-800/30 cursor-pointer min-h-[44px]" onClick={() => setCurrentView('assessments')}>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm">
<ClipboardCheck className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('parent.upcoming_assessments')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-amber-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
<div className="flex flex-col items-center justify-center h-24 rounded-lg bg-amber-50/50 dark:bg-amber-900/10 border border-amber-200/30 dark:border-amber-900/20">
<ClipboardCheck className="h-8 w-8 text-amber-400/60 dark:text-amber-600/40 mb-1" />
<p className="text-xs text-gray-500 dark:text-gray-400">{t('parent.no_assessments')}</p>
</div>
</CardContent>
</Card>
{/* Messages from Teachers Card */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-teal-200/40 dark:hover:ring-teal-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-teal-200/60 dark:hover:shadow-teal-800/30 cursor-pointer min-h-[44px]" onClick={() => setCurrentView('parents')}>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-teal-400 to-teal-500 text-white shadow-sm">
<MessageSquare className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('parent.messages_from_teachers')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-teal-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
<div className="flex flex-col items-center justify-center h-24 rounded-lg bg-teal-50/50 dark:bg-teal-900/10 border border-teal-200/30 dark:border-teal-900/20">
<MessageSquare className="h-8 w-8 text-teal-400/60 dark:text-teal-600/40 mb-1" />
<p className="text-xs text-gray-500 dark:text-gray-400">{t('parent.no_messages')}</p>
</div>
</CardContent>
</Card>
{/* Attendance Summary Card */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-rose-200/40 dark:hover:ring-rose-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-rose-200/60 dark:hover:shadow-rose-800/30 cursor-pointer min-h-[44px]" onClick={() => setCurrentView('attendance')}>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-rose-400 to-rose-500 text-white shadow-sm">
<CalendarCheck className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('parent.attendance_summary')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-rose-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
{children.length > 0 ? (
<div className="space-y-2">
{children.slice(0, 3).map((child) => (
<div key={child.studentId} className="flex items-center justify-between px-3 py-2 rounded-lg bg-rose-50/50 dark:bg-rose-900/10 border border-rose-100/50 dark:border-rose-900/20">
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">{child.firstName}</span>
<div className="flex items-center gap-1">
<CalendarCheck className="h-3.5 w-3.5 text-emerald-500" />
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">{t('parent.view_attendance')}</span>
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-24 rounded-lg bg-gray-50 dark:bg-gray-900/20 border border-gray-200/30 dark:border-gray-800/20">
<CalendarCheck className="h-8 w-8 text-gray-300 dark:text-gray-600 mb-1" />
<p className="text-xs text-gray-400 dark:text-gray-500">{t('parent.no_children')}</p>
</div>
)}
</CardContent>
</Card>
{/* Calendar Card */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-violet-200/40 dark:hover:ring-violet-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-violet-200/60 dark:hover:shadow-violet-800/30 cursor-pointer min-h-[44px]" onClick={() => setCurrentView('calendar')}>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-violet-400 to-purple-500 text-white shadow-sm">
<CalendarIconNav className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('nav.calendar')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-violet-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
<div className="flex items-center justify-center h-24 rounded-lg bg-gradient-to-br from-violet-50 to-purple-50/50 dark:from-violet-900/20 dark:to-purple-900/10 border border-violet-200/30 dark:border-violet-900/20">
<div className="text-center">
<p className="text-lg font-bold text-violet-600 dark:text-violet-400">
{new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { day: 'numeric', month: 'short' })}
</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400">
{new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'long' })}
</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
</motion.div>
);
}
export default function DashboardView() { export default function DashboardView() {
const currentUser = useAppStore((s) => s.currentUser); const currentUser = useAppStore((s) => s.currentUser);
const setCurrentView = useAppStore((s) => s.setCurrentView); const setCurrentView = useAppStore((s) => s.setCurrentView);
@@ -514,6 +793,17 @@ export default function DashboardView() {
); );
} }
// ─── Parent Dashboard ──────────────────────────────────────────────
if (currentUser?.role === 'PARENT') {
return (
<ParentDashboard
currentUser={currentUser}
setCurrentView={setCurrentView}
locale={locale}
/>
);
}
return ( return (
<motion.div <motion.div
variants={containerVariants} variants={containerVariants}
+61 -10
View File
@@ -23,6 +23,8 @@ import {
Eye, Eye,
EyeOff, EyeOff,
ChevronDown, ChevronDown,
ZoomIn,
ZoomOut,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
@@ -431,6 +433,9 @@ export default function DrawingCanvas({
// Canvas dimensions // Canvas dimensions
const [canvasSize, setCanvasSize] = useState({ width: 1200, height: 800 }); const [canvasSize, setCanvasSize] = useState({ width: 1200, height: 800 });
// Zoom state
const [zoomLevel, setZoomLevel] = useState(100);
/* ── Load initial data ─────────────────────────────────────────── */ /* ── Load initial data ─────────────────────────────────────────── */
useEffect(() => { useEffect(() => {
@@ -780,9 +785,9 @@ export default function DrawingCanvas({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setActiveTool(tool.id)} onClick={() => setActiveTool(tool.id)}
className={`h-10 w-10 transition-all ${ className={`h-10 w-10 transition-all rounded-lg ${
activeTool === tool.id 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' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`} }`}
> >
@@ -820,9 +825,9 @@ export default function DrawingCanvas({
<button <button
key={color} key={color}
onClick={() => setStrokeColor(color)} onClick={() => setStrokeColor(color)}
className={`h-7 w-7 rounded-full border-2 transition-all hover:scale-110 ${ className={`h-8 w-8 rounded-full border-2 transition-all hover:scale-110 ${
strokeColor === color strokeColor === color
? 'border-emerald-500 ring-2 ring-emerald-500/30' ? 'border-emerald-500 ring-2 ring-emerald-500/30 scale-110'
: 'border-gray-300' : 'border-gray-300'
}`} }`}
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
@@ -837,9 +842,9 @@ export default function DrawingCanvas({
<button <button
key={color} key={color}
onClick={() => setStrokeColor(color)} onClick={() => setStrokeColor(color)}
className={`h-7 w-7 rounded-full border-2 transition-all hover:scale-110 ${ className={`h-8 w-8 rounded-full border-2 transition-all hover:scale-110 ${
strokeColor === color strokeColor === color
? 'border-emerald-500 ring-2 ring-emerald-500/30' ? 'border-emerald-500 ring-2 ring-emerald-500/30 scale-110'
: 'border-gray-300' : 'border-gray-300'
}`} }`}
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
@@ -958,7 +963,7 @@ export default function DrawingCanvas({
size="sm" size="sm"
onClick={handleUndo} onClick={handleUndo}
disabled={strokes.length === 0} disabled={strokes.length === 0}
className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30" className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30 transition-transform active:scale-90"
> >
<Undo2 className="h-5 w-5" /> <Undo2 className="h-5 w-5" />
</Button> </Button>
@@ -972,7 +977,7 @@ export default function DrawingCanvas({
size="sm" size="sm"
onClick={handleRedo} onClick={handleRedo}
disabled={redoStack.length === 0} disabled={redoStack.length === 0}
className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30" className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30 transition-transform active:scale-90"
> >
<Redo2 className="h-5 w-5" /> <Redo2 className="h-5 w-5" />
</Button> </Button>
@@ -981,6 +986,42 @@ export default function DrawingCanvas({
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
{/* Divider */}
<div className="mx-1 h-8 w-px bg-gray-700" />
{/* Zoom controls */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setZoomLevel(Math.max(25, zoomLevel - 25))}
disabled={zoomLevel <= 25}
className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30"
>
<ZoomOut className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('drawing.zoom_out')}</TooltipContent>
</Tooltip>
<span className="text-xs text-gray-400 min-w-[2rem] text-center">{zoomLevel}%</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setZoomLevel(Math.min(200, zoomLevel + 25))}
disabled={zoomLevel >= 200}
className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30"
>
<ZoomIn className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('drawing.zoom_in')}</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Spacer */} {/* Spacer */}
<div className="flex-1" /> <div className="flex-1" />
@@ -1047,9 +1088,19 @@ export default function DrawingCanvas({
{/* ── Canvas Area ────────────────────────────────────────────── */} {/* ── Canvas Area ────────────────────────────────────────────── */}
<div <div
ref={containerRef} ref={containerRef}
className="flex-1 overflow-hidden bg-gray-100 p-4" className="flex-1 overflow-auto bg-gray-100 p-4"
>
<div
className="relative mx-auto overflow-hidden rounded-lg shadow-lg canvas-toolbar"
style={{
width: `${zoomLevel}%`,
maxWidth: '100%',
height: `${zoomLevel}%`,
maxHeight: '100%',
transform: zoomLevel !== 100 ? `scale(${zoomLevel / 100})` : undefined,
transformOrigin: 'center center',
}}
> >
<div className="relative mx-auto h-full max-w-full overflow-hidden rounded-lg shadow-lg">
<canvas <canvas
ref={canvasRef} ref={canvasRef}
width={canvasSize.width} width={canvasSize.width}
+380 -32
View File
@@ -14,6 +14,7 @@ import {
User as UserIcon, GraduationCap, FileDown, User as UserIcon, GraduationCap, FileDown,
Copy, FlaskConical, Languages, Calculator, Paintbrush, Megaphone, Copy, FlaskConical, Languages, Calculator, Paintbrush, Megaphone,
Strikethrough, Type, Highlighter, GripVertical, Strikethrough, Type, Highlighter, GripVertical,
Clock, History, ZoomIn, ZoomOut, Image as ImageIcon,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -33,6 +34,7 @@ import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api'; import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
import { toast } from 'sonner'; import { toast } from 'sonner';
import DrawingCanvas from '@/components/drawing-canvas';
// ─── Types ─────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────
@@ -87,6 +89,17 @@ interface ClassGroup {
gradeLevel: number; gradeLevel: number;
} }
interface PageVersion {
id: string;
pageId: string;
version: number;
textContent: string | null;
drawingData: string | null;
editedBy: string | null;
editSummary: string | null;
createdAt: string;
}
// ─── Constants ─────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────
const NOTEBOOK_TYPES: Array<{ key: string; icon: React.ElementType; labelKey: string; descKey: string }> = [ const NOTEBOOK_TYPES: Array<{ key: string; icon: React.ElementType; labelKey: string; descKey: string }> = [
@@ -946,7 +959,7 @@ function PageThumbnail({ page, notebookType }: { page: NotebookPage; notebookTyp
return ( return (
<div <div
className="w-full h-24 rounded-md overflow-hidden border border-gray-200 dark:border-gray-600 relative" className="w-full h-24 rounded-md overflow-hidden border border-gray-200 dark:border-gray-600 relative group-hover:shadow-md transition-shadow duration-200"
style={{ ...getPageBackgroundCSS(bgType), backgroundColor: '#fff' }} style={{ ...getPageBackgroundCSS(bgType), backgroundColor: '#fff' }}
> >
<div className="p-1.5 text-xs text-gray-400 dark:text-gray-500 truncate leading-tight"> <div className="p-1.5 text-xs text-gray-400 dark:text-gray-500 truncate leading-tight">
@@ -964,6 +977,12 @@ function PageThumbnail({ page, notebookType }: { page: NotebookPage; notebookTyp
<Bookmark className="w-3 h-3 text-amber-500 fill-amber-500" /> <Bookmark className="w-3 h-3 text-amber-500 fill-amber-500" />
</div> </div>
)} )}
{/* Drawing indicator */}
{page.drawingData && (
<div className="absolute bottom-0 right-0">
<ImageIcon className="w-3 h-3 text-emerald-500" />
</div>
)}
</div> </div>
); );
} }
@@ -998,6 +1017,13 @@ function NotebookDetailView({
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
// Version history state
const [versionHistoryOpen, setVersionHistoryOpen] = useState(false);
const [pageVersions, setPageVersions] = useState<PageVersion[]>([]);
const [previewVersion, setPreviewVersion] = useState<PageVersion | null>(null);
const [restoringVersion, setRestoringVersion] = useState(false);
const [restoreConfirmVersion, setRestoreConfirmVersion] = useState<PageVersion | null>(null);
// Drag-and-drop state // Drag-and-drop state
const [draggedPageId, setDraggedPageId] = useState<string | null>(null); const [draggedPageId, setDraggedPageId] = useState<string | null>(null);
const [dragOverPageId, setDragOverPageId] = useState<string | null>(null); const [dragOverPageId, setDragOverPageId] = useState<string | null>(null);
@@ -1123,6 +1149,14 @@ function NotebookDetailView({
lastSavedTitleRef.current = pageTitle; lastSavedTitleRef.current = pageTitle;
toast.success(t('notebooks.page_saved')); toast.success(t('notebooks.page_saved'));
setAutoSaveStatus('idle'); 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 { } catch {
toast.error(t('notebooks.error_save')); toast.error(t('notebooks.error_save'));
} finally { } finally {
@@ -1130,6 +1164,71 @@ function NotebookDetailView({
} }
}; };
// Load version history
const loadVersionHistory = useCallback(async () => {
if (!currentPage) return;
try {
const versions = await apiGet<PageVersion[]>(`/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 // Drag-and-drop handlers for page reorder
const handleDragStart = useCallback((e: React.DragEvent, pageId: string) => { const handleDragStart = useCallback((e: React.DragEvent, pageId: string) => {
setDraggedPageId(pageId); setDraggedPageId(pageId);
@@ -1222,10 +1321,11 @@ function NotebookDetailView({
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: 30 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }} exit={{ opacity: 0, x: -30 }}
className="flex flex-col h-full" transition={{ type: 'spring', stiffness: 200, damping: 25 }}
className="flex flex-col h-full animate-slide-in"
> >
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 p-4 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center gap-3 p-4 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
@@ -1280,19 +1380,20 @@ function NotebookDetailView({
variant={drawingMode ? 'default' : 'outline'} variant={drawingMode ? 'default' : 'outline'}
size="sm" size="sm"
onClick={() => setDrawingMode(!drawingMode)} 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' : ''}`}
>
<PenLine className="w-4 h-4 mr-1" />
{drawingMode ? t('notebooks.drawing_mode_exit') : t('notebooks.drawing_mode_toggle')}
</Button>
{/* Version history button */}
<Button
variant="ghost"
size="sm"
onClick={handleOpenVersionHistory}
className="min-h-[44px] shrink-0" className="min-h-[44px] shrink-0"
> >
{drawingMode ? ( <Clock className="w-4 h-4" />
<>
<PenLine className="w-4 h-4 mr-1" />
{t('notebooks.drawing_mode_exit')}
</>
) : (
<>
<PenLine className="w-4 h-4 mr-1" />
{t('notebooks.drawing_mode_toggle')}
</>
)}
</Button> </Button>
{/* Public toggle */} {/* 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" className="font-semibold text-lg border-0 bg-transparent shadow-none focus-visible:ring-0 h-10"
/> />
{/* Version history button in page title bar */}
<Button
variant="ghost"
size="sm"
onClick={handleOpenVersionHistory}
className="min-h-[44px] shrink-0 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
>
<History className="w-4 h-4" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -1438,24 +1549,22 @@ function NotebookDetailView({
</div> </div>
{drawingMode ? ( {drawingMode ? (
/* Drawing placeholder */ /* Drawing mode - render DrawingCanvas */
<div className="flex-1 flex items-center justify-center p-4">
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, x: 30 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, x: 0 }}
className="w-full h-full max-w-3xl rounded-xl border-2 border-gray-200 dark:border-gray-700 bg-white flex flex-col items-center justify-center gap-4 p-8" exit={{ opacity: 0, x: -30 }}
style={{ ...getPageBackgroundCSS(currentPage.background ?? notebook.notebookType), backgroundColor: '#fff' }} transition={{ type: 'spring', stiffness: 200, damping: 25 }}
className="flex-1 flex flex-col overflow-hidden animate-slide-in"
> >
<PenLine className="w-12 h-12 text-gray-300 dark:text-gray-600" /> <DrawingCanvas
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium"> backgroundType={currentPage.background === 'music' || currentPage.background === 'calligraphy' ? 'blank' : (currentPage.background as 'blank' | 'lined' | 'grid' | 'dotted') ?? (notebook.notebookType as 'blank' | 'lined' | 'grid' | 'dotted')}
{t('notebooks.drawing_placeholder')} initialDrawingData={currentPage.drawingData ?? undefined}
</p> onSave={handleDrawingSave}
<Button variant="outline" onClick={() => setDrawingMode(false)} className="min-h-[44px]"> onExit={() => setDrawingMode(false)}
<ChevronLeft className="w-4 h-4 mr-1" /> title={currentPage.title ?? `${t('notebooks.page')} ${currentPage.pageNumber}`}
{t('notebooks.drawing_mode_exit')} />
</Button>
</motion.div> </motion.div>
</div>
) : ( ) : (
/* Text editing area with WYSIWYG toolbar and page background */ /* Text editing area with WYSIWYG toolbar and page background */
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
@@ -1491,9 +1600,29 @@ function NotebookDetailView({
{/* Save bar */} {/* Save bar */}
<div className="flex items-center justify-between px-4 py-2 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900"> <div className="flex items-center justify-between px-4 py-2 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900">
<div className="text-sm text-gray-500 dark:text-gray-400"> <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
{t('notebooks.page')} {currentPage.pageNumber} / {pages.length} <span>{t('notebooks.page')} {currentPage.pageNumber} / {pages.length}</span>
{/* Drawing indicator when not in drawing mode */}
{currentPage.drawingData && !drawingMode && (
<Badge variant="outline" className="text-xs gap-1 border-emerald-300 text-emerald-600 dark:border-emerald-700 dark:text-emerald-400">
<ImageIcon className="w-3 h-3" />
{t('notebooks.view_drawing')}
</Badge>
)}
</div> </div>
<div className="flex items-center gap-2">
{/* View drawing button if page has drawing data */}
{currentPage.drawingData && !drawingMode && (
<Button
variant="outline"
size="sm"
onClick={() => setDrawingMode(true)}
className="min-h-[44px]"
>
<ImageIcon className="w-4 h-4 mr-1" />
{t('notebooks.edit_drawing')}
</Button>
)}
<Button <Button
onClick={handleSavePage} onClick={handleSavePage}
disabled={saving} disabled={saving}
@@ -1508,6 +1637,7 @@ function NotebookDetailView({
{t('notebooks.save_page')} {t('notebooks.save_page')}
</Button> </Button>
</div> </div>
</div>
</> </>
) : ( ) : (
/* Empty state - no pages */ /* Empty state - no pages */
@@ -1524,6 +1654,20 @@ function NotebookDetailView({
)} )}
</div> </div>
</div> </div>
{/* Version History Dialog */}
<VersionHistoryDialog
open={versionHistoryOpen}
onClose={() => setVersionHistoryOpen(false)}
versions={pageVersions}
previewVersion={previewVersion}
onPreviewVersion={setPreviewVersion}
onRestoreVersion={handleRestoreVersion}
restoringVersion={restoringVersion}
restoreConfirmVersion={restoreConfirmVersion}
onSetRestoreConfirm={setRestoreConfirmVersion}
currentPageContent={currentPage?.textContent ?? null}
/>
</motion.div> </motion.div>
); );
} }
@@ -2421,3 +2565,207 @@ export default function NotebooksView() {
</div> </div>
); );
} }
// ─── 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 (
<>
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History className="w-5 h-5 text-emerald-500" />
{t('notebooks.version_history_title')}
</DialogTitle>
<DialogDescription>
{t('notebooks.version_history')}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 overflow-y-auto max-h-[60vh]">
{versions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 gap-3">
<Clock className="w-10 h-10 text-gray-300 dark:text-gray-600" />
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t('notebooks.version_no_history')}
</p>
</div>
) : (
<div className="version-timeline">
{versions.map((version, idx) => {
const isCurrent = idx === 0;
const isPreviewed = previewVersion?.id === version.id;
return (
<div key={version.id} className="flex items-start gap-3 group">
{/* Timeline dot */}
<div className="flex flex-col items-center shrink-0 pt-1">
<div className={`w-3 h-3 rounded-full border-2 transition-all ${
isCurrent
? 'bg-emerald-500 border-emerald-500 animate-pulse-dot'
: isPreviewed
? 'bg-emerald-400 border-emerald-400'
: 'bg-gray-300 dark:bg-gray-600 border-gray-400 dark:border-gray-500'
}`} />
{idx < versions.length - 1 && (
<div className="w-0.5 h-full min-h-[40px] bg-gray-200 dark:bg-gray-700 mt-1" />
)}
</div>
{/* Version content */}
<div
className={`flex-1 rounded-lg p-3 transition-all cursor-pointer ${
isPreviewed
? 'bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-300 dark:border-emerald-700'
: 'bg-gray-50 dark:bg-gray-800/50 border border-transparent hover:border-gray-200 dark:hover:border-gray-600'
}`}
onClick={() => onPreviewVersion(version)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-gray-700 dark:text-gray-300">
{t('notebooks.version_number')} {version.version}
</span>
{isCurrent && (
<Badge className="text-xs bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border-0">
{t('notebooks.version_current')}
</Badge>
)}
{version.editSummary && (
<Badge variant="outline" className="text-xs">
{version.editSummary}
</Badge>
)}
</div>
<span className="text-xs text-gray-400 dark:text-gray-500">
{formatDate(version.createdAt)}
</span>
</div>
{/* Content preview */}
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{getPreviewText(version.textContent)}
</div>
{/* Drawing indicator */}
{version.drawingData && (
<div className="mt-1 flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<ImageIcon className="w-3 h-3" />
{t('notebooks.drawing_page_label')}
</div>
)}
</div>
</div>
);
})}
</div>
)}
{/* Preview panel */}
{previewVersion && (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-900">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-sm flex items-center gap-2">
{t('notebooks.version_preview')} - {t('notebooks.version_number')} {previewVersion.version}
{versions[0]?.id === previewVersion.id && (
<Badge className="text-xs bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border-0">
{t('notebooks.version_current')}
</Badge>
)}
</h4>
{!isLatestVersion && (
<Button
variant="outline"
size="sm"
onClick={() => onSetRestoreConfirm(previewVersion)}
disabled={restoringVersion}
className="min-h-[36px]"
>
<History className="w-3.5 h-3.5 mr-1" />
{t('notebooks.version_restore')}
</Button>
)}
</div>
<ScrollArea className="max-h-[200px]">
<div className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
{previewVersion.textContent ? getPreviewText(previewVersion.textContent) : t('notebooks.export_empty_page')}
</div>
</ScrollArea>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} className="min-h-[44px]">
{t('action.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Restore confirmation */}
<AlertDialog open={!!restoreConfirmVersion} onOpenChange={(v) => { if (!v) onSetRestoreConfirm(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<History className="w-5 h-5 text-emerald-500" />
{t('notebooks.version_restore_confirm')}
</AlertDialogTitle>
<AlertDialogDescription>
{t('notebooks.version_restore_desc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="min-h-[44px]">{t('action.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => { if (restoreConfirmVersion) onRestoreVersion(restoreConfirmVersion); }}
disabled={restoringVersion}
className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
>
{restoringVersion ? (
<div className="w-4 h-4 animate-spin rounded-full border-2 border-white border-t-transparent mr-2" />
) : null}
{t('notebooks.version_restore')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+110 -1
View File
@@ -5,7 +5,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { import {
FileText, Plus, Eye, Download, Check, PenLine, BookOpen, Printer, FileText, Plus, Eye, Download, Check, PenLine, BookOpen, Printer,
School, Clock, Sparkles, FileEdit, FileCheck, FileType, School, Clock, Sparkles, FileEdit, FileCheck, FileType,
CheckCircle, BarChart3, ClipboardList, CheckCircle, BarChart3, ClipboardList, FileDown, Users,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -70,6 +70,12 @@ export default function ReportsView() {
// Preview dialog // Preview dialog
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
// PDF generation
const [pdfTemplate, setPdfTemplate] = useState<'short' | 'full' | 'custom'>('full');
const [generatingPdf, setGeneratingPdf] = useState(false);
const [downloadHistory, setDownloadHistory] = useState<Array<{ id: string; studentName: string; template: string; date: string }>>([]);
const [batchGenerating, setBatchGenerating] = useState(false);
// Edit section // Edit section
const [editingSection, setEditingSection] = useState<{ id: string; text: string } | null>(null); const [editingSection, setEditingSection] = useState<{ id: string; text: string } | null>(null);
@@ -160,6 +166,50 @@ export default function ReportsView() {
setEditingSection(null); 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 draftReports = reports.filter((r) => r.status === 'DRAFT');
const finalReports = reports.filter((r) => r.status === 'FINAL'); const finalReports = reports.filter((r) => r.status === 'FINAL');
@@ -484,6 +534,10 @@ export default function ReportsView() {
<Printer className="h-4 w-4 mr-1" /> <Printer className="h-4 w-4 mr-1" />
{t('pdf.print_view')} {t('pdf.print_view')}
</Button> </Button>
<Button variant="outline" className="rounded-xl border-emerald-300 dark:border-emerald-700" onClick={() => handleGeneratePdf(selectedReport.id)} disabled={generatingPdf}>
<FileDown className="h-4 w-4 mr-1" />
{generatingPdf ? t('empty.loading') : t('reports.generate_pdf')}
</Button>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@@ -758,6 +812,61 @@ export default function ReportsView() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* PDF Template Selection + Batch Generate */}
{selectedClass && students.length > 0 && (
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<FileDown className="h-4 w-4" />
</div>
{t('reports.generate_pdf')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Label className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('reports.template')}</Label>
<div className="flex gap-2">
{(['short', 'full', 'custom'] as const).map((tmpl) => (
<Button
key={tmpl}
variant={pdfTemplate === tmpl ? 'default' : 'outline'}
size="sm"
className={pdfTemplate === tmpl ? 'bg-emerald-500 hover:bg-emerald-600 text-white min-h-[44px] rounded-xl' : 'min-h-[44px] rounded-xl'}
onClick={() => setPdfTemplate(tmpl)}
>
<span className="badge-type">{t(`reports.template_${tmpl}`)}</span>
</Button>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md rounded-xl min-h-[44px]"
onClick={handleBatchGenerate}
disabled={batchGenerating}
>
<Users className="h-4 w-4 mr-1.5" />
{batchGenerating ? t('empty.loading') : t('reports.batch_generate')}
</Button>
</div>
{downloadHistory.length > 0 && (
<div className="space-y-2 mt-2">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t('reports.download_history')}</p>
<div className="max-h-32 overflow-y-auto scrollbar-education space-y-1">
{downloadHistory.map((item) => (
<div key={item.id} className="flex items-center justify-between text-xs p-2 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<span className="text-gray-700 dark:text-gray-300">{item.studentName}</span>
<span className="text-gray-500 dark:text-gray-400">{item.template} · {item.date}</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Edit section dialog */} {/* Edit section dialog */}
<Dialog open={!!editingSection} onOpenChange={() => setEditingSection(null)}> <Dialog open={!!editingSection} onOpenChange={() => setEditingSection(null)}>
<DialogContent className="rounded-xl"> <DialogContent className="rounded-xl">
+435 -14
View File
@@ -34,6 +34,12 @@ import {
CheckCircle, CheckCircle,
Leaf, Leaf,
DatabaseIcon, DatabaseIcon,
Upload,
FileDown,
FileUp,
Loader2,
ClipboardCheck,
CalendarDays,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -69,8 +75,12 @@ import {
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Checkbox } from '@/components/ui/checkbox';
import { useAppStore } from '@/lib/store'; import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
import { toast } from 'sonner';
import { import {
fetchSchools, fetchSchools,
updateSchool, updateSchool,
@@ -87,14 +97,15 @@ import {
createUser, createUser,
updateUser, updateUser,
deleteUser, deleteUser,
createStudentUserAccount,
bulkCreateStudentAccounts,
fetchStudents,
type School as SchoolType, type School as SchoolType,
type SchoolYear, type SchoolYear,
type Subject, type Subject,
type AuditLogEntry, type AuditLogEntry,
type UserAccount, type UserAccount,
} from '@/lib/api'; } from '@/lib/api';
import { toast } from 'sonner';
import { apiGet, apiPut, apiDelete } from '@/lib/api';
const containerVariants = { const containerVariants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
@@ -168,10 +179,28 @@ export default function SettingsView() {
lastName: '', lastName: '',
email: '', email: '',
password: '', password: '',
role: 'TEACHER' as 'TEACHER' | 'SCHOOL_ADMIN' | 'SUPER_ADMIN', role: 'TEACHER' as 'TEACHER' | 'SCHOOL_ADMIN' | 'SUPER_ADMIN' | 'STUDENT' | 'PARENT',
}); });
const [deleteUserId, setDeleteUserId] = useState<string | null>(null); const [deleteUserId, setDeleteUserId] = useState<string | null>(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<Array<{ id: string; firstName: string; lastName: string }>>([]);
const [bulkCreating, setBulkCreating] = useState(false);
// Demo accounts state // Demo accounts state
const [demoAccounts, setDemoAccounts] = useState<Array<{ const [demoAccounts, setDemoAccounts] = useState<Array<{
id: string; id: string;
@@ -188,6 +217,22 @@ export default function SettingsView() {
const [deleteDemoId, setDeleteDemoId] = useState<string | null>(null); const [deleteDemoId, setDeleteDemoId] = useState<string | null>(null);
const [deleteAllDemoOpen, setDeleteAllDemoOpen] = useState(false); const [deleteAllDemoOpen, setDeleteAllDemoOpen] = useState(false);
// Data import state
const [importType, setImportType] = useState<'students' | 'assessments' | 'grades'>('students');
const [importFile, setImportFile] = useState<File | null>(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<Array<{ type: string; date: string }>>([]);
const isAdmin = currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN'; const isAdmin = currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const loadSchools = useCallback(async () => { const loadSchools = useCallback(async () => {
@@ -388,15 +433,51 @@ export default function SettingsView() {
} }
}; };
const handleCsvExport = (type: 'students' | 'progress' | 'assessments' | 'grades') => { const handleCsvExport = (type: 'students' | 'progress' | 'assessments' | 'grades' | 'attendance') => {
downloadCsvExport({ downloadCsvExport({
type, type,
schoolId: currentUser?.schoolId ?? undefined, schoolId: currentUser?.schoolId ?? undefined,
schoolYearId: useAppStore.getState().schoolYearId ?? undefined, schoolYearId: useAppStore.getState().schoolYearId ?? undefined,
}); });
setExportHistory((prev) => [{ type, date: new Date().toLocaleDateString() }, ...prev].slice(0, 20));
toast.success(t('settings.data_export_started')); 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 () => { const handleErasureRequest = async () => {
try { try {
await requestDataErasure({ await requestDataErasure({
@@ -496,6 +577,72 @@ export default function SettingsView() {
setShowUserDialog(true); 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) => { const openEditUserDialog = (u: UserAccount) => {
setEditingUser(u); setEditingUser(u);
setUserForm({ setUserForm({
@@ -503,7 +650,7 @@ export default function SettingsView() {
lastName: u.lastName, lastName: u.lastName,
email: u.email, email: u.email,
password: '', password: '',
role: u.role as 'TEACHER' | 'SCHOOL_ADMIN' | 'SUPER_ADMIN', role: u.role as 'TEACHER' | 'SCHOOL_ADMIN' | 'SUPER_ADMIN' | 'STUDENT' | 'PARENT',
}); });
setShowUserDialog(true); setShowUserDialog(true);
}; };
@@ -1032,18 +1179,36 @@ export default function SettingsView() {
<CardDescription>{t('users.subtitle')}</CardDescription> <CardDescription>{t('users.subtitle')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between flex-wrap gap-2">
<Badge className="bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 text-xs font-medium"> <Badge className="bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 text-xs font-medium">
{t('users.count', { count: users.length })} {t('users.count', { count: users.length })}
</Badge> </Badge>
<div className="flex items-center gap-2">
<Button
onClick={openCreateStudentAccountDialog}
variant="outline"
className="border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-xl px-4 min-h-[44px]"
>
<UserCheck className="h-4 w-4 mr-2" />
{t('settings.create_student_account')}
</Button>
<Button
onClick={openBulkStudentDialog}
variant="outline"
className="border-teal-300 dark:border-teal-700 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/20 rounded-xl px-4 min-h-[44px]"
>
<Zap className="h-4 w-4 mr-2" />
{t('settings.bulk_create_students')}
</Button>
<Button <Button
onClick={openCreateUserDialog} onClick={openCreateUserDialog}
className="bg-gradient-to-r from-rose-500 to-amber-500 hover:from-rose-600 hover:to-amber-600 text-white shadow-md rounded-xl px-6" className="bg-gradient-to-r from-rose-500 to-amber-500 hover:from-rose-600 hover:to-amber-600 text-white shadow-md rounded-xl px-6 min-h-[44px]"
> >
<UserPlus className="h-4 w-4 mr-2" /> <UserPlus className="h-4 w-4 mr-2" />
{t('users.create_user')} {t('users.create_user')}
</Button> </Button>
</div> </div>
</div>
{usersLoading ? ( {usersLoading ? (
<div className="space-y-3"> <div className="space-y-3">
@@ -1181,7 +1346,7 @@ export default function SettingsView() {
<Label>{t('users.role')}</Label> <Label>{t('users.role')}</Label>
<Select <Select
value={userForm.role} value={userForm.role}
onValueChange={(v) => setUserForm({ ...userForm, role: v as 'TEACHER' | 'SCHOOL_ADMIN' | 'SUPER_ADMIN' })} onValueChange={(v) => setUserForm({ ...userForm, role: v as 'TEACHER' | 'SCHOOL_ADMIN' | 'SUPER_ADMIN' | 'STUDENT' | 'PARENT' })}
> >
<SelectTrigger className="rounded-xl border-rose-200/50 dark:border-rose-900/30"> <SelectTrigger className="rounded-xl border-rose-200/50 dark:border-rose-900/30">
<SelectValue /> <SelectValue />
@@ -1189,6 +1354,8 @@ export default function SettingsView() {
<SelectContent> <SelectContent>
<SelectItem value="TEACHER">{t('role.teacher')}</SelectItem> <SelectItem value="TEACHER">{t('role.teacher')}</SelectItem>
<SelectItem value="SCHOOL_ADMIN">{t('role.school_admin')}</SelectItem> <SelectItem value="SCHOOL_ADMIN">{t('role.school_admin')}</SelectItem>
<SelectItem value="STUDENT">{t('role.student')}</SelectItem>
<SelectItem value="PARENT">{t('role.parent')}</SelectItem>
{currentUser?.role === 'SUPER_ADMIN' && ( {currentUser?.role === 'SUPER_ADMIN' && (
<SelectItem value="SUPER_ADMIN">{t('role.super_admin')}</SelectItem> <SelectItem value="SUPER_ADMIN">{t('role.super_admin')}</SelectItem>
)} )}
@@ -1247,6 +1414,113 @@ export default function SettingsView() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* Create Student Account Dialog */}
<Dialog open={showStudentAccountDialog} onOpenChange={(open) => { if (!open) setShowStudentAccountDialog(false); }}>
<DialogContent className="sm:max-w-md" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserCheck className="h-4 w-4 text-amber-500" />
{t('settings.create_student_account')}
</DialogTitle>
<DialogDescription>{t('auth.student_login_desc')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t('label.first_name')}</Label>
<Input value={studentAccountForm.firstName} onChange={(e) => setStudentAccountForm({ ...studentAccountForm, firstName: e.target.value })} className="rounded-xl" />
</div>
<div className="space-y-2">
<Label>{t('label.last_name')}</Label>
<Input value={studentAccountForm.lastName} onChange={(e) => setStudentAccountForm({ ...studentAccountForm, lastName: e.target.value })} className="rounded-xl" />
</div>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-1"><Mail className="h-3 w-3" />{t('users.email')}</Label>
<Input type="email" value={studentAccountForm.email} onChange={(e) => setStudentAccountForm({ ...studentAccountForm, email: e.target.value })} className="rounded-xl" placeholder="name@schule.de" />
</div>
<div className="space-y-2">
<Label className="flex items-center gap-1"><Key className="h-3 w-3" />{t('settings.default_password')}</Label>
<Input type="text" value={studentAccountForm.password} onChange={(e) => setStudentAccountForm({ ...studentAccountForm, password: e.target.value })} className="rounded-xl" />
</div>
{availableStudents.length > 0 && (
<div className="space-y-2">
<Label>{t('settings.link_student')}</Label>
<Select value={studentAccountForm.studentId} onValueChange={(v) => setStudentAccountForm({ ...studentAccountForm, studentId: v })}>
<SelectTrigger className="rounded-xl"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="">--</SelectItem>
{availableStudents.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.firstName} {s.lastName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowStudentAccountDialog(false)} className="rounded-xl">{t('action.cancel')}</Button>
<Button onClick={handleCreateStudentAccount} disabled={!studentAccountForm.firstName || !studentAccountForm.lastName || !studentAccountForm.email || studentAccountForm.password.length < 6} className="bg-gradient-to-r from-amber-500 to-orange-500 text-white rounded-xl">{t('action.create')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bulk Create Student Accounts Dialog */}
<Dialog open={showBulkStudentDialog} onOpenChange={(open) => { if (!open) setShowBulkStudentDialog(false); }}>
<DialogContent className="sm:max-w-lg" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Zap className="h-4 w-4 text-teal-500" />
{t('settings.bulk_create_students')}
</DialogTitle>
<DialogDescription>{t('auth.bulk_create')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label className="flex items-center gap-1"><Key className="h-3 w-3" />{t('settings.default_password')}</Label>
<Input type="text" value={bulkForm.defaultPassword} onChange={(e) => setBulkForm({ ...bulkForm, defaultPassword: e.target.value })} className="rounded-xl" />
</div>
<div className="space-y-2">
<Label className="flex items-center gap-1"><Mail className="h-3 w-3" />{t('settings.generate_email')}</Label>
<Input type="text" value={bulkForm.emailDomain} onChange={(e) => setBulkForm({ ...bulkForm, emailDomain: e.target.value })} className="rounded-xl" placeholder="schule.de" />
</div>
<div className="space-y-2">
<Label>{t('settings.link_student')} ({bulkForm.selectedStudentIds.length} {t('label.selected')})</Label>
<div className="max-h-48 overflow-y-auto scrollbar-education rounded-xl border border-gray-200/30 dark:border-gray-800/20">
{availableStudents.map((s) => (
<label key={s.id} className="flex items-center gap-2 px-3 py-2 hover:bg-teal-50/50 dark:hover:bg-teal-900/10 cursor-pointer min-h-[44px]">
<Checkbox
checked={bulkForm.selectedStudentIds.includes(s.id)}
onCheckedChange={(checked) => {
if (checked) {
setBulkForm({ ...bulkForm, selectedStudentIds: [...bulkForm.selectedStudentIds, s.id] });
} else {
setBulkForm({ ...bulkForm, selectedStudentIds: bulkForm.selectedStudentIds.filter((id) => id !== s.id) });
}
}}
/>
<span className="text-sm font-medium">{s.firstName} {s.lastName}</span>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowBulkStudentDialog(false)} className="rounded-xl">{t('action.cancel')}</Button>
<Button onClick={handleBulkCreateStudents} disabled={bulkForm.selectedStudentIds.length === 0 || bulkCreating} className="bg-gradient-to-r from-teal-500 to-emerald-500 text-white rounded-xl">
{bulkCreating ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
{t('empty.loading')}
</span>
) : (
<span>{t('action.create')} ({bulkForm.selectedStudentIds.length})</span>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
@@ -1442,6 +1716,137 @@ export default function SettingsView() {
{/* ── Data Management Tab ─────────────────────────────── */} {/* ── Data Management Tab ─────────────────────────────── */}
<TabsContent value="data"> <TabsContent value="data">
<div className="space-y-6"> <div className="space-y-6">
{/* Data Import */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
<CardHeader className="bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<Upload className="h-4 w-4" />
</div>
{t('import.title')}
</CardTitle>
<CardDescription>{t('import.csv_upload')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Import type selector */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('calendar.event_type')}</Label>
<div className="flex gap-2">
{(['students', 'assessments', 'grades'] as const).map((impType) => {
const icons = { students: GraduationCap, assessments: ClipboardCheck, grades: BarChart3 };
const Icon = icons[impType];
return (
<Button
key={impType}
variant={importType === impType ? 'default' : 'outline'}
size="sm"
className={importType === impType ? 'bg-emerald-500 hover:bg-emerald-600 text-white min-h-[44px] rounded-xl' : 'min-h-[44px] rounded-xl'}
onClick={() => { setImportType(impType); setImportResult(null); }}
>
<Icon className="h-4 w-4 mr-1.5" />
{impType === 'students' ? t('label.student') : impType === 'assessments' ? t('nav.assessments') : t('nav.grading')}
</Button>
);
})}
</div>
</div>
{/* Drag and drop zone */}
<div
className={`file-drop-zone ${dragOver ? 'drag-over' : ''}`}
onDragOver={(e) => { 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');
}
}}
>
<Upload className="h-8 w-8 text-amber-400 dark:text-amber-500 mb-2" />
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
{importFile ? importFile.name : t('import.csv_upload')}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Drag & drop or click to select
</p>
<input
type="file"
accept=".csv"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setImportFile(file);
setImportResult(null);
}
}}
/>
</div>
{/* Import button */}
<div className="flex items-center gap-3">
<Button
onClick={handleImport}
disabled={!importFile || importing}
className="bg-gradient-to-r from-amber-500 to-emerald-500 hover:from-amber-600 hover:to-emerald-600 text-white shadow-md rounded-xl min-h-[44px]"
>
{importing ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <FileUp className="h-4 w-4 mr-1.5" />}
{importing ? t('import.progress') : t('action.upload')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadSample(importType)}
className="rounded-xl min-h-[44px]"
>
<Download className="h-4 w-4 mr-1.5" />
{t('import.sample_csv')}
</Button>
</div>
{/* Import result */}
{importResult && (
<div className="import-progress p-4 rounded-xl bg-gradient-to-r from-emerald-50/60 to-teal-50/30 dark:from-emerald-900/15 dark:to-teal-900/10 border border-emerald-200/30 dark:border-emerald-900/20">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">{t('import.summary')}</p>
<div className="grid grid-cols-3 gap-3 mb-3">
<div className="text-center">
<p className="text-xl font-bold text-emerald-600 dark:text-emerald-400">{importResult.created}</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('import.success')}</p>
</div>
<div className="text-center">
<p className="text-xl font-bold text-amber-600 dark:text-amber-400">{importResult.skipped}</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400">Skipped</p>
</div>
<div className="text-center">
<p className="text-xl font-bold text-red-600 dark:text-red-400">{importResult.errorCount}</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('import.errors')}</p>
</div>
</div>
{importResult.detectedColumns.length > 0 && (
<div className="flex flex-wrap gap-1">
{importResult.detectedColumns.map((col) => (
<Badge key={col} variant="outline" className="text-[10px] bg-gray-50 dark:bg-gray-800/50">{col}</Badge>
))}
</div>
)}
{importResult.errors.length > 0 && (
<div className="mt-2 max-h-24 overflow-y-auto scrollbar-education">
{importResult.errors.slice(0, 5).map((err, i) => (
<p key={i} className="text-xs text-red-600 dark:text-red-400">{err}</p>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* CSV Export */} {/* CSV Export */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden"> <Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
<CardHeader className="bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent"> <CardHeader className="bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
@@ -1454,12 +1859,13 @@ export default function SettingsView() {
<CardDescription>{t('csv.export_title')}</CardDescription> <CardDescription>{t('csv.export_title')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
{[ {[
{ 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: '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('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: '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: 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: '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('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: '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) => ( ].map((item) => (
<motion.div <motion.div
key={item.type} key={item.type}
@@ -1468,7 +1874,7 @@ export default function SettingsView() {
className={`p-5 rounded-xl bg-gradient-to-br ${item.color} border ${item.border} hover:shadow-lg transition-all cursor-pointer`} className={`p-5 rounded-xl bg-gradient-to-br ${item.color} border ${item.border} hover:shadow-lg transition-all cursor-pointer`}
onClick={() => handleCsvExport(item.type)} onClick={() => handleCsvExport(item.type)}
> >
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br ${item.iconBg} text-white shadow-sm mb-3"> <div className={`flex items-center justify-center w-10 h-10 rounded-xl ${item.iconBg} text-white shadow-sm mb-3`}>
<item.iconComponent className="w-5 h-5" /> <item.iconComponent className="w-5 h-5" />
</div> </div>
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{item.label}</p> <p className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{item.label}</p>
@@ -1476,6 +1882,21 @@ export default function SettingsView() {
</motion.div> </motion.div>
))} ))}
</div> </div>
{/* Export history */}
{exportHistory.length > 0 && (
<div className="mt-4 space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t('settings.export_history')}</p>
<div className="max-h-24 overflow-y-auto scrollbar-education space-y-1">
{exportHistory.map((item, idx) => (
<div key={idx} className="flex items-center justify-between text-xs p-2 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<span className="text-gray-700 dark:text-gray-300 capitalize">{item.type}</span>
<span className="text-gray-500 dark:text-gray-400">{item.date}</span>
</div>
))}
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
+94 -3
View File
@@ -16,7 +16,7 @@ import {
Calculator, Flower2, BookOpen, GraduationCap, TrendingUp, Award, Calculator, Flower2, BookOpen, GraduationCap, TrendingUp, Award,
Printer, ChevronRight, Sparkles, MessageSquare, Grid3X3, Trophy, Flag, Zap, Printer, ChevronRight, Sparkles, MessageSquare, Grid3X3, Trophy, Flag, Zap,
Rocket, Target, PenLine, Pencil, Home, ClipboardList, Star, BarChart3, Rocket, Target, PenLine, Pencil, Home, ClipboardList, Star, BarChart3,
LucideIcon, Download, FileSpreadsheet, FileDown, LucideIcon, Download, FileSpreadsheet, FileDown, Heart, Users as UsersIcon,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -32,9 +32,9 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { useAppStore } from '@/lib/store'; import { useAppStore, type CurrentUser } from '@/lib/store';
import { t } from '@/lib/i18n'; 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 { toast } from 'sonner';
import TeacherNotesSection from './teacher-notes-section'; import TeacherNotesSection from './teacher-notes-section';
@@ -338,12 +338,24 @@ export default function StudentDetailView() {
const currentStudentId = useAppStore((s) => s.currentStudentId); const currentStudentId = useAppStore((s) => s.currentStudentId);
const navigateBack = useAppStore((s) => s.navigateBack); const navigateBack = useAppStore((s) => s.navigateBack);
const setCurrentView = useAppStore((s) => s.setCurrentView); 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<ParentStudentLinkData[]>([]);
const [data, setData] = useState<StudentDetailData | null>(null); const [data, setData] = useState<StudentDetailData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedFlowerSubjectId, setSelectedFlowerSubjectId] = useState<string>(''); const [selectedFlowerSubjectId, setSelectedFlowerSubjectId] = useState<string>('');
const [timeRange, setTimeRange] = useState<JourneyTimeRange>('all'); const [timeRange, setTimeRange] = useState<JourneyTimeRange>('all');
// Load parent links for parent users
useEffect(() => {
if (isParent && currentUser?.id) {
fetchParentLinks(currentUser.id).then(setParentLinks).catch(() => {});
}
}, [isParent, currentUser?.id]);
useEffect(() => { useEffect(() => {
if (!currentStudentId) { if (!currentStudentId) {
setLoading(false); setLoading(false);
@@ -1142,6 +1154,85 @@ export default function StudentDetailView() {
{/* Teacher notes — replaced with the dedicated TeacherNotesSection */} {/* Teacher notes — replaced with the dedicated TeacherNotesSection */}
<TeacherNotesSection studentId={student.id} /> <TeacherNotesSection studentId={student.id} />
{/* My Progress section (for student self-view) */}
{isStudentSelf && (
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<TrendingUp className="h-4 w-4" />
</div>
{t('student.my_competencies')}
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-2">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">{t('student.progress_overview')}</p>
{/* Mastery Level Visualization */}
<div className="space-y-3">
{stats.averageMastery > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">{t('student_detail.avg_mastery')}</span>
<span className="text-xs font-bold text-emerald-600 dark:text-emerald-400">{stats.averageMastery.toFixed(1)} / 4.0</span>
</div>
<div className="h-3 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-teal-500 transition-all duration-1000"
style={{ width: `${Math.min(100, (stats.averageMastery / 4) * 100)}%` }}
/>
</div>
</div>
)}
{stats.totalProgressEntries > 0 && (
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-amber-50/50 dark:bg-amber-900/10 border border-amber-100/50 dark:border-amber-900/20">
<span className="text-xs text-gray-600 dark:text-gray-400">{t('student_detail.total_entries')}</span>
<span className="text-sm font-bold text-amber-600 dark:text-amber-400">{stats.totalProgressEntries}</span>
</div>
)}
{stats.latestGrade && (
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-teal-50/50 dark:bg-teal-900/10 border border-teal-100/50 dark:border-teal-900/20">
<span className="text-xs text-gray-600 dark:text-gray-400">{t('student_detail.latest_grade')}</span>
<span className="text-sm font-bold text-teal-600 dark:text-teal-400">{stats.latestGrade.value.toFixed(1)}</span>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Parent Info section (for parent users) */}
{isParent && parentLinks.length > 0 && (
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
<Heart className="h-4 w-4" />
</div>
{t('parent.my_children')}
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-2">
<div className="space-y-2">
{parentLinks.map((link) => (
<div key={link.id} className="flex items-center justify-between px-3 py-2 rounded-lg bg-violet-50/50 dark:bg-violet-900/10 border border-violet-100/50 dark:border-violet-900/20">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-violet-200 dark:bg-violet-800 text-violet-700 dark:text-violet-300 text-[10px] font-bold">
{link.student.firstName[0]}{link.student.lastName[0]}
</div>
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">{link.student.firstName} {link.student.lastName}</span>
</div>
{link.relationship && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 bg-violet-50/50 dark:bg-violet-900/10 border-violet-200/50 dark:border-violet-900/30 text-violet-700 dark:text-violet-300 shrink-0">
{t(`parent.${link.relationship}`)}
</Badge>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
</div> </div>
</div> </div>
); );
+83 -2
View File
@@ -1344,6 +1344,13 @@ export function fetchCalendarEvents(schoolId: string, month: string): Promise<Ca
export type CalendarEventItemType = 'assessment' | 'lesson' | 'reminder'; export type CalendarEventItemType = 'assessment' | 'lesson' | 'reminder';
export interface RecurrencePattern {
type: 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly';
interval: number;
endDate?: string;
daysOfWeek?: number[];
}
export interface CalendarEventItem { export interface CalendarEventItem {
id: string; id: string;
schoolId: string; schoolId: string;
@@ -1357,10 +1364,15 @@ export interface CalendarEventItem {
classGroupId: string | null; classGroupId: string | null;
notes: string | null; notes: string | null;
allDay: boolean; allDay: boolean;
recurrencePattern: string | null;
recurrenceEnd: string | null;
parentEventId: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
subject: { id: string; name: string } | null; subject: { id: string; name: string } | null;
classGroup: { id: string; name: string } | null; classGroup: { id: string; name: string } | null;
childEvents?: { id: string; date: string }[];
childCount?: number;
} }
export function fetchCalendarEventItems(schoolId: string, month: string): Promise<CalendarEventItem[]> { export function fetchCalendarEventItems(schoolId: string, month: string): Promise<CalendarEventItem[]> {
@@ -1381,6 +1393,8 @@ export function createCalendarEventItem(data: {
classGroupId?: string | null; classGroupId?: string | null;
notes?: string | null; notes?: string | null;
allDay?: boolean; allDay?: boolean;
recurrencePattern?: RecurrencePattern | null;
recurrenceEnd?: string | null;
}): Promise<CalendarEventItem> { }): Promise<CalendarEventItem> {
return apiPost<CalendarEventItem>('/api/calendar-events', data); return apiPost<CalendarEventItem>('/api/calendar-events', data);
} }
@@ -1395,12 +1409,16 @@ export function updateCalendarEventItem(id: string, data: {
classGroupId?: string | null; classGroupId?: string | null;
notes?: string | null; notes?: string | null;
allDay?: boolean; allDay?: boolean;
recurrencePattern?: RecurrencePattern | null;
recurrenceEnd?: string | null;
editMode?: 'series' | 'instance';
}): Promise<CalendarEventItem> { }): Promise<CalendarEventItem> {
return apiPut<CalendarEventItem>(`/api/calendar-events/${id}`, data); return apiPut<CalendarEventItem>(`/api/calendar-events/${id}`, data);
} }
export function deleteCalendarEventItem(id: string): Promise<{ success: boolean }> { export function deleteCalendarEventItem(id: string, mode?: 'series' | 'instance'): Promise<{ success: boolean }> {
return apiDelete<{ success: boolean }>(`/api/calendar-events/${id}`); const query = mode ? `?mode=${mode}` : '';
return apiDelete<{ success: boolean }>(`/api/calendar-events/${id}${query}`);
} }
/* ── Lesson Plans ────────────────────────────────────────────────── */ /* ── Lesson Plans ────────────────────────────────────────────────── */
@@ -2110,3 +2128,66 @@ export async function deleteBatchProgressEntries(ids: string[]): Promise<void> {
export async function reorderStudents(classGroupId: string, studentIds: string[]): Promise<void> { export async function reorderStudents(classGroupId: string, studentIds: string[]): Promise<void> {
await apiPut<{ success: boolean }>('/api/students/reorder', { classGroupId, studentIds }); 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<ParentStudentLinkData[]> {
const params = new URLSearchParams();
if (parentId) params.set('parentId', parentId);
return apiGet<ParentStudentLinkData[]>(`/api/parent-links?${params.toString()}`);
}
export function createParentLink(data: {
parentId: string;
studentId: string;
schoolId: string;
relationship?: string;
}): Promise<ParentStudentLinkData> {
return apiPost<ParentStudentLinkData>('/api/parent-links', data);
}
export function updateParentLink(id: string, data: { relationship?: string }): Promise<ParentStudentLinkData> {
return apiPut<ParentStudentLinkData>(`/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<Record<string, unknown>> {
return apiPost<Record<string, unknown>>('/api/users', data);
}
export function bulkCreateStudentAccounts(data: {
schoolId: string;
defaultPassword: string;
studentIds: string[];
emailDomain?: string;
locale?: string;
}): Promise<{ created: Array<Record<string, unknown>>; count: number }> {
return apiPost<{ created: Array<Record<string, unknown>>; count: number }>('/api/users', { ...data, action: 'bulkCreateStudents' });
}
+206
View File
@@ -1767,6 +1767,8 @@ const de: TranslationDict = {
'drawing.bg_grid': 'Kariert', 'drawing.bg_grid': 'Kariert',
'drawing.bg_dotted': 'Gepunktet', 'drawing.bg_dotted': 'Gepunktet',
'drawing.guide_mode': 'Hilfslinien', 'drawing.guide_mode': 'Hilfslinien',
'drawing.zoom_in': 'Vergrößern',
'drawing.zoom_out': 'Verkleinern',
'drawing.guide_shapes': 'Formvorlagen', 'drawing.guide_shapes': 'Formvorlagen',
'drawing.guide_off': 'Aus', 'drawing.guide_off': 'Aus',
'drawing.guide_basic': 'Grundformen', 'drawing.guide_basic': 'Grundformen',
@@ -1927,6 +1929,23 @@ const de: TranslationDict = {
'notebooks.open_drawing': 'Zeichnung öffnen', 'notebooks.open_drawing': 'Zeichnung öffnen',
'notebooks.switch_to_mixed': 'Text + Zeichnung', '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 // PDF Export
'notebooks.export_pdf': 'PDF exportieren', 'notebooks.export_pdf': 'PDF exportieren',
'notebooks.export_error': 'Fehler beim Exportieren. Bitte Popups erlauben.', 'notebooks.export_error': 'Fehler beim Exportieren. Bitte Popups erlauben.',
@@ -2022,6 +2041,7 @@ const de: TranslationDict = {
'nav.student_notebooks': 'Meine Hefte', 'nav.student_notebooks': 'Meine Hefte',
'nav.student_attendance': 'Meine Anwesenheit', 'nav.student_attendance': 'Meine Anwesenheit',
'role.student': 'Schueler', 'role.student': 'Schueler',
'role.parent': 'Elternteil',
// Calendar (enhanced - Task 4) // Calendar (enhanced - Task 4)
'calendar.new_event': 'Neues Ereignis', 'calendar.new_event': 'Neues Ereignis',
@@ -2043,6 +2063,40 @@ const de: TranslationDict = {
'calendar.event_create_error': 'Fehler beim Erstellen des Ereignisses', 'calendar.event_create_error': 'Fehler beim Erstellen des Ereignisses',
'calendar.event_update_error': 'Fehler beim Aktualisieren des Ereignisses', 'calendar.event_update_error': 'Fehler beim Aktualisieren des Ereignisses',
'calendar.event_delete_error': 'Fehler beim Löschen 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 (enhanced - Task 4)
'dashboard.new_assessment': 'Neue Bewertung', 'dashboard.new_assessment': 'Neue Bewertung',
@@ -2057,6 +2111,55 @@ const de: TranslationDict = {
// Student (enhanced - Task 4) // Student (enhanced - Task 4)
'student.shared_with_me': 'Mit mir geteilt', '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 = { const en: TranslationDict = {
@@ -3823,6 +3926,8 @@ const en: TranslationDict = {
'drawing.bg_grid': 'Grid', 'drawing.bg_grid': 'Grid',
'drawing.bg_dotted': 'Dotted', 'drawing.bg_dotted': 'Dotted',
'drawing.guide_mode': 'Guide Mode', 'drawing.guide_mode': 'Guide Mode',
'drawing.zoom_in': 'Zoom In',
'drawing.zoom_out': 'Zoom Out',
'drawing.guide_shapes': 'Shape Templates', 'drawing.guide_shapes': 'Shape Templates',
'drawing.guide_off': 'Off', 'drawing.guide_off': 'Off',
'drawing.guide_basic': 'Basic Shapes', 'drawing.guide_basic': 'Basic Shapes',
@@ -3983,6 +4088,23 @@ const en: TranslationDict = {
'notebooks.open_drawing': 'Open drawing', 'notebooks.open_drawing': 'Open drawing',
'notebooks.switch_to_mixed': 'Text + 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 // PDF Export
'notebooks.export_pdf': 'Export PDF', 'notebooks.export_pdf': 'Export PDF',
'notebooks.export_error': 'Export error. Please allow popups.', 'notebooks.export_error': 'Export error. Please allow popups.',
@@ -4078,6 +4200,7 @@ const en: TranslationDict = {
'nav.student_notebooks': 'My Notebooks', 'nav.student_notebooks': 'My Notebooks',
'nav.student_attendance': 'My Attendance', 'nav.student_attendance': 'My Attendance',
'role.student': 'Student', 'role.student': 'Student',
'role.parent': 'Parent',
// Calendar (enhanced - Task 4) // Calendar (enhanced - Task 4)
'calendar.new_event': 'New Event', 'calendar.new_event': 'New Event',
@@ -4099,6 +4222,40 @@ const en: TranslationDict = {
'calendar.event_create_error': 'Error creating event', 'calendar.event_create_error': 'Error creating event',
'calendar.event_update_error': 'Error updating event', 'calendar.event_update_error': 'Error updating event',
'calendar.event_delete_error': 'Error deleting 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 (enhanced - Task 4)
'dashboard.new_assessment': 'New Assessment', 'dashboard.new_assessment': 'New Assessment',
@@ -4113,6 +4270,55 @@ const en: TranslationDict = {
// Student (enhanced - Task 4) // Student (enhanced - Task 4)
'student.shared_with_me': 'Shared with me', '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<string, TranslationDict> = { de, en }; const translations: Record<string, TranslationDict> = { de, en };