From 6174f127f96ada08ab32efbb1a90089b0d28143d Mon Sep 17 00:00:00 2001 From: Mustafa Cetin Date: Wed, 29 Jul 2026 11:45:24 +0000 Subject: [PATCH] Round 16: Peer Assessment, Emergency Contacts, School Events, School Branding, Email Templates, Performance, Styling Polish, Onboarding 24 Steps --- prisma/schema.prisma | 172 +++ src/app/api/email-logs/route.ts | 43 + src/app/api/email-templates/[id]/route.ts | 107 ++ src/app/api/email-templates/route.ts | 75 ++ src/app/api/email-templates/send/route.ts | 95 ++ src/app/api/emergency-contacts/[id]/route.ts | 137 ++ src/app/api/emergency-contacts/route.ts | 109 ++ src/app/api/peer-assessments/[id]/route.ts | 161 +++ src/app/api/peer-assessments/route.ts | 146 +++ .../api/school-events/[id]/register/route.ts | 113 ++ src/app/api/school-events/[id]/route.ts | 165 +++ src/app/api/school-events/route.ts | 142 +++ src/app/api/schools/route.ts | 67 + src/app/globals.css | 215 ++++ src/components/app-layout.tsx | 97 +- src/components/assessments-view.tsx | 2 +- src/components/attendance-view.tsx | 4 +- src/components/classes-view.tsx | 2 +- src/components/dashboard-view.tsx | 215 +++- src/components/grading-view.tsx | 8 +- src/components/lesson-plans-view.tsx | 2 +- src/components/onboarding-tour.tsx | 330 +++-- src/components/progress-entries-view.tsx | 2 +- src/components/reports-view.tsx | 12 +- src/components/settings-view.tsx | 1125 ++++++++++++++++- src/components/student-detail-view.tsx | 639 ++++++++++ src/lib/api.ts | 108 +- src/lib/cache.ts | 79 ++ src/lib/i18n.ts | 484 +++++++ worklog.md | 121 ++ 30 files changed, 4835 insertions(+), 142 deletions(-) create mode 100644 src/app/api/email-logs/route.ts create mode 100644 src/app/api/email-templates/[id]/route.ts create mode 100644 src/app/api/email-templates/route.ts create mode 100644 src/app/api/email-templates/send/route.ts create mode 100644 src/app/api/emergency-contacts/[id]/route.ts create mode 100644 src/app/api/emergency-contacts/route.ts create mode 100644 src/app/api/peer-assessments/[id]/route.ts create mode 100644 src/app/api/peer-assessments/route.ts create mode 100644 src/app/api/school-events/[id]/register/route.ts create mode 100644 src/app/api/school-events/[id]/route.ts create mode 100644 src/app/api/school-events/route.ts create mode 100644 src/lib/cache.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f51fd76..495f776 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,6 +21,19 @@ model School { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + // School branding fields + logoUrl String? // school logo URL + primaryColor String? @default("#10b981") // primary brand color + secondaryColor String? @default("#14b8a6") // secondary brand color + accentColor String? @default("#059669") // accent brand color + fontFamily String? @default("Inter") // preferred font + customCss String? // custom CSS overrides + motto String? // school motto + websiteUrl String? // school website URL + emailDomain String? // school email domain (e.g., "@schule.de") + address String? // school address + phone String? // school phone number + users User[] schoolYears SchoolYear[] classGroups ClassGroup[] @@ -51,6 +64,11 @@ model School { portfolioEntries PortfolioEntry[] timetableSlots TimetableSlot[] resources Resource[] + emailTemplates EmailTemplate[] + emailLogs EmailLog[] + peerAssessments PeerAssessment[] + emergencyContacts EmergencyContact[] + schoolEvents SchoolEvent[] district SchoolDistrict? @relation(fields: [districtId], references: [id]) districtId String? } @@ -95,6 +113,8 @@ model User { announcements Announcement[] timetableSlots TimetableSlot[] resources Resource[] + organizedSchoolEvents SchoolEvent[] + eventRegistrations EventRegistration[] } model SchoolYear { @@ -146,6 +166,8 @@ model ClassGroup { selfAssessments SelfAssessment[] timetableSlots TimetableSlot[] resources Resource[] + peerAssessments PeerAssessment[] + schoolEvents SchoolEvent[] } model ClassGroupTeacher { @@ -188,6 +210,9 @@ model Student { selfAssessments SelfAssessment[] learningGoals LearningGoal[] portfolioEntries PortfolioEntry[] + peerAssessmentsGiven PeerAssessment[] @relation("PeerAssessor") + peerAssessmentsReceived PeerAssessment[] @relation("PeerAssessed") + emergencyContacts EmergencyContact[] } model Enrollment { @@ -280,6 +305,7 @@ model Competency { selfAssessments SelfAssessment[] learningGoals LearningGoal[] portfolioEntries PortfolioEntry[] + peerAssessments PeerAssessment[] } model MasteryLevelDefinition { @@ -690,6 +716,7 @@ model Rubric { updatedAt DateTime @updatedAt criteria RubricCriterion[] + peerAssessments PeerAssessment[] @@index([schoolId]) @@index([teacherId]) @@ -1306,3 +1333,148 @@ model Resource { @@index([schoolId, resourceType]) @@map("resources") } + +// ─── Email Templates & Logging ─────────────────────────────────────── + +model EmailTemplate { + id String @id @default(cuid()) + schoolId String + name String // "weekly_report", "assessment_reminder", "behavior_alert", "attendance_notice", "welcome" + subject String + body String // HTML template with {{variables}} + isDefault Boolean @default(false) + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + + @@unique([schoolId, name]) + @@map("email_templates") +} + +model EmailLog { + id String @id @default(cuid()) + schoolId String + templateId String? + recipientEmail String + recipientName String? + subject String + body String + status String @default("pending") // pending, sent, failed, bounced + sentAt DateTime? + error String? + createdAt DateTime @default(now()) + + school School @relation(fields: [schoolId], references: [id]) + + @@index([schoolId, status]) + @@map("email_logs") +} + +// ─── Peer Assessment ────────────────────────────────────────────── + +model PeerAssessment { + id String @id @default(cuid()) + schoolId String + assessorId String // student who is assessing + assessedId String // student being assessed + competencyId String? + classGroupId String? + assessmentType String // "competency", "project", "presentation", "teamwork", "behavior" + level Int? // 1-6 rating + comment String? + rubricId String? // optional rubric reference + isAnonymous Boolean @default(false) + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + assessor Student @relation("PeerAssessor", fields: [assessorId], references: [id]) + assessed Student @relation("PeerAssessed", fields: [assessedId], references: [id]) + competency Competency? @relation(fields: [competencyId], references: [id]) + classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) + rubric Rubric? @relation(fields: [rubricId], references: [id]) + + @@index([schoolId, assessedId]) + @@index([schoolId, assessorId]) + @@map("peer_assessments") +} + +// ─── Emergency Contacts ─────────────────────────────────────────── + +model EmergencyContact { + id String @id @default(cuid()) + schoolId String + studentId String + name String + relationship String // "mother", "father", "guardian", "grandparent", "other" + phone String + phoneAlt String? + email String? + address String? + isPrimary Boolean @default(false) + priority Int @default(1) // 1=first call, 2=second call, etc. + notes String? + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + + @@index([schoolId, studentId]) + @@map("emergency_contacts") +} + +// ─── School Events ──────────────────────────────────────────────── + +model SchoolEvent { + id String @id @default(cuid()) + schoolId String + title String + description String? + eventType String // "assembly", "field_trip", "sports_day", "concert", "fair", "parent_meeting", "graduation", "holiday" + startDate DateTime + endDate DateTime? + location String? + organizerId String? + classGroupId String? // if event is for specific class + isAllSchool Boolean @default(true) + isPublic Boolean @default(true) + requiresRegistration Boolean @default(false) + maxParticipants Int? + notes String? + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + organizer User? @relation(fields: [organizerId], references: [id]) + classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) + registrations EventRegistration[] + + @@index([schoolId, startDate]) + @@map("school_events") +} + +model EventRegistration { + id String @id @default(cuid()) + eventId String + userId String + status String @default("registered") // registered, cancelled, attended + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + event SchoolEvent @relation(fields: [eventId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) + + @@unique([eventId, userId]) + @@map("event_registrations") +} diff --git a/src/app/api/email-logs/route.ts b/src/app/api/email-logs/route.ts new file mode 100644 index 0000000..83ce75d --- /dev/null +++ b/src/app/api/email-logs/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId; + const status = searchParams.get('status'); + const limit = parseInt(searchParams.get('limit') || '50', 10); + + if (!schoolId) { + return NextResponse.json({ error: 'School ID required' }, { status: 400 }); + } + + const where: Record = { schoolId }; + if (status) where.status = status; + + const logs = await db.emailLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + + const counts = { + total: await db.emailLog.count({ where: { schoolId } }), + sent: await db.emailLog.count({ where: { schoolId, status: 'sent' } }), + failed: await db.emailLog.count({ where: { schoolId, status: 'failed' } }), + pending: await db.emailLog.count({ where: { schoolId, status: 'pending' } }), + bounced: await db.emailLog.count({ where: { schoolId, status: 'bounced' } }), + }; + + return NextResponse.json({ logs, counts }); + } catch (error) { + console.error('EmailLogs GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/email-templates/[id]/route.ts b/src/app/api/email-templates/[id]/route.ts new file mode 100644 index 0000000..f072d94 --- /dev/null +++ b/src/app/api/email-templates/[id]/route.ts @@ -0,0 +1,107 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +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 template = await db.emailTemplate.findUnique({ + where: { id, deletedAt: null }, + }); + + if (!template) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json(template); + } catch (error) { + console.error('EmailTemplate GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +const updateTemplateSchema = z.object({ + name: z.string().min(1).optional(), + subject: z.string().min(1).optional(), + body: z.string().min(1).optional(), + isDefault: z.boolean().optional(), +}); + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user?.role !== 'SUPER_ADMIN' && + session.user?.role !== 'SCHOOL_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json(); + const parsed = updateTemplateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const template = await db.emailTemplate.update({ + where: { id }, + data: parsed.data, + }); + + return NextResponse.json(template); + } catch (error) { + console.error('EmailTemplate PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user?.role !== 'SUPER_ADMIN' && + session.user?.role !== 'SCHOOL_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + // Soft delete + await db.emailTemplate.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('EmailTemplate DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/email-templates/route.ts b/src/app/api/email-templates/route.ts new file mode 100644 index 0000000..8895ddc --- /dev/null +++ b/src/app/api/email-templates/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId; + + if (!schoolId) { + return NextResponse.json({ error: 'School ID required' }, { status: 400 }); + } + + const templates = await db.emailTemplate.findMany({ + where: { + schoolId, + deletedAt: null, + }, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json(templates); + } catch (error) { + console.error('EmailTemplates GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +const createTemplateSchema = z.object({ + schoolId: z.string().min(1), + name: z.string().min(1), + subject: z.string().min(1), + body: z.string().min(1), + isDefault: z.boolean().default(false), +}); + +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' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = createTemplateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const template = await db.emailTemplate.create({ + data: parsed.data, + }); + + return NextResponse.json(template, { status: 201 }); + } catch (error) { + console.error('EmailTemplates POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/email-templates/send/route.ts b/src/app/api/email-templates/send/route.ts new file mode 100644 index 0000000..68cabb1 --- /dev/null +++ b/src/app/api/email-templates/send/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const sendSchema = z.object({ + templateId: z.string().optional(), + schoolId: z.string().min(1), + recipientEmail: z.string().email(), + recipientName: z.string().optional(), + subject: z.string().min(1), + body: z.string().min(1), + isTest: z.boolean().default(false), +}); + +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' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = sendSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + // Replace template variables with sample data + const replaceVariables = (text: string, vars: Record) => { + let result = text; + for (const [key, value] of Object.entries(vars)) { + result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value); + } + return result; + }; + + const sampleVars: Record = { + studentName: parsed.data.recipientName || 'Max Mustermann', + className: 'Klasse 5a', + teacherName: `${session.user?.firstName || 'Frau'} ${session.user?.lastName || 'Muster'}`, + date: new Date().toLocaleDateString('de-DE'), + score: '85%', + schoolName: 'CompetenceTrack Schule', + subjectName: 'Mathematik', + behaviorDescription: 'Beschreibung des Vorfalls', + behaviorCategory: 'Störung', + attendanceStatus: 'Fehlend', + email: parsed.data.recipientEmail, + competencyProgress: 'Alle Kompetenzen auf dem erwarteten Niveau', + }; + + const processedSubject = replaceVariables(parsed.data.subject, sampleVars); + const processedBody = replaceVariables(parsed.data.body, sampleVars); + + // Create email log entry + const emailLog = await db.emailLog.create({ + data: { + schoolId: parsed.data.schoolId, + templateId: parsed.data.templateId || null, + recipientEmail: parsed.data.recipientEmail, + recipientName: parsed.data.recipientName || null, + subject: processedSubject, + body: processedBody, + status: 'sent', + sentAt: new Date(), + }, + }); + + // In production, this would use an actual email sending service + // For now, we log the email as "sent" and return success + console.log(`[Email] ${parsed.data.isTest ? '[TEST] ' : ''}To: ${parsed.data.recipientEmail}, Subject: ${processedSubject}`); + + return NextResponse.json({ + success: true, + logId: emailLog.id, + message: parsed.data.isTest + ? 'Test email logged successfully' + : 'Email logged successfully', + }); + } catch (error) { + console.error('Email send error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/emergency-contacts/[id]/route.ts b/src/app/api/emergency-contacts/[id]/route.ts new file mode 100644 index 0000000..bcd9a7d --- /dev/null +++ b/src/app/api/emergency-contacts/[id]/route.ts @@ -0,0 +1,137 @@ +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 contact = await db.emergencyContact.findFirst({ + where: { id, deletedAt: null }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + if (!contact) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== contact.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(contact); + } catch (error) { + console.error('EmergencyContact GET [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const existing = await db.emergencyContact.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { + name, + relationship, + phone, + phoneAlt, + email, + address, + isPrimary, + priority, + notes, + } = body; + + const updated = await db.emergencyContact.update({ + where: { id }, + data: { + ...(name !== undefined && { name }), + ...(relationship !== undefined && { relationship }), + ...(phone !== undefined && { phone }), + ...(phoneAlt !== undefined && { phoneAlt: phoneAlt || null }), + ...(email !== undefined && { email: email || null }), + ...(address !== undefined && { address: address || null }), + ...(isPrimary !== undefined && { isPrimary }), + ...(priority !== undefined && { priority }), + ...(notes !== undefined && { notes }), + }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('EmergencyContact PUT [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const existing = await db.emergencyContact.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Soft delete + await db.emergencyContact.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('EmergencyContact DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/emergency-contacts/route.ts b/src/app/api/emergency-contacts/route.ts new file mode 100644 index 0000000..0f1dbc3 --- /dev/null +++ b/src/app/api/emergency-contacts/route.ts @@ -0,0 +1,109 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId'); + const studentId = searchParams.get('studentId'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const where: Record = { + schoolId, + deletedAt: null, + }; + + if (studentId) where.studentId = studentId; + + const contacts = await db.emergencyContact.findMany({ + where, + orderBy: [{ isPrimary: 'desc' }, { priority: 'asc' }], + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + return NextResponse.json(contacts); + } catch (error) { + console.error('EmergencyContact 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 }); + } + + const body = await request.json(); + const { + schoolId, + studentId, + name, + relationship, + phone, + phoneAlt, + email, + address, + isPrimary, + priority, + notes, + isDemo, + } = body; + + if (!schoolId || !studentId || !name || !relationship || !phone) { + return NextResponse.json( + { error: 'schoolId, studentId, name, relationship, and phone are required' }, + { status: 400 } + ); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const contact = await db.emergencyContact.create({ + data: { + schoolId, + studentId, + name, + relationship, + phone, + phoneAlt: phoneAlt || null, + email: email || null, + address: address || null, + isPrimary: isPrimary ?? false, + priority: priority ?? 1, + notes: notes || null, + isDemo: isDemo ?? false, + }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + return NextResponse.json(contact, { status: 201 }); + } catch (error) { + console.error('EmergencyContact POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/peer-assessments/[id]/route.ts b/src/app/api/peer-assessments/[id]/route.ts new file mode 100644 index 0000000..6d48a60 --- /dev/null +++ b/src/app/api/peer-assessments/[id]/route.ts @@ -0,0 +1,161 @@ +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 assessment = await db.peerAssessment.findFirst({ + where: { id, deletedAt: null }, + include: { + assessor: { + select: { id: true, firstName: true, lastName: true }, + }, + assessed: { + select: { id: true, firstName: true, lastName: true }, + }, + competency: { + select: { id: true, code: true, title: true }, + }, + classGroup: { + select: { id: true, name: true }, + }, + rubric: { + select: { id: true, title: true }, + }, + }, + }); + + if (!assessment) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== assessment.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(assessment); + } catch (error) { + console.error('PeerAssessment GET [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const existing = await db.peerAssessment.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { + level, + comment, + assessmentType, + competencyId, + classGroupId, + rubricId, + isAnonymous, + } = body; + + if (level !== undefined && level !== null && (level < 1 || level > 6)) { + return NextResponse.json({ error: 'level must be between 1 and 6' }, { status: 400 }); + } + + const updated = await db.peerAssessment.update({ + where: { id }, + data: { + ...(level !== undefined && { level: level ?? null }), + ...(comment !== undefined && { comment }), + ...(assessmentType !== undefined && { assessmentType }), + ...(competencyId !== undefined && { competencyId: competencyId || null }), + ...(classGroupId !== undefined && { classGroupId: classGroupId || null }), + ...(rubricId !== undefined && { rubricId: rubricId || null }), + ...(isAnonymous !== undefined && { isAnonymous }), + }, + include: { + assessor: { + select: { id: true, firstName: true, lastName: true }, + }, + assessed: { + select: { id: true, firstName: true, lastName: true }, + }, + competency: { + select: { id: true, code: true, title: true }, + }, + classGroup: { + select: { id: true, name: true }, + }, + rubric: { + select: { id: true, title: true }, + }, + }, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('PeerAssessment PUT [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const existing = await db.peerAssessment.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Soft delete + await db.peerAssessment.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('PeerAssessment DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/peer-assessments/route.ts b/src/app/api/peer-assessments/route.ts new file mode 100644 index 0000000..64ccd00 --- /dev/null +++ b/src/app/api/peer-assessments/route.ts @@ -0,0 +1,146 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId'); + const assessedId = searchParams.get('assessedId'); + const assessorId = searchParams.get('assessorId'); + const competencyId = searchParams.get('competencyId'); + const classGroupId = searchParams.get('classGroupId'); + const assessmentType = searchParams.get('assessmentType'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const where: Record = { + schoolId, + deletedAt: null, + }; + + if (assessedId) where.assessedId = assessedId; + if (assessorId) where.assessorId = assessorId; + if (competencyId) where.competencyId = competencyId; + if (classGroupId) where.classGroupId = classGroupId; + if (assessmentType) where.assessmentType = assessmentType; + + const assessments = await db.peerAssessment.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { + assessor: { + select: { id: true, firstName: true, lastName: true }, + }, + assessed: { + select: { id: true, firstName: true, lastName: true }, + }, + competency: { + select: { id: true, code: true, title: true }, + }, + classGroup: { + select: { id: true, name: true }, + }, + rubric: { + select: { id: true, title: true }, + }, + }, + }); + + return NextResponse.json(assessments); + } catch (error) { + console.error('PeerAssessment 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 }); + } + + const body = await request.json(); + const { + schoolId, + assessorId, + assessedId, + competencyId, + classGroupId, + assessmentType, + level, + comment, + rubricId, + isAnonymous, + isDemo, + } = body; + + if (!schoolId || !assessorId || !assessedId || !assessmentType) { + return NextResponse.json( + { error: 'schoolId, assessorId, assessedId, and assessmentType are required' }, + { status: 400 } + ); + } + + if (level !== undefined && level !== null && (level < 1 || level > 6)) { + return NextResponse.json( + { error: 'level must be between 1 and 6' }, + { status: 400 } + ); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const assessment = await db.peerAssessment.create({ + data: { + schoolId, + assessorId, + assessedId, + competencyId: competencyId || null, + classGroupId: classGroupId || null, + assessmentType, + level: level ?? null, + comment: comment || null, + rubricId: rubricId || null, + isAnonymous: isAnonymous ?? false, + isDemo: isDemo ?? false, + }, + include: { + assessor: { + select: { id: true, firstName: true, lastName: true }, + }, + assessed: { + select: { id: true, firstName: true, lastName: true }, + }, + competency: { + select: { id: true, code: true, title: true }, + }, + classGroup: { + select: { id: true, name: true }, + }, + rubric: { + select: { id: true, title: true }, + }, + }, + }); + + return NextResponse.json(assessment, { status: 201 }); + } catch (error) { + console.error('PeerAssessment POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/school-events/[id]/register/route.ts b/src/app/api/school-events/[id]/register/route.ts new file mode 100644 index 0000000..ca7d2ec --- /dev/null +++ b/src/app/api/school-events/[id]/register/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const event = await db.schoolEvent.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!event) { + return NextResponse.json({ error: 'Event not found' }, { status: 404 }); + } + + if (!event.requiresRegistration) { + return NextResponse.json({ error: 'Event does not require registration' }, { status: 400 }); + } + + const body = await request.json(); + const { userId, notes } = body; + + if (!userId) { + return NextResponse.json({ error: 'userId is required' }, { status: 400 }); + } + + // Check if already registered + const existing = await db.eventRegistration.findUnique({ + where: { eventId_userId: { eventId: id, userId } }, + }); + + if (existing) { + if (existing.status === 'cancelled') { + // Re-register + const updated = await db.eventRegistration.update({ + where: { id: existing.id }, + data: { status: 'registered', notes: notes || null }, + }); + return NextResponse.json(updated); + } + return NextResponse.json({ error: 'Already registered' }, { status: 409 }); + } + + // Check max participants + if (event.maxParticipants) { + const count = await db.eventRegistration.count({ + where: { eventId: id, status: 'registered' }, + }); + if (count >= event.maxParticipants) { + return NextResponse.json({ error: 'Event is full' }, { status: 400 }); + } + } + + const registration = await db.eventRegistration.create({ + data: { + eventId: id, + userId, + notes: notes || null, + }, + }); + + return NextResponse.json(registration, { status: 201 }); + } catch (error) { + console.error('EventRegistration POST 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 { userId, status } = body; + + if (!userId || !status) { + return NextResponse.json({ error: 'userId and status are required' }, { status: 400 }); + } + + const registration = await db.eventRegistration.findUnique({ + where: { eventId_userId: { eventId: id, userId } }, + }); + + if (!registration) { + return NextResponse.json({ error: 'Registration not found' }, { status: 404 }); + } + + const updated = await db.eventRegistration.update({ + where: { id: registration.id }, + data: { status }, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('EventRegistration PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/school-events/[id]/route.ts b/src/app/api/school-events/[id]/route.ts new file mode 100644 index 0000000..e7a804c --- /dev/null +++ b/src/app/api/school-events/[id]/route.ts @@ -0,0 +1,165 @@ +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 event = await db.schoolEvent.findFirst({ + where: { id, deletedAt: null }, + include: { + organizer: { + select: { id: true, firstName: true, lastName: true }, + }, + classGroup: { + select: { id: true, name: true }, + }, + registrations: { + include: { + user: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }, + }, + }); + + if (!event) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== event.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(event); + } catch (error) { + console.error('SchoolEvent GET [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const existing = await db.schoolEvent.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { + title, + description, + eventType, + startDate, + endDate, + location, + organizerId, + classGroupId, + isAllSchool, + isPublic, + requiresRegistration, + maxParticipants, + notes, + } = body; + + const updated = await db.schoolEvent.update({ + where: { id }, + data: { + ...(title !== undefined && { title }), + ...(description !== undefined && { description }), + ...(eventType !== undefined && { eventType }), + ...(startDate !== undefined && { startDate: new Date(startDate) }), + ...(endDate !== undefined && { endDate: endDate ? new Date(endDate) : null }), + ...(location !== undefined && { location }), + ...(organizerId !== undefined && { organizerId: organizerId || null }), + ...(classGroupId !== undefined && { classGroupId: classGroupId || null }), + ...(isAllSchool !== undefined && { isAllSchool }), + ...(isPublic !== undefined && { isPublic }), + ...(requiresRegistration !== undefined && { requiresRegistration }), + ...(maxParticipants !== undefined && { maxParticipants: maxParticipants || null }), + ...(notes !== undefined && { notes }), + }, + include: { + organizer: { + select: { id: true, firstName: true, lastName: true }, + }, + classGroup: { + select: { id: true, name: true }, + }, + registrations: { + include: { + user: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }, + }, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('SchoolEvent PUT [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const existing = await db.schoolEvent.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Soft delete + await db.schoolEvent.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('SchoolEvent DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/school-events/route.ts b/src/app/api/school-events/route.ts new file mode 100644 index 0000000..8d9f846 --- /dev/null +++ b/src/app/api/school-events/route.ts @@ -0,0 +1,142 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId'); + const eventType = searchParams.get('eventType'); + const classGroupId = searchParams.get('classGroupId'); + const upcoming = searchParams.get('upcoming'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const where: Record = { + schoolId, + deletedAt: null, + }; + + if (eventType) where.eventType = eventType; + if (classGroupId) where.classGroupId = classGroupId; + + if (upcoming === 'true') { + where.startDate = { gte: new Date() }; + } + + const events = await db.schoolEvent.findMany({ + where, + orderBy: { startDate: 'asc' }, + include: { + organizer: { + select: { id: true, firstName: true, lastName: true }, + }, + classGroup: { + select: { id: true, name: true }, + }, + registrations: { + include: { + user: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }, + }, + }); + + return NextResponse.json(events); + } catch (error) { + console.error('SchoolEvent 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 }); + } + + const body = await request.json(); + const { + schoolId, + title, + description, + eventType, + startDate, + endDate, + location, + organizerId, + classGroupId, + isAllSchool, + isPublic, + requiresRegistration, + maxParticipants, + notes, + isDemo, + } = body; + + if (!schoolId || !title || !eventType || !startDate) { + return NextResponse.json( + { error: 'schoolId, title, eventType, and startDate are required' }, + { status: 400 } + ); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const event = await db.schoolEvent.create({ + data: { + schoolId, + title, + description: description || null, + eventType, + startDate: new Date(startDate), + endDate: endDate ? new Date(endDate) : null, + location: location || null, + organizerId: organizerId || null, + classGroupId: classGroupId || null, + isAllSchool: isAllSchool ?? true, + isPublic: isPublic ?? true, + requiresRegistration: requiresRegistration ?? false, + maxParticipants: maxParticipants || null, + notes: notes || null, + isDemo: isDemo ?? false, + }, + include: { + organizer: { + select: { id: true, firstName: true, lastName: true }, + }, + classGroup: { + select: { id: true, name: true }, + }, + registrations: { + include: { + user: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }, + }, + }); + + return NextResponse.json(event, { status: 201 }); + } catch (error) { + console.error('SchoolEvent POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/schools/route.ts b/src/app/api/schools/route.ts index bbcb058..b9dd600 100644 --- a/src/app/api/schools/route.ts +++ b/src/app/api/schools/route.ts @@ -8,6 +8,18 @@ const createSchoolSchema = z.object({ schoolType: z.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER']), country: z.string().default('DE'), timezone: z.string().default('Europe/Berlin'), + // Branding fields + logoUrl: z.string().optional(), + primaryColor: z.string().optional(), + secondaryColor: z.string().optional(), + accentColor: z.string().optional(), + fontFamily: z.string().optional(), + customCss: z.string().optional(), + motto: z.string().optional(), + websiteUrl: z.string().optional(), + emailDomain: z.string().optional(), + address: z.string().optional(), + phone: z.string().optional(), }); export async function GET(request: Request) { @@ -19,6 +31,7 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const schoolType = searchParams.get('schoolType'); + const includeBranding = searchParams.get('includeBranding') === 'true'; const where: Record = {}; if (schoolType) where.schoolType = schoolType; @@ -39,6 +52,7 @@ export async function GET(request: Request) { students: { where: { deletedAt: null } }, }, }, + ...(includeBranding ? {} : {}), }, }); @@ -79,6 +93,47 @@ export async function POST(request: Request) { data: parsed.data, }); + // Seed default email templates for the new school + const defaultTemplates = [ + { + schoolId: school.id, + name: 'weekly_report', + subject: 'Wochenbericht {{studentName}}', + body: '

Wochenbericht für {{studentName}}

Klasse: {{className}}

Lehrkraft: {{teacherName}}

Datum: {{date}}

Kompetenzfortschritt

{{competencyProgress}}

Mit freundlichen Grüßen,
{{schoolName}}

', + isDefault: true, + }, + { + schoolId: school.id, + name: 'assessment_reminder', + subject: 'Bewertung am {{date}}', + body: '

Bewertungserinnerung

Hallo {{teacherName}},

am {{date}} steht eine Bewertung an für {{className}}.

Betreff: {{subjectName}}

Mit freundlichen Grüßen,
{{schoolName}}

', + isDefault: true, + }, + { + schoolId: school.id, + name: 'behavior_alert', + subject: 'Verhaltensnotice für {{studentName}}', + body: '

Verhaltensnotice

Schüler/in: {{studentName}}

Klasse: {{className}}

Datum: {{date}}

Vorfall: {{behaviorDescription}}

Kategorie: {{behaviorCategory}}

Mit freundlichen Grüßen,
{{schoolName}}

', + isDefault: true, + }, + { + schoolId: school.id, + name: 'attendance_notice', + subject: 'Anwesenheits-Notice', + body: '

Anwesenheitsnotice

Schüler/in: {{studentName}}

Klasse: {{className}}

Datum: {{date}}

Status: {{attendanceStatus}}

Mit freundlichen Grüßen,
{{schoolName}}

', + isDefault: true, + }, + { + schoolId: school.id, + name: 'welcome', + subject: 'Willkommen bei CompetenceTrack', + body: '

Willkommen bei CompetenceTrack!

Hallo {{studentName}},

du wurdest für die Schule {{schoolName}} registriert.

Deine Zugangsdaten:

E-Mail: {{email}}

Bitte melde dich an und ändere dein Passwort.

Mit freundlichen Grüßen,
{{schoolName}}

', + isDefault: true, + }, + ]; + + await db.emailTemplate.createMany({ data: defaultTemplates }); + return NextResponse.json(school, { status: 201 }); } catch (error) { console.error('Schools POST error:', error); @@ -95,6 +150,18 @@ const updateSchoolSchema = z.object({ schoolType: z.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER']).optional(), country: z.string().optional(), timezone: z.string().optional(), + // Branding fields + logoUrl: z.string().nullable().optional(), + primaryColor: z.string().nullable().optional(), + secondaryColor: z.string().nullable().optional(), + accentColor: z.string().nullable().optional(), + fontFamily: z.string().nullable().optional(), + customCss: z.string().nullable().optional(), + motto: z.string().nullable().optional(), + websiteUrl: z.string().nullable().optional(), + emailDomain: z.string().nullable().optional(), + address: z.string().nullable().optional(), + phone: z.string().nullable().optional(), }); export async function PUT(request: Request) { diff --git a/src/app/globals.css b/src/app/globals.css index 527524b..2cbd38a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -48,6 +48,12 @@ :root { --radius: 0.75rem; + /* Brand CSS custom properties — applied dynamically via JS */ + --brand-primary: #10b981; + --brand-secondary: #14b8a6; + --brand-accent: #059669; + --brand-font: "Inter", sans-serif; + --background: oklch(0.99 0.002 155); --foreground: oklch(0.17 0.01 260); --card: oklch(1 0 0); @@ -2100,4 +2106,213 @@ font-weight: 600; color: oklch(0.55 0.17 25); } + + /* ─── Performance & Styling Polish Animations ────────── */ + + /* Card hover lift effect */ + .card-hover-lift { + transition: transform 0.2s ease, box-shadow 0.2s ease; + } + .card-hover-lift:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px -5px oklch(0.55 0.17 155 / 0.15), 0 4px 10px -4px oklch(0.55 0.17 155 / 0.1); + } + .dark .card-hover-lift:hover { + box-shadow: 0 8px 25px -5px oklch(0.3 0.05 155 / 0.3), 0 4px 10px -4px oklch(0.3 0.05 155 / 0.2); + } + + /* Gradient line separator */ + .gradient-separator { + height: 1px; + background: linear-gradient(to right, transparent, oklch(0.55 0.17 155 / 0.3), oklch(0.55 0.17 155 / 0.5), oklch(0.55 0.17 155 / 0.3), transparent); + } + .dark .gradient-separator { + background: linear-gradient(to right, transparent, oklch(0.55 0.17 155 / 0.2), oklch(0.55 0.17 155 / 0.4), oklch(0.55 0.17 155 / 0.2), transparent); + } + + /* Badge glow animations */ + @keyframes badge-glow-emerald { + 0%, 100% { box-shadow: 0 0 0 0 oklch(0.55 0.17 155 / 0.3); } + 50% { box-shadow: 0 0 8px 2px oklch(0.55 0.17 155 / 0.2); } + } + @keyframes badge-glow-amber { + 0%, 100% { box-shadow: 0 0 0 0 oklch(0.75 0.15 85 / 0.3); } + 50% { box-shadow: 0 0 8px 2px oklch(0.75 0.15 85 / 0.2); } + } + @keyframes badge-glow-rose { + 0%, 100% { box-shadow: 0 0 0 0 oklch(0.55 0.2 15 / 0.3); } + 50% { box-shadow: 0 0 8px 2px oklch(0.55 0.2 15 / 0.2); } + } + + .badge-glow-emerald { + animation: badge-glow-emerald 2s ease-in-out infinite; + } + .badge-glow-amber { + animation: badge-glow-amber 2s ease-in-out infinite; + } + .badge-glow-rose { + animation: badge-glow-rose 2s ease-in-out infinite; + } + + /* Staggered entrance animation */ + @keyframes stagger-entrance { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + .stagger-entrance { + animation: stagger-entrance 0.4s ease-out forwards; + opacity: 0; + } + .stagger-entrance:nth-child(1) { animation-delay: 0ms; } + .stagger-entrance:nth-child(2) { animation-delay: 60ms; } + .stagger-entrance:nth-child(3) { animation-delay: 120ms; } + .stagger-entrance:nth-child(4) { animation-delay: 180ms; } + .stagger-entrance:nth-child(5) { animation-delay: 240ms; } + .stagger-entrance:nth-child(6) { animation-delay: 300ms; } + .stagger-entrance:nth-child(7) { animation-delay: 360ms; } + .stagger-entrance:nth-child(8) { animation-delay: 420ms; } + + /* Celebration confetti burst */ + @keyframes confetti-burst { + 0% { transform: scale(0); opacity: 1; } + 50% { transform: scale(1.2); opacity: 0.8; } + 100% { transform: scale(0); opacity: 0; } + } + + /* Success animation for save actions */ + @keyframes success-bounce { + 0% { transform: scale(0.8); opacity: 0; } + 50% { transform: scale(1.1); } + 100% { transform: scale(1); opacity: 1; } + } + .success-bounce { + animation: success-bounce 0.4s ease-out; + } + + /* Animated pill filter */ + @keyframes pill-highlight { + from { + background-position: left center; + } + to { + background-position: right center; + } + } + + /* Onboarding tour animated highlight */ + @keyframes tour-highlight-ring { + 0% { transform: scale(0.95); opacity: 0.8; } + 50% { transform: scale(1.05); opacity: 1; } + 100% { transform: scale(0.95); opacity: 0.8; } + } + .tour-highlight-ring { + animation: tour-highlight-ring 1.5s ease-in-out infinite; + } + + /* Smooth theme transition */ + .theme-transition * { + transition: background-color 0.3s ease, color 0.15s ease, border-color 0.3s ease; + } + + /* Custom scrollbar styling for lists */ + .custom-scrollbar::-webkit-scrollbar { + width: 6px; + height: 6px; + } + .custom-scrollbar::-webkit-scrollbar-track { + background: transparent; + } + .custom-scrollbar::-webkit-scrollbar-thumb { + background: oklch(0.55 0.17 155 / 0.2); + border-radius: 3px; + } + .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: oklch(0.55 0.17 155 / 0.4); + } + .dark .custom-scrollbar::-webkit-scrollbar-thumb { + background: oklch(0.55 0.17 155 / 0.15); + } + .dark .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: oklch(0.55 0.17 155 / 0.3); + } + + /* Attendance grid colored cells */ + .attendance-present { + background: linear-gradient(135deg, oklch(0.6 0.17 155 / 0.15), oklch(0.6 0.17 155 / 0.08)); + border-left: 3px solid oklch(0.55 0.17 155); + } + .dark .attendance-present { + background: linear-gradient(135deg, oklch(0.55 0.17 155 / 0.2), oklch(0.55 0.17 155 / 0.08)); + } + .attendance-absent { + background: linear-gradient(135deg, oklch(0.55 0.2 15 / 0.15), oklch(0.55 0.2 15 / 0.08)); + border-left: 3px solid oklch(0.55 0.2 15); + } + .dark .attendance-absent { + background: linear-gradient(135deg, oklch(0.55 0.2 15 / 0.2), oklch(0.55 0.2 15 / 0.08)); + } + .attendance-excused { + background: linear-gradient(135deg, oklch(0.75 0.15 85 / 0.15), oklch(0.75 0.15 85 / 0.08)); + border-left: 3px solid oklch(0.75 0.15 85); + } + .dark .attendance-excused { + background: linear-gradient(135deg, oklch(0.75 0.15 85 / 0.2), oklch(0.75 0.15 85 / 0.08)); + } + .attendance-late { + background: linear-gradient(135deg, oklch(0.55 0.1 185 / 0.15), oklch(0.55 0.1 185 / 0.08)); + border-left: 3px solid oklch(0.55 0.1 185); + } + .dark .attendance-late { + background: linear-gradient(135deg, oklch(0.55 0.1 185 / 0.2), oklch(0.55 0.1 185 / 0.08)); + } + + /* Grade color coding */ + .grade-excellent { + background: linear-gradient(135deg, oklch(0.6 0.17 155 / 0.9), oklch(0.5 0.15 165 / 0.9)); + color: white; + } + .grade-good { + background: linear-gradient(135deg, oklch(0.65 0.13 155 / 0.85), oklch(0.55 0.12 165 / 0.85)); + color: white; + } + .grade-satisfactory { + background: linear-gradient(135deg, oklch(0.75 0.15 85 / 0.8), oklch(0.65 0.13 75 / 0.8)); + color: white; + } + .grade-sufficient { + background: linear-gradient(135deg, oklch(0.75 0.12 55 / 0.8), oklch(0.65 0.1 45 / 0.8)); + color: white; + } + .grade-poor { + background: linear-gradient(135deg, oklch(0.6 0.18 25 / 0.85), oklch(0.55 0.16 15 / 0.85)); + color: white; + } + .grade-failing { + background: linear-gradient(135deg, oklch(0.55 0.2 15 / 0.9), oklch(0.5 0.18 25 / 0.9)); + color: white; + } + + /* Glass effect for cards */ + .glass-card { + background: rgba(255, 255, 255, 0.6); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.2); + } + .dark .glass-card { + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.05); + } + + /* Min touch target size (44px) */ + .min-touch { + min-height: 44px; + min-width: 44px; + } } diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index 476f272..3acbe3f 100644 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -107,6 +107,7 @@ import { useAppStore, type ViewName } from '@/lib/store'; import { t } from '@/lib/i18n'; import { fetchSchoolYears, fetchStudents, fetchClasses, fetchDBNotifications, markAllNotificationsRead, markSingleNotificationRead, type SchoolYear, type Student, type ClassGroup, type DBNotification, type DBNotificationData } from '@/lib/api'; import { apiGet } from '@/lib/api'; +import type { School as SchoolType } from '@/lib/api'; import OnboardingTour, { isOnboardingCompleted } from '@/components/onboarding-tour'; import KeyboardShortcutsDialog from '@/components/keyboard-shortcuts-dialog'; import { @@ -123,6 +124,10 @@ import { toast } from 'sonner'; import { useWebSocket, usePushNotifications, playNotificationSound, getNotificationSoundPref } from '@/lib/websocket'; import { OfflineIndicator, PWAInstallPrompt, useServiceWorker, OfflineSyncManager } from '@/components/offline-indicator'; +import dynamic from 'next/dynamic'; +import { Skeleton } from '@/components/ui/skeleton'; + +// Static imports for lightweight views import DashboardView from './dashboard-view'; import ClassesView from './classes-view'; import CompetencyGridView from './competency-grid-view'; @@ -133,23 +138,37 @@ import GradingView from './grading-view'; import ReportsView from './reports-view'; import SettingsView from './settings-view'; import StudentDetailView from './student-detail-view'; -import AnalyticsView from './analytics-view'; import MasteryMatrixView from './mastery-matrix-view'; import AttendanceView from './attendance-view'; import LessonPlansView from './lesson-plans-view'; -import CalendarView from './calendar-view'; import ParentCommunicationView from './parent-communication-view'; import BehaviorTrackingView from './behavior-tracking-view'; import CurriculumCoverageView from './curriculum-coverage-view'; import RubricLibraryView from './rubric-library-view'; import CommentBankView from './comment-bank-view'; -import NotebooksView from './notebooks-view'; -import DrawingView from './drawing-view'; import HomeworkView from './homework-view'; import PortfolioView from './portfolio-view'; import TimetableView from './timetable-view'; import ResourceLibraryView from './resource-library-view'; +// Dynamic imports for heavy components with loading skeletons +const AnalyticsView = dynamic(() => import('./analytics-view'), { + loading: () =>
{[1,2,3].map(i => )}
, +}); + +const CalendarView = dynamic(() => import('./calendar-view'), { + loading: () =>
, +}); + +const NotebooksView = dynamic(() => import('./notebooks-view'), { + loading: () =>
{[1,2,3].map(i => )}
, +}); + +const DrawingView = dynamic(() => import('./drawing-view'), { + ssr: false, + loading: () =>
Loading canvas...
, +}); + type NavItem = { key: ViewName; icon: React.ElementType; labelKey: string }; type NavSection = { id: string; labelKey: string; items: NavItem[] }; @@ -375,6 +394,9 @@ export default function AppLayout() { const [schoolYears, setSchoolYears] = useState([]); const [selectedYearId, setSelectedYearId] = useState(storeSchoolYearId ?? ''); + // School branding state + const [schoolBranding, setSchoolBranding] = useState(null); + // Service Worker registration for PWA useServiceWorker(); @@ -755,6 +777,33 @@ export default function AppLayout() { loadYears(); }, [currentUser?.schoolId]); + // Load school branding and apply CSS custom properties + useEffect(() => { + async function loadBranding() { + if (!currentUser?.schoolId) return; + try { + const schools = await apiGet('/api/schools'); + const mySchool = schools.find((s) => s.id === currentUser.schoolId); + if (mySchool) { + setSchoolBranding(mySchool); + // Apply branding CSS variables + const root = document.documentElement; + if (mySchool.primaryColor) root.style.setProperty('--brand-primary', mySchool.primaryColor); + if (mySchool.secondaryColor) root.style.setProperty('--brand-secondary', mySchool.secondaryColor); + if (mySchool.accentColor) root.style.setProperty('--brand-accent', mySchool.accentColor); + if (mySchool.fontFamily) root.style.setProperty('--brand-font', `"${mySchool.fontFamily}", sans-serif`); + // Apply font family globally + if (mySchool.fontFamily) { + root.style.fontFamily = `"${mySchool.fontFamily}", sans-serif`; + } + } + } catch { + // ignore branding errors + } + } + loadBranding(); + }, [currentUser?.schoolId]); + return ( {/* Offline indicator bar */} @@ -774,11 +823,19 @@ export default function AppLayout() { animate={{ opacity: 1 }} className="flex items-center gap-3 group-data-[collapsible=icon]:justify-center" > -
- -
+ {schoolBranding?.logoUrl ? ( + {schoolBranding.name} + ) : ( +
+ +
+ )}
-

CompetenceTrack

+

{schoolBranding?.name || 'CompetenceTrack'}

{t('app.subtitle')}

@@ -827,7 +884,7 @@ export default function AppLayout() { - {/* Environmental message */} + {/* School motto or Environmental message */}

- {t('sidebar.eco_message')} + {schoolBranding?.motto || t('sidebar.eco_message')}

@@ -866,7 +923,14 @@ export default function AppLayout() { onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} title={t('theme.toggle')} > - {theme === 'dark' ? : } + + {theme === 'dark' ? : } + +
+ + + {loading ? ( +
+ {[1, 2, 3].map((i) => )} +
+ ) : events.length === 0 ? ( +
+
+ +
+

{t('events.no_events')}

+
+ ) : ( +
+ {events.map((event) => { + const Icon = eventTypeIcon[event.eventType] ?? CalendarDays; + const iconColor = eventTypeColor[event.eventType] ?? 'bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400'; + const isRegistered = event.registrations.some((r) => r.userId === currentUser?.id && r.status === 'registered'); + return ( + +
+
+ +
+
+

{event.title}

+
+ {formatDate(event.startDate)} + {event.location && ( + {event.location} + )} + {event.isAllSchool && ( + {t('events.all_school')} + )} +
+
+
+
+ {event.requiresRegistration && ( + isRegistered ? ( + + {t('events.registered')} + + ) : ( + + ) + )} +
+
+ ); + })} +
+ )} +
+ + + ); +} diff --git a/src/components/grading-view.tsx b/src/components/grading-view.tsx index 16db01b..1346a43 100644 --- a/src/components/grading-view.tsx +++ b/src/components/grading-view.tsx @@ -368,7 +368,7 @@ export default function GradingView() { animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }} > - +
@@ -467,7 +467,7 @@ export default function GradingView() { ) : ( <> {/* Schemes */} - +
@@ -548,7 +548,7 @@ export default function GradingView() { {/* Computed grades */} - +
@@ -769,7 +769,7 @@ export default function GradingView() { animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4, delay: 0.1 }} > - +
diff --git a/src/components/lesson-plans-view.tsx b/src/components/lesson-plans-view.tsx index 98dddeb..de20f53 100644 --- a/src/components/lesson-plans-view.tsx +++ b/src/components/lesson-plans-view.tsx @@ -494,7 +494,7 @@ export default function LessonPlansView() {
{/* ── Filter bar ──────────────────────────────────────────────── */} - +
diff --git a/src/components/onboarding-tour.tsx b/src/components/onboarding-tour.tsx index a902edb..5d1d8eb 100644 --- a/src/components/onboarding-tour.tsx +++ b/src/components/onboarding-tour.tsx @@ -1,14 +1,17 @@ 'use client'; -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { X, BookOpen, BarChart3, Pencil, Flower2, FileText, PartyPopper, Users, ClipboardCheck, GraduationCap, Palette, Leaf, - TreePine, CalendarDays, MessageSquare, BarChart, Target, - Zap, Award, Shield, Notebook, + TreePine, CalendarDays, MessageSquare, Target, + Zap, Award, Shield, Notebook, Grid3X3, Calculator, + CalendarCheck, BookCheck, Briefcase, Clock, Library, + Settings as SettingsIcon, Ruler, MessageSquareText, Heart, } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, @@ -19,6 +22,7 @@ import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { t } from '@/lib/i18n'; const ONBOARDING_KEY = 'ct_onboarding_completed'; +const DONT_SHOW_KEY = 'ct_onboarding_dont_show'; interface OnboardingStep { iconComponent: React.ElementType; @@ -30,86 +34,146 @@ interface OnboardingStep { const steps: OnboardingStep[] = [ { iconComponent: BookOpen, - titleKey: 'onboarding.welcome_title', - descKey: 'onboarding.welcome_desc', + titleKey: 'onboarding.step_1_title', + descKey: 'onboarding.step_1_desc', accent: 'from-emerald-400 to-teal-500', }, { iconComponent: BarChart3, - titleKey: 'onboarding.dashboard_title', - descKey: 'onboarding.dashboard_desc', + titleKey: 'onboarding.step_2_title', + descKey: 'onboarding.step_2_desc', accent: 'from-teal-400 to-emerald-500', }, { iconComponent: Users, - titleKey: 'onboarding.classes_title', - descKey: 'onboarding.classes_desc', + titleKey: 'onboarding.step_3_title', + descKey: 'onboarding.step_3_desc', accent: 'from-emerald-400 to-cyan-500', }, + { + iconComponent: Grid3X3, + titleKey: 'onboarding.step_4_title', + descKey: 'onboarding.step_4_desc', + accent: 'from-cyan-400 to-emerald-500', + }, { iconComponent: Pencil, - titleKey: 'onboarding.progress_title', - descKey: 'onboarding.progress_desc', + titleKey: 'onboarding.step_5_title', + descKey: 'onboarding.step_5_desc', accent: 'from-amber-400 to-emerald-500', }, { iconComponent: Flower2, - titleKey: 'onboarding.flower_title', - descKey: 'onboarding.flower_desc', + titleKey: 'onboarding.step_6_title', + descKey: 'onboarding.step_6_desc', accent: 'from-rose-400 to-emerald-500', }, + { + iconComponent: Grid3X3, + titleKey: 'onboarding.step_7_title', + descKey: 'onboarding.step_7_desc', + accent: 'from-teal-400 to-cyan-500', + }, { iconComponent: ClipboardCheck, - titleKey: 'onboarding.assessments_title', - descKey: 'onboarding.assessments_desc', + titleKey: 'onboarding.step_8_title', + descKey: 'onboarding.step_8_desc', accent: 'from-amber-400 to-rose-500', }, { - iconComponent: GraduationCap, - titleKey: 'onboarding.grading_title', - descKey: 'onboarding.grading_desc', + iconComponent: Calculator, + titleKey: 'onboarding.step_9_title', + descKey: 'onboarding.step_9_desc', accent: 'from-violet-400 to-emerald-500', }, { - iconComponent: FileText, - titleKey: 'onboarding.reports_title', - descKey: 'onboarding.reports_desc', - accent: 'from-violet-400 to-emerald-500', - }, - { - iconComponent: Notebook, - titleKey: 'onboarding.notebooks_title', - descKey: 'onboarding.notebooks_desc', - accent: 'from-teal-400 to-emerald-500', - }, - { - iconComponent: Palette, - titleKey: 'onboarding.drawing_title', - descKey: 'onboarding.drawing_desc', - accent: 'from-rose-400 to-violet-500', + iconComponent: CalendarCheck, + titleKey: 'onboarding.step_10_title', + descKey: 'onboarding.step_10_desc', + accent: 'from-emerald-400 to-teal-500', }, { iconComponent: CalendarDays, - titleKey: 'onboarding.calendar_title', - descKey: 'onboarding.calendar_desc', + titleKey: 'onboarding.step_11_title', + descKey: 'onboarding.step_11_desc', accent: 'from-amber-400 to-teal-500', }, + { + iconComponent: CalendarDays, + titleKey: 'onboarding.step_12_title', + descKey: 'onboarding.step_12_desc', + accent: 'from-teal-400 to-amber-500', + }, + { + iconComponent: Shield, + titleKey: 'onboarding.step_13_title', + descKey: 'onboarding.step_13_desc', + accent: 'from-rose-400 to-amber-500', + }, { iconComponent: MessageSquare, - titleKey: 'onboarding.parents_title', - descKey: 'onboarding.parents_desc', + titleKey: 'onboarding.step_14_title', + descKey: 'onboarding.step_14_desc', accent: 'from-cyan-400 to-emerald-500', }, { - iconComponent: Leaf, - titleKey: 'onboarding.environmental_title', - descKey: 'onboarding.environmental_desc', - accent: 'from-emerald-400 to-green-500', + iconComponent: Ruler, + titleKey: 'onboarding.step_15_title', + descKey: 'onboarding.step_15_desc', + accent: 'from-amber-400 to-teal-500', }, { - iconComponent: PartyPopper, - titleKey: 'onboarding.done_title', - descKey: 'onboarding.done_desc', + iconComponent: MessageSquareText, + titleKey: 'onboarding.step_16_title', + descKey: 'onboarding.step_16_desc', + accent: 'from-teal-400 to-emerald-500', + }, + { + iconComponent: Notebook, + titleKey: 'onboarding.step_17_title', + descKey: 'onboarding.step_17_desc', + accent: 'from-emerald-400 to-teal-500', + }, + { + iconComponent: Palette, + titleKey: 'onboarding.step_18_title', + descKey: 'onboarding.step_18_desc', + accent: 'from-rose-400 to-violet-500', + }, + { + iconComponent: Briefcase, + titleKey: 'onboarding.step_19_title', + descKey: 'onboarding.step_19_desc', + accent: 'from-violet-400 to-emerald-500', + }, + { + iconComponent: BookCheck, + titleKey: 'onboarding.step_20_title', + descKey: 'onboarding.step_20_desc', + accent: 'from-amber-400 to-emerald-500', + }, + { + iconComponent: Clock, + titleKey: 'onboarding.step_21_title', + descKey: 'onboarding.step_21_desc', + accent: 'from-teal-400 to-cyan-500', + }, + { + iconComponent: Library, + titleKey: 'onboarding.step_22_title', + descKey: 'onboarding.step_22_desc', + accent: 'from-emerald-400 to-amber-500', + }, + { + iconComponent: BarChart3, + titleKey: 'onboarding.step_23_title', + descKey: 'onboarding.step_23_desc', + accent: 'from-teal-400 to-violet-500', + }, + { + iconComponent: SettingsIcon, + titleKey: 'onboarding.step_24_title', + descKey: 'onboarding.step_24_desc', accent: 'from-emerald-400 to-teal-500', }, ]; @@ -121,6 +185,15 @@ interface OnboardingTourProps { export default function OnboardingTour({ open, onClose }: OnboardingTourProps) { const [currentStep, setCurrentStep] = useState(0); + const [dontShowAgain, setDontShowAgain] = useState(false); + const [showCelebration, setShowCelebration] = useState(false); + + useEffect(() => { + if (open) { + setCurrentStep(0); + setShowCelebration(false); + } + }, [open]); const handleNext = useCallback(() => { if (currentStep < steps.length - 1) { @@ -129,12 +202,18 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) { // Complete onboarding try { localStorage.setItem(ONBOARDING_KEY, 'true'); + if (dontShowAgain) { + localStorage.setItem(DONT_SHOW_KEY, 'true'); + } } catch { // ignore } - onClose(); + setShowCelebration(true); + setTimeout(() => { + onClose(); + }, 2000); } - }, [currentStep, onClose]); + }, [currentStep, onClose, dontShowAgain]); const handleBack = useCallback(() => { if (currentStep > 0) { @@ -145,15 +224,19 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) { const handleSkip = useCallback(() => { try { localStorage.setItem(ONBOARDING_KEY, 'true'); + if (dontShowAgain) { + localStorage.setItem(DONT_SHOW_KEY, 'true'); + } } catch { // ignore } onClose(); - }, [onClose]); + }, [onClose, dontShowAgain]); const step = steps[currentStep]; const isLastStep = currentStep === steps.length - 1; const isFirstStep = currentStep === 0; + const progress = ((currentStep + 1) / steps.length) * 100; return ( { if (!isOpen) handleSkip(); }}> @@ -167,13 +250,23 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) { {/* Close button */} + {/* Progress bar */} +
+ +
+ {/* Step content with animation */} -
+
- {/* Icon illustration */} + {/* Icon illustration with animated highlight ring */} +
+ + + + +
+ + {/* Step number badge */} - + + {t('onboarding.step', { current: String(currentStep + 1), total: String(steps.length) })} + {/* Title */} @@ -198,7 +309,7 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) { initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2, duration: 0.3 }} - className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3" + className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-2" > {t(step.titleKey)} @@ -214,45 +325,104 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
+ + {/* Celebration overlay */} + + {showCelebration && ( + + + + + + {t('onboarding.celebration')} + + + {t('onboarding.complete')} + + {/* Confetti-like particles */} + {[...Array(8)].map((_, i) => ( + + ))} + + )} +
- {/* Step indicator dots */} -
+ {/* Step indicator dots (scrollable for 24 steps) */} +
{steps.map((_, i) => (
- {/* Step counter */} -
- - {t('onboarding.step', { current: String(currentStep + 1), total: String(steps.length) })} - -
- {/* Action buttons */}
-
+
{!isFirstStep && ( )} + {/* Don't show again checkbox */} +
{!isLastStep && ( @@ -260,7 +430,7 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) { variant="ghost" size="sm" onClick={handleSkip} - className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300" + className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 min-h-[44px]" > {t('onboarding.skip')} @@ -268,7 +438,7 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) { @@ -286,3 +456,11 @@ export function isOnboardingCompleted(): boolean { return false; } } + +export function isDontShowAgain(): boolean { + try { + return localStorage.getItem(DONT_SHOW_KEY) === 'true'; + } catch { + return false; + } +} diff --git a/src/components/progress-entries-view.tsx b/src/components/progress-entries-view.tsx index ef76e57..4211f26 100644 --- a/src/components/progress-entries-view.tsx +++ b/src/components/progress-entries-view.tsx @@ -903,7 +903,7 @@ export default function ProgressEntriesView() { - +
- +
@@ -717,7 +717,7 @@ export default function ReportsView() { animate={{ opacity: 1, x: 0 }} transition={{ duration: 0.3 }} > - +
@@ -837,7 +837,7 @@ export default function ReportsView() { animate={{ opacity: 1, x: 0 }} transition={{ duration: 0.3, delay: 0.1 }} > - +
@@ -897,7 +897,7 @@ export default function ReportsView() { {/* PDF Template Selection + Batch Generate */} {selectedClass && students.length > 0 && ( - +
@@ -1106,7 +1106,7 @@ export default function ReportsView() { {/* Schedule Calendar Preview */} {schedules.length > 0 && ( - +
diff --git a/src/components/settings-view.tsx b/src/components/settings-view.tsx index 4981fd9..ac7782a 100644 --- a/src/components/settings-view.tsx +++ b/src/components/settings-view.tsx @@ -51,6 +51,17 @@ import { ArrowRightLeft, TrendingDown, ArrowUpRight, + Palette, + Eye, + Send, + Globe, + FileJson, + FileType, + Phone, + PhoneCall, + AlertCircle, + Star, + Printer, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -126,9 +137,218 @@ import { deleteDistrict, fetchDistrictSchools, assignSchoolToDistrict, + fetchEmailTemplates, + createEmailTemplate, + updateEmailTemplate, + deleteEmailTemplate, + sendTestEmail, + fetchEmailLogs, + type EmailTemplate, + type EmailLog, } from '@/lib/api'; import { RateLimitStatus } from '@/components/offline-indicator'; +// ─── Emergency Contact Data Type ───────────────────────────────────── +interface EmergencyContactData { + id: string; + schoolId: string; + studentId: string; + name: string; + relationship: string; + phone: string; + phoneAlt: string | null; + email: string | null; + address: string | null; + isPrimary: boolean; + priority: number; + notes: string | null; + isDemo: boolean; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + student: { id: string; firstName: string; lastName: string }; +} + +// ─── Emergency Contacts Manager Component ──────────────────────────── +function EmergencyContactsManager({ schoolId }: { schoolId: string }) { + const [contacts, setContacts] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [ecDialogOpen, setEcDialogOpen] = useState(false); + const [ecEditId, setEcEditId] = useState(null); + const [ecForm, setEcForm] = useState({ studentId: '', name: '', relationship: 'mother', phone: '', phoneAlt: '', email: '', address: '', isPrimary: false, priority: 1, notes: '' }); + + const loadContacts = useCallback(async () => { + if (!schoolId) return; + setLoading(true); + try { + const data = await apiGet(`/api/emergency-contacts?schoolId=${schoolId}`); + setContacts(data); + } catch { /* ignore */ } + setLoading(false); + }, [schoolId]); + + useEffect(() => { loadContacts(); }, [loadContacts]); + + const filtered = contacts.filter((c) => + c.name.toLowerCase().includes(searchTerm.toLowerCase()) || + c.student.firstName.toLowerCase().includes(searchTerm.toLowerCase()) || + c.student.lastName.toLowerCase().includes(searchTerm.toLowerCase()) || + c.phone.includes(searchTerm) + ); + + return ( +
+
+
+ + setSearchTerm(e.target.value)} + placeholder={t('emergency.search')} + className="pl-10 rounded-xl border-rose-200/50 dark:border-rose-900/30" + /> +
+ +
+ + {loading ? ( +
+ {[1, 2, 3].map((i) => )} +
+ ) : filtered.length === 0 ? ( +
+ +

{t('emergency.no_contacts')}

+
+ ) : ( +
+ {filtered.map((ec) => { + const relLabel = t(`emergency.${ec.relationship}`) || ec.relationship; + return ( +
+
+
+ {ec.isPrimary ? : } +
+
+
+ {ec.name} + {relLabel} + {ec.isPrimary && {t('emergency.primary')}} +
+
+ {t('emergency.student')}: {ec.student.firstName} {ec.student.lastName} + {ec.phone} +
+
+
+
+ + +
+
+ ); + })} +
+ )} + + + + + {ecEditId ? t('emergency.edit') : t('emergency.add')} + {t('emergency.title')} + +
+
+ + setEcForm((f) => ({ ...f, name: e.target.value }))} className="mt-1 rounded-lg" placeholder={t('emergency.name')} /> +
+
+
+ + +
+
+ + setEcForm((f) => ({ ...f, priority: parseInt(e.target.value) || 1 }))} className="mt-1 rounded-lg" /> +
+
+
+
+ + setEcForm((f) => ({ ...f, phone: e.target.value }))} className="mt-1 rounded-lg" placeholder="+49 123 456789" /> +
+
+ + setEcForm((f) => ({ ...f, phoneAlt: e.target.value }))} className="mt-1 rounded-lg" placeholder="+49 987 654321" /> +
+
+
+ + setEcForm((f) => ({ ...f, email: e.target.value }))} className="mt-1 rounded-lg" placeholder="name@example.com" /> +
+
+ + setEcForm((f) => ({ ...f, address: e.target.value }))} className="mt-1 rounded-lg" /> +
+
+ + setEcForm((f) => ({ ...f, notes: e.target.value }))} className="mt-1 rounded-lg" /> +
+
+ setEcForm((f) => ({ ...f, isPrimary: e.target.checked }))} className="rounded border-gray-300" /> + +
+
+ + + + +
+
+
+ ); +} + const containerVariants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.06 } }, @@ -292,7 +512,62 @@ export default function SettingsView() { const [dragOver, setDragOver] = useState(false); // Export history - const [exportHistory, setExportHistory] = useState>([]); + const [exportHistory, setExportHistory] = useState>([]); + + // Branding state + const [brandingForm, setBrandingForm] = useState({ + logoUrl: '', + primaryColor: '#10b981', + secondaryColor: '#14b8a6', + accentColor: '#059669', + fontFamily: 'Inter', + customCss: '', + motto: '', + websiteUrl: '', + emailDomain: '', + address: '', + phone: '', + }); + const [brandingSaving, setBrandingSaving] = useState(false); + + // Email templates state + const [emailTemplates, setEmailTemplates] = useState([]); + const [emailTemplatesLoading, setEmailTemplatesLoading] = useState(true); + const [showEmailTemplateDialog, setShowEmailTemplateDialog] = useState(false); + const [editingEmailTemplate, setEditingEmailTemplate] = useState(null); + const [emailTemplateForm, setEmailTemplateForm] = useState({ name: '', subject: '', body: '' }); + const [emailTemplateSaving, setEmailTemplateSaving] = useState(false); + const [deleteEmailTemplateId, setDeleteEmailTemplateId] = useState(null); + const [showEmailPreview, setShowEmailPreview] = useState(false); + const [emailPreviewTemplate, setEmailPreviewTemplate] = useState(null); + const [testEmailSending, setTestEmailSending] = useState(false); + const [testEmailRecipient, setTestEmailRecipient] = useState(''); + + // Email log state + const [emailLogs, setEmailLogs] = useState([]); + const [emailLogCounts, setEmailLogCounts] = useState({ total: 0, sent: 0, failed: 0, pending: 0, bounced: 0 }); + const [emailLogsLoading, setEmailLogsLoading] = useState(true); + const [emailLogFilter, setEmailLogFilter] = useState('all'); + + // Email settings state + const [emailSettings, setEmailSettings] = useState({ + smtpHost: '', + smtpPort: '587', + smtpUser: '', + smtpPassword: '', + smtpFrom: '', + frequency: 'weekly', + autoReports: true, + autoBehavior: true, + autoAttendance: false, + }); + + // Enhanced export state + const [exportFormat, setExportFormat] = useState<'csv' | 'json' | 'pdf'>('csv'); + const [exportClassFilter, setExportClassFilter] = useState(''); + const [exportDateFrom, setExportDateFrom] = useState(''); + const [exportDateTo, setExportDateTo] = useState(''); + const [exporting, setExporting] = useState(false); // Backup state const [backups, setBackups] = useState { + if (!currentUser?.schoolId) return; + setEmailTemplatesLoading(true); + try { + const data = await fetchEmailTemplates(currentUser.schoolId); + setEmailTemplates(data); + } catch { + // ignore + } finally { + setEmailTemplatesLoading(false); + } + }, [currentUser?.schoolId]); + + // Load email logs + const loadEmailLogs = useCallback(async () => { + if (!currentUser?.schoolId) return; + setEmailLogsLoading(true); + try { + const data = await fetchEmailLogs(currentUser.schoolId, emailLogFilter !== 'all' ? emailLogFilter : undefined); + setEmailLogs(data.logs); + setEmailLogCounts(data.counts); + } catch { + // ignore + } finally { + setEmailLogsLoading(false); + } + }, [currentUser?.schoolId, emailLogFilter]); + const loadDemoAccounts = useCallback(async () => { setDemoLoading(true); try { @@ -467,7 +771,26 @@ export default function SettingsView() { if (tab === 'district' && isSuperAdmin) { loadDistricts(); } - }, [isAdmin, loadDemoAccounts, loadBackups, isSuperAdmin]); + if (tab === 'email') { + loadEmailTemplates(); + loadEmailLogs(); + } + if (tab === 'branding' && selectedSchool) { + setBrandingForm({ + logoUrl: selectedSchool.logoUrl || '', + primaryColor: selectedSchool.primaryColor || '#10b981', + secondaryColor: selectedSchool.secondaryColor || '#14b8a6', + accentColor: selectedSchool.accentColor || '#059669', + fontFamily: selectedSchool.fontFamily || 'Inter', + customCss: selectedSchool.customCss || '', + motto: selectedSchool.motto || '', + websiteUrl: selectedSchool.websiteUrl || '', + emailDomain: selectedSchool.emailDomain || '', + address: selectedSchool.address || '', + phone: selectedSchool.phone || '', + }); + } + }, [isAdmin, loadDemoAccounts, loadBackups, isSuperAdmin, loadEmailTemplates, loadEmailLogs, selectedSchool]); // District handlers const loadDistricts = useCallback(async () => { @@ -753,13 +1076,114 @@ export default function SettingsView() { }; const handleCsvExport = (type: 'students' | 'progress' | 'assessments' | 'grades' | 'attendance') => { + setExporting(true); downloadCsvExport({ type, schoolId: currentUser?.schoolId ?? undefined, schoolYearId: useAppStore.getState().schoolYearId ?? undefined, + format: exportFormat, + classGroupId: exportClassFilter || undefined, + dateFrom: exportDateFrom || undefined, + dateTo: exportDateTo || undefined, }); - setExportHistory((prev) => [{ type, date: new Date().toLocaleDateString() }, ...prev].slice(0, 20)); + setExportHistory((prev) => [{ type, date: new Date().toLocaleDateString(), format: exportFormat }, ...prev].slice(0, 20)); toast.success(t('settings.data_export_started')); + setTimeout(() => setExporting(false), 1500); + }; + + // Branding save handler + const handleSaveBranding = async () => { + if (!selectedSchool) return; + setBrandingSaving(true); + try { + await updateSchool({ + id: selectedSchool.id, + logoUrl: brandingForm.logoUrl || null, + primaryColor: brandingForm.primaryColor || null, + secondaryColor: brandingForm.secondaryColor || null, + accentColor: brandingForm.accentColor || null, + fontFamily: brandingForm.fontFamily || null, + customCss: brandingForm.customCss || null, + motto: brandingForm.motto || null, + websiteUrl: brandingForm.websiteUrl || null, + emailDomain: brandingForm.emailDomain || null, + address: brandingForm.address || null, + phone: brandingForm.phone || null, + }); + toast.success(t('branding.saved')); + loadSchools(); + } catch { + toast.error(t('error.generic')); + } finally { + setBrandingSaving(false); + } + }; + + // Email template handlers + const handleSaveEmailTemplate = async () => { + if (!currentUser?.schoolId) return; + setEmailTemplateSaving(true); + try { + if (editingEmailTemplate) { + await updateEmailTemplate(editingEmailTemplate.id, { + name: emailTemplateForm.name, + subject: emailTemplateForm.subject, + body: emailTemplateForm.body, + }); + toast.success(t('email.template_edit')); + } else { + await createEmailTemplate({ + schoolId: currentUser.schoolId, + name: emailTemplateForm.name, + subject: emailTemplateForm.subject, + body: emailTemplateForm.body, + }); + toast.success(t('email.template_create')); + } + setShowEmailTemplateDialog(false); + setEditingEmailTemplate(null); + setEmailTemplateForm({ name: '', subject: '', body: '' }); + loadEmailTemplates(); + } catch { + toast.error(t('error.generic')); + } finally { + setEmailTemplateSaving(false); + } + }; + + const handleDeleteEmailTemplate = async () => { + if (!deleteEmailTemplateId) return; + try { + await deleteEmailTemplate(deleteEmailTemplateId); + toast.success(t('email.template_delete')); + setDeleteEmailTemplateId(null); + loadEmailTemplates(); + } catch { + toast.error(t('error.generic')); + } + }; + + const handleSendTestEmail = async () => { + if (!currentUser?.schoolId || !testEmailRecipient || !emailPreviewTemplate) return; + setTestEmailSending(true); + try { + await sendTestEmail({ + schoolId: currentUser.schoolId, + templateId: emailPreviewTemplate.id, + recipientEmail: testEmailRecipient, + recipientName: currentUser.firstName + ' ' + currentUser.lastName, + subject: emailPreviewTemplate.subject, + body: emailPreviewTemplate.body, + }); + toast.success(t('email.test_sent')); + setShowEmailPreview(false); + setTestEmailRecipient(''); + loadEmailLogs(); + } catch { + toast.error(t('error.generic')); + } finally { + setTestEmailSending(false); + } }; const handleImport = async () => { @@ -1117,6 +1541,14 @@ export default function SettingsView() { {t('settings.tab_data')} + + + {t('branding.tab')} + + + + {t('email.tab')} + {isAdmin && ( @@ -1141,7 +1573,7 @@ export default function SettingsView() { {/* ── School Info Tab ─────────────────────────────────── */} - +
@@ -1235,7 +1667,7 @@ export default function SettingsView() { {/* ── School Years Tab ────────────────────────────────── */} - +
@@ -1347,7 +1779,7 @@ export default function SettingsView() { {/* ── Subjects Tab ────────────────────────────────────── */} - +
@@ -1506,7 +1938,7 @@ export default function SettingsView() { {/* ── Users Tab ──────────────────────────────────────── */} - +
@@ -1865,7 +2297,7 @@ export default function SettingsView() { {/* ── Audit Log Tab ───────────────────────────────────── */} - +
@@ -2207,7 +2639,7 @@ export default function SettingsView() {
{/* Data Import */} - +
@@ -2337,25 +2769,61 @@ export default function SettingsView() { - {/* CSV Export */} - + {/* Enhanced Data Export */} +
- {t('settings.data_export_csv')} + {t('export.enhanced_title')}
- {t('csv.export_title')} + {t('export.enhanced_desc')}
- + + {/* Export format & filters */} +
+
+ + +
+
+ + setExportDateFrom(e.target.value)} + className="border-emerald-200 dark:border-emerald-900/30 rounded-xl" + /> +
+
+ + setExportDateTo(e.target.value)} + className="border-emerald-200 dark:border-emerald-900/30 rounded-xl" + /> +
+
+ + {/* Export cards */}
{[ - { type: 'students' as const, iconComponent: GraduationCap, label: t('export.students'), color: 'from-emerald-50/60 to-emerald-100/0 dark:from-emerald-900/15 dark:to-emerald-900/0', border: 'border-emerald-200/40 dark:border-emerald-900/30', iconBg: 'bg-gradient-to-br from-emerald-400 to-emerald-500' }, - { type: 'progress' as const, iconComponent: TrendingUp, label: t('export.progress'), color: 'from-amber-50/60 to-amber-100/0 dark:from-amber-900/15 dark:to-amber-900/0', border: 'border-amber-200/40 dark:border-amber-900/30', iconBg: 'bg-gradient-to-br from-amber-400 to-amber-500' }, - { type: 'assessments' as const, iconComponent: ClipboardCheck, label: t('export.assessments'), color: 'from-teal-50/60 to-teal-100/0 dark:from-teal-900/15 dark:to-teal-900/0', border: 'border-teal-200/40 dark:border-teal-900/30', iconBg: 'bg-gradient-to-br from-teal-400 to-teal-500' }, - { type: 'grades' as const, iconComponent: BarChart3, label: t('export.grades'), color: 'from-violet-50/60 to-violet-100/0 dark:from-violet-900/15 dark:to-violet-900/0', border: 'border-violet-200/40 dark:border-violet-900/30', iconBg: 'bg-gradient-to-br from-violet-400 to-violet-500' }, - { type: 'attendance' as const, iconComponent: CalendarDays, label: t('export.attendance'), color: 'from-rose-50/60 to-rose-100/0 dark:from-rose-900/15 dark:to-rose-900/0', border: 'border-rose-200/40 dark:border-rose-900/30', iconBg: 'bg-gradient-to-br from-rose-400 to-rose-500' }, + { type: 'students' as const, iconComponent: GraduationCap, label: t('export.students'), desc: t('export.grade_report'), color: 'from-emerald-50/60 to-emerald-100/0 dark:from-emerald-900/15 dark:to-emerald-900/0', border: 'border-emerald-200/40 dark:border-emerald-900/30', iconBg: 'bg-gradient-to-br from-emerald-400 to-emerald-500' }, + { type: 'progress' as const, iconComponent: TrendingUp, label: t('export.progress'), desc: t('export.competency_report'), color: 'from-amber-50/60 to-amber-100/0 dark:from-amber-900/15 dark:to-amber-900/0', border: 'border-amber-200/40 dark:border-amber-900/30', iconBg: 'bg-gradient-to-br from-amber-400 to-amber-500' }, + { type: 'assessments' as const, iconComponent: ClipboardCheck, label: t('export.assessments'), desc: t('export.attendance_report'), color: 'from-teal-50/60 to-teal-100/0 dark:from-teal-900/15 dark:to-teal-900/0', border: 'border-teal-200/40 dark:border-teal-900/30', iconBg: 'bg-gradient-to-br from-teal-400 to-teal-500' }, + { type: 'grades' as const, iconComponent: BarChart3, label: t('export.grades'), desc: t('export.grade_report'), 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'), desc: t('export.attendance_report'), 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) => (

{item.label}

-

CSV

+

{item.desc}

+
+ {exportFormat.toUpperCase()} +
))}
+ {/* Progress indicator */} + {exporting && ( +
+ +

{t('export.progress_indicator')}

+
+ )} + {/* Export history */} {exportHistory.length > 0 && ( -
-

{t('settings.export_history')}

-
+
+

{t('export.download_history')}

+
{exportHistory.map((item, idx) => (
- {item.type} +
+ {item.type} + {item.format?.toUpperCase() || 'CSV'} +
{item.date}
))} @@ -2462,7 +2944,7 @@ export default function SettingsView() { animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} > - +
@@ -2509,7 +2991,7 @@ export default function SettingsView() { {/* Demo Accounts Card */} - +
@@ -2662,7 +3144,7 @@ export default function SettingsView() { {/* ── Backup Tab ─────────────────────────────────────────── */} - +
@@ -2963,7 +3445,7 @@ export default function SettingsView() { {/* District Schools Detail */} {selectedDistrictId && ( - +
@@ -3053,7 +3535,7 @@ export default function SettingsView() { {/* Cross-School Comparison */} {districts.length > 0 && ( - +
@@ -3191,7 +3673,7 @@ export default function SettingsView() { {/* ── Rate Limit Tab ─────────────────────────────────────────── */} - +
@@ -3208,6 +3690,589 @@ export default function SettingsView() { + + {/* ── Branding Tab ───────────────────────────────────────────── */} + + + + +
+ +
+ {t('branding.title')} +
+ {t('branding.colors')} · {t('branding.typography')} · {t('branding.contact')} +
+ + {/* Logo */} +
+ + setBrandingForm({ ...brandingForm, logoUrl: e.target.value })} + placeholder={t('branding.logo_placeholder')} + className="border-teal-200 dark:border-teal-900/30 rounded-xl" + /> + {brandingForm.logoUrl && ( +
+ Logo preview { (e.target as HTMLImageElement).style.display = 'none'; }} /> + {t('branding.preview')} +
+ )} +
+ + {/* Colors */} +
+

{t('branding.colors')}

+
+
+ +
+ setBrandingForm({ ...brandingForm, primaryColor: e.target.value })} + className="w-10 h-10 rounded-lg border border-teal-200 dark:border-teal-900/30 cursor-pointer min-h-[44px]" + /> + setBrandingForm({ ...brandingForm, primaryColor: e.target.value })} + className="border-teal-200 dark:border-teal-900/30 rounded-xl text-sm" + /> +
+
+
+ +
+ setBrandingForm({ ...brandingForm, secondaryColor: e.target.value })} + className="w-10 h-10 rounded-lg border border-teal-200 dark:border-teal-900/30 cursor-pointer min-h-[44px]" + /> + setBrandingForm({ ...brandingForm, secondaryColor: e.target.value })} + className="border-teal-200 dark:border-teal-900/30 rounded-xl text-sm" + /> +
+
+
+ +
+ setBrandingForm({ ...brandingForm, accentColor: e.target.value })} + className="w-10 h-10 rounded-lg border border-teal-200 dark:border-teal-900/30 cursor-pointer min-h-[44px]" + /> + setBrandingForm({ ...brandingForm, accentColor: e.target.value })} + className="border-teal-200 dark:border-teal-900/30 rounded-xl text-sm" + /> +
+
+
+
+ + {/* Typography */} +
+ + +
+ + {/* Motto */} +
+ + setBrandingForm({ ...brandingForm, motto: e.target.value })} + placeholder={t('branding.motto_placeholder')} + className="border-teal-200 dark:border-teal-900/30 rounded-xl" + /> +
+ + {/* Contact info */} +
+

{t('branding.contact')}

+
+
+ + setBrandingForm({ ...brandingForm, websiteUrl: e.target.value })} + placeholder={t('branding.website_placeholder')} + className="border-teal-200 dark:border-teal-900/30 rounded-xl" + /> +
+
+ + setBrandingForm({ ...brandingForm, emailDomain: e.target.value })} + placeholder={t('branding.email_domain_placeholder')} + className="border-teal-200 dark:border-teal-900/30 rounded-xl" + /> +
+
+ + setBrandingForm({ ...brandingForm, address: e.target.value })} + className="border-teal-200 dark:border-teal-900/30 rounded-xl" + /> +
+
+ + setBrandingForm({ ...brandingForm, phone: e.target.value })} + className="border-teal-200 dark:border-teal-900/30 rounded-xl" + /> +
+
+
+ + {/* Custom CSS */} +
+ +