diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 050b758..230857a 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -104,6 +104,10 @@ model School { bookCheckouts BookCheckout[] bookReservations BookReservation[] seatingCharts SeatingChart[] + dataImportJobs DataImportJob[] + dataExportJobs DataExportJob[] + wellnessCheckins WellnessCheckin[] + wellnessScores WellnessScore[] } model User { @@ -149,6 +153,7 @@ model User { resources Resource[] organizedSchoolEvents SchoolEvent[] eventRegistrations EventRegistration[] + eventFeedbacks EventFeedback[] awardedBadges StudentBadge[] newsletters Newsletter[] competitionsCreated Competition[] @@ -187,6 +192,8 @@ model User { seatingCharts SeatingChart[] bookCheckouts BookCheckout[] bookReservations BookReservation[] + dataImportJobs DataImportJob[] + dataExportJobs DataExportJob[] } model SchoolYear { @@ -305,6 +312,8 @@ model Student { gradingAnnotations GradingAnnotation[] bookCheckouts BookCheckout[] bookReservations BookReservation[] + wellnessCheckins WellnessCheckin[] + wellnessScores WellnessScore[] @@index([userId]) } @@ -1622,7 +1631,7 @@ model SchoolEvent { schoolId String title String description String? - eventType String // "assembly", "field_trip", "sports_day", "concert", "fair", "parent_meeting", "graduation", "holiday" + eventType String // "assembly", "field_trip", "sports_day", "concert", "fair", "parent_meeting", "graduation", "holiday", "cultural", "academic", "social" startDate DateTime endDate DateTime? location String? @@ -1637,11 +1646,20 @@ model SchoolEvent { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? + budget Float? + registrationDeadline DateTime? + capacity Int? + isRecurring Boolean @default(false) + recurrenceRule String? // JSON: { type: "weekly", interval: 1, days: ["mon","wed"] } + bannerImageUrl String? + status String @default("draft") // draft, published, cancelled, completed + feedbackForm String? // JSON: feedback questions school School @relation(fields: [schoolId], references: [id]) organizer User? @relation(fields: [organizerId], references: [id]) classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) registrations EventRegistration[] + feedbacks EventFeedback[] @@index([schoolId, startDate]) @@map("school_events") @@ -1663,6 +1681,21 @@ model EventRegistration { @@map("event_registrations") } +model EventFeedback { + id String @id @default(cuid()) + eventId String + userId String + rating Int // 1-5 + comment String? + createdAt DateTime @default(now()) + + event SchoolEvent @relation(fields: [eventId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) + + @@unique([eventId, userId]) + @@map("event_feedbacks") +} + // ─── Student Transportation ────────────────────────────────────── model StudentTransport { @@ -2803,3 +2836,96 @@ model SeatingChart { @@index([teacherId]) @@map("seating_charts") } + +model DataImportJob { + id String @id @default(cuid()) + schoolId String + userId String + type String // STUDENT, TEACHER, GRADE, ATTENDANCE, COMPETENCY + fileName String + fileSize Int + status String @default("pending") // pending, processing, completed, failed + totalRows Int @default(0) + successRows Int @default(0) + errorRows Int @default(0) + errors String? // JSON string of error details + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@index([schoolId]) + @@index([userId]) + @@map("data_import_jobs") +} + +model DataExportJob { + id String @id @default(cuid()) + schoolId String + userId String + type String // STUDENT, GRADE, ATTENDANCE, COMPETENCY, REPORT + format String // CSV, JSON + filters String? // JSON string of filter parameters + fileName String? + fileSize Int @default(0) + status String @default("pending") // pending, processing, completed, failed + fileData String? // Base64 encoded file data + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@index([schoolId]) + @@index([userId]) + @@map("data_export_jobs") +} + +model WellnessCheckin { + id String @id @default(cuid()) + schoolId String + studentId String + date DateTime + mood Int // 1-5 + sleepHours Float? + sleepQuality Int? // 1-5 + stressLevel Int? // 1-10 + activityType String? + activityMinutes Int? + mealsCount Int? + waterGlasses Int? + gratitudeEntry String? + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + + @@index([schoolId]) + @@index([studentId]) + @@index([date]) + @@map("wellness_checkins") +} + +model WellnessScore { + id String @id @default(cuid()) + schoolId String + studentId String + date DateTime + overallScore Float // 0-100 + physicalScore Float? // 0-100 + mentalScore Float? // 0-100 + socialScore Float? // 0-100 + academicScore Float? // 0-100 + createdAt DateTime @default(now()) + + school School @relation(fields: [schoolId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + + @@index([schoolId]) + @@index([studentId]) + @@index([date]) + @@map("wellness_scores") +} diff --git a/src/app/api/data-cleanup/route.ts b/src/app/api/data-cleanup/route.ts new file mode 100644 index 0000000..bcdf184 --- /dev/null +++ b/src/app/api/data-cleanup/route.ts @@ -0,0 +1,219 @@ +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 }); + } + + if ( + session.user?.role !== 'SUPER_ADMIN' && + session.user?.role !== 'SCHOOL_ADMIN' && + session.user?.role !== 'VICE_PRINCIPAL' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId') || session.user?.schoolId; + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + // Get database statistics + const [ + studentCount, + teacherCount, + gradeCount, + attendanceCount, + competencyCount, + classCount, + subjectCount, + assessmentCount, + reportCount, + orphanedStudents, + orphanedEnrollments, + ] = await Promise.all([ + db.student.count({ where: { schoolId, deletedAt: null } }), + db.user.count({ where: { schoolId, deletedAt: null, role: 'TEACHER' } }), + db.assessmentResult.count({ + where: { assessment: { classGroup: { schoolId } } }, + }), + db.attendanceRecord.count({ + where: { student: { schoolId, deletedAt: null } }, + }), + db.learningProgressEntry.count({ + where: { student: { schoolId, deletedAt: null } }, + }), + db.classGroup.count({ where: { schoolId } }), + db.subject.count({ where: { schoolId } }), + db.assessment.count({ where: { classGroup: { schoolId } } }), + db.report.count({ where: { schoolId } }), + // Orphaned students: students without enrollments + db.student.count({ + where: { + schoolId, + deletedAt: null, + enrollments: { none: {} }, + }, + }), + // Orphaned enrollments: enrollments pointing to deleted students + db.enrollment.count({ + where: { + student: { deletedAt: { not: null } }, + }, + }), + ]); + + return NextResponse.json({ + statistics: { + students: studentCount, + teachers: teacherCount, + grades: gradeCount, + attendance: attendanceCount, + competencies: competencyCount, + classes: classCount, + subjects: subjectCount, + assessments: assessmentCount, + reports: reportCount, + total: studentCount + teacherCount + gradeCount + attendanceCount + competencyCount, + }, + cleanup: { + orphanedStudents, + orphanedEnrollments, + totalOrphans: orphanedStudents + orphanedEnrollments, + }, + }); + } catch (error) { + console.error('Data Cleanup GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user?.role !== 'SUPER_ADMIN' && + session.user?.role !== 'SCHOOL_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { operation, schoolId: bodySchoolId } = body as { + operation: string; + schoolId?: string; + }; + + const schoolId = bodySchoolId || session.user?.schoolId; + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + let result: Record = {}; + + if (operation === 'remove_orphans') { + // Remove orphaned enrollments (enrollments pointing to deleted students) + const deletedEnrollments = await db.enrollment.deleteMany({ + where: { + student: { deletedAt: { not: null } }, + }, + }); + + // Remove orphaned attendance records + const deletedAttendance = await db.attendanceRecord.deleteMany({ + where: { + student: { deletedAt: { not: null } }, + }, + }); + + // Remove orphaned learning progress entries + const deletedProgress = await db.learningProgressEntry.deleteMany({ + where: { + student: { deletedAt: { not: null } }, + }, + }); + + result = { + deletedEnrollments: deletedEnrollments.count, + deletedAttendance: deletedAttendance.count, + deletedProgress: deletedProgress.count, + totalDeleted: deletedEnrollments.count + deletedAttendance.count + deletedProgress.count, + }; + } else if (operation === 'bulk_delete') { + const { type, ids } = body as { type: string; ids: string[] }; + + if (!Array.isArray(ids) || ids.length === 0) { + return NextResponse.json({ error: 'ids array is required' }, { status: 400 }); + } + + let deleted = 0; + + if (type === 'STUDENT') { + const res = await db.student.updateMany({ + where: { id: { in: ids }, schoolId }, + data: { deletedAt: new Date() }, + }); + deleted = res.count; + } else if (type === 'ASSESSMENT') { + const res = await db.assessment.deleteMany({ + where: { id: { in: ids }, classGroup: { schoolId } }, + }); + deleted = res.count; + } else if (type === 'REPORT') { + const res = await db.report.deleteMany({ + where: { id: { in: ids }, schoolId }, + }); + deleted = res.count; + } + + result = { type, deleted }; + } else if (operation === 'backup') { + // Create a simple backup by counting all records + const tables = { + schools: await db.school.count(), + users: await db.user.count(), + students: await db.student.count({ where: { deletedAt: null } }), + classes: await db.classGroup.count(), + subjects: await db.subject.count(), + assessments: await db.assessment.count(), + reports: await db.report.count(), + }; + + result = { + backup: true, + timestamp: new Date().toISOString(), + tables, + message: 'Backup metadata recorded. Full database backup requires server-side access.', + }; + } else { + return NextResponse.json({ error: 'Unknown operation' }, { status: 400 }); + } + + // Create audit log + await db.auditLog.create({ + data: { + userId: session.userId, + schoolId, + action: operation.toUpperCase(), + entityType: 'DataCleanup', + entityId: null, + metadata: JSON.stringify(result), + }, + }); + + return NextResponse.json({ success: true, result }); + } catch (error) { + console.error('Data Cleanup POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/data-export/jobs/[id]/route.ts b/src/app/api/data-export/jobs/[id]/route.ts new file mode 100644 index 0000000..e868014 --- /dev/null +++ b/src/app/api/data-export/jobs/[id]/route.ts @@ -0,0 +1,73 @@ +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 job = await db.dataExportJob.findUnique({ + where: { id }, + }); + + if (!job) { + return NextResponse.json({ error: 'Export job not found' }, { status: 404 }); + } + + // Check access + if ( + session.user?.role !== 'SUPER_ADMIN' && + session.user?.role !== 'SCHOOL_ADMIN' && + session.user?.role !== 'VICE_PRINCIPAL' && + job.userId !== session.userId + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(job); + } catch (error) { + console.error('Data Export Job GET 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' && + session.user?.role !== 'VICE_PRINCIPAL' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const job = await db.dataExportJob.findUnique({ where: { id } }); + + if (!job) { + return NextResponse.json({ error: 'Export job not found' }, { status: 404 }); + } + + await db.dataExportJob.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Data Export Job DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/data-export/jobs/route.ts b/src/app/api/data-export/jobs/route.ts new file mode 100644 index 0000000..5198ba3 --- /dev/null +++ b/src/app/api/data-export/jobs/route.ts @@ -0,0 +1,261 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +function escapeCsvField(field: string | number | null | undefined): string { + if (field === null || field === undefined) return ''; + const str = String(field); + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +function toCsvRow(fields: (string | number | null | undefined)[]): string { + return fields.map(escapeCsvField).join(','); +} + +function objectsToCsv(data: Record[]): string { + if (data.length === 0) return ''; + const headers = Object.keys(data[0]); + const headerRow = toCsvRow(headers); + const dataRows = data.map((item) => + toCsvRow(headers.map((h) => item[h] as string | number | null | undefined)) + ); + return [headerRow, ...dataRows].join('\n'); +} + +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: 'schoolId is required' }, { status: 400 }); + } + + const where: Record = { schoolId }; + if (session.user?.role === 'TEACHER') { + where.userId = session.userId; + } + + const jobs = await db.dataExportJob.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: 50, + }); + + return NextResponse.json(jobs); + } catch (error) { + console.error('Data Export GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user?.role !== 'SUPER_ADMIN' && + session.user?.role !== 'SCHOOL_ADMIN' && + session.user?.role !== 'VICE_PRINCIPAL' && + session.user?.role !== 'TEACHER' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { type, format, filters } = body as { + type: string; + format: string; + filters?: Record; + }; + + const schoolId = (filters?.schoolId as string) || session.user?.schoolId; + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + // Create export job + const exportJob = await db.dataExportJob.create({ + data: { + schoolId, + userId: session.userId, + type: type || 'STUDENT', + format: format || 'CSV', + filters: filters ? JSON.stringify(filters) : null, + status: 'processing', + }, + }); + + let exportData: Record[] = []; + let fileName = ''; + + try { + if (type === 'STUDENT') { + const students = await db.student.findMany({ + where: { + schoolId, + deletedAt: null, + ...(filters?.classId ? { enrollments: { some: { classGroupId: filters.classId as string } } } : {}), + }, + include: { + enrollments: { include: { classGroup: { select: { name: true } } } }, + }, + take: 10000, + }); + exportData = students.map((s) => ({ + id: s.id, + firstName: s.firstName, + lastName: s.lastName, + externalId: s.externalId || '', + dateOfBirth: s.dateOfBirth ? s.dateOfBirth.toISOString().split('T')[0] : '', + class: s.enrollments[0]?.classGroup?.name || '', + })); + fileName = `students_export_${new Date().toISOString().split('T')[0]}`; + } else if (type === 'GRADE') { + const results = await db.assessmentResult.findMany({ + where: { + assessment: { + classGroup: { schoolId }, + ...(filters?.classId ? { classGroupId: filters.classId as string } : {}), + ...(filters?.subjectId ? { subjectId: filters.subjectId as string } : {}), + }, + }, + include: { + student: { select: { firstName: true, lastName: true } }, + assessment: { select: { title: true, type: true } }, + }, + take: 10000, + }); + exportData = results.map((r) => ({ + studentName: `${r.student.firstName} ${r.student.lastName}`, + assessmentTitle: r.assessment.title, + assessmentType: r.assessment.type, + score: r.score ?? '', + note: r.note || '', + })); + fileName = `grades_export_${new Date().toISOString().split('T')[0]}`; + } else if (type === 'ATTENDANCE') { + const records = await db.attendanceRecord.findMany({ + where: { + student: { schoolId, deletedAt: null }, + ...(filters?.classId ? { student: { enrollments: { some: { classGroupId: filters.classId as string } } } } : {}), + }, + include: { + student: { select: { firstName: true, lastName: true } }, + }, + take: 10000, + }); + exportData = records.map((r) => ({ + studentName: `${r.student.firstName} ${r.student.lastName}`, + status: r.status, + date: r.date ? new Date(r.date).toISOString().split('T')[0] : '', + })); + fileName = `attendance_export_${new Date().toISOString().split('T')[0]}`; + } else if (type === 'COMPETENCY') { + const entries = await db.learningProgressEntry.findMany({ + where: { + student: { schoolId, deletedAt: null }, + ...(filters?.classId ? { student: { enrollments: { some: { classGroupId: filters.classId as string } } } } : {}), + }, + include: { + student: { select: { firstName: true, lastName: true } }, + competency: { select: { code: true, title: true } }, + }, + take: 10000, + }); + exportData = entries.map((e) => ({ + studentName: `${e.student.firstName} ${e.student.lastName}`, + competencyCode: e.competency.code, + competencyTitle: e.competency.title, + level: e.level, + note: e.note || '', + })); + fileName = `competencies_export_${new Date().toISOString().split('T')[0]}`; + } else if (type === 'REPORT') { + const reports = await db.report.findMany({ + where: { + schoolId, + ...(filters?.classId ? { classGroupId: filters.classId as string } : {}), + }, + include: { + student: { select: { firstName: true, lastName: true } }, + }, + take: 10000, + }); + exportData = reports.map((r) => ({ + studentName: `${r.student.firstName} ${r.student.lastName}`, + title: r.title || '', + status: r.status, + createdAt: r.createdAt.toISOString().split('T')[0], + })); + fileName = `reports_export_${new Date().toISOString().split('T')[0]}`; + } + + // Generate file content + let fileContent: string; + const ext = format === 'JSON' ? 'json' : 'csv'; + + if (format === 'JSON') { + fileContent = JSON.stringify(exportData, null, 2); + } else { + fileContent = objectsToCsv(exportData); + } + + fileName = `${fileName}.${ext}`; + + // Update export job as completed + await db.dataExportJob.update({ + where: { id: exportJob.id }, + data: { + status: 'completed', + fileName, + fileSize: Buffer.byteLength(fileContent), + fileData: Buffer.from(fileContent).toString('base64'), + }, + }); + + // Create audit log + await db.auditLog.create({ + data: { + userId: session.userId, + schoolId, + action: 'EXPORT', + entityType: 'DataExportJob', + entityId: exportJob.id, + metadata: JSON.stringify({ type, format, recordCount: exportData.length, fileName }), + }, + }); + + return NextResponse.json({ + id: exportJob.id, + type, + format, + fileName, + fileSize: Buffer.byteLength(fileContent), + recordCount: exportData.length, + status: 'completed', + fileData: Buffer.from(fileContent).toString('base64'), + }, { status: 201 }); + } catch (processingError) { + await db.dataExportJob.update({ + where: { id: exportJob.id }, + data: { status: 'failed' }, + }); + throw processingError; + } + } catch (error) { + console.error('Data Export POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/data-import/route.ts b/src/app/api/data-import/route.ts index 79c67e9..8d61241 100755 --- a/src/app/api/data-import/route.ts +++ b/src/app/api/data-import/route.ts @@ -3,19 +3,6 @@ import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; import { withRateLimit } from '@/lib/rate-limit'; -function escapeCsvField(field: string | number | null | undefined): string { - if (field === null || field === undefined) return ''; - const str = String(field); - if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { - return `"${str.replace(/"/g, '""')}"`; - } - return str; -} - -function toCsvRow(fields: (string | number | null | undefined)[]): string { - return fields.map(escapeCsvField).join(','); -} - interface ImportRow { [key: string]: string; } @@ -60,6 +47,21 @@ function parseCsv(text: string): { headers: string[]; rows: ImportRow[] } { return { headers, rows }; } +function parseJson(text: string): { headers: string[]; rows: ImportRow[] } { + const data = JSON.parse(text); + const arr = Array.isArray(data) ? data : data.records || data.data || []; + if (arr.length === 0) return { headers: [], rows: [] }; + const headers = Object.keys(arr[0]); + const rows = arr.map((item: Record) => { + const row: ImportRow = {}; + headers.forEach((h) => { + row[h] = item[h] !== undefined && item[h] !== null ? String(item[h]) : ''; + }); + return row; + }); + return { headers, rows }; +} + export const POST = withRateLimit(async function POST(request: Request) { try { const session = await getSession(); @@ -70,6 +72,7 @@ export const POST = withRateLimit(async function POST(request: Request) { if ( session.user?.role !== 'SUPER_ADMIN' && session.user?.role !== 'SCHOOL_ADMIN' && + session.user?.role !== 'VICE_PRINCIPAL' && session.user?.role !== 'TEACHER' ) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); @@ -77,8 +80,9 @@ export const POST = withRateLimit(async function POST(request: Request) { const formData = await request.formData(); const file = formData.get('file') as File | null; - const type = formData.get('type') as string || 'students'; - const schoolId = formData.get('schoolId') as string || session.user?.schoolId; + const type = (formData.get('type') as string) || 'STUDENT'; + const schoolId = (formData.get('schoolId') as string) || session.user?.schoolId; + const columnMapping = formData.get('columnMapping') as string | null; if (!file) { return NextResponse.json({ error: 'No file provided' }, { status: 400 }); @@ -88,22 +92,84 @@ export const POST = withRateLimit(async function POST(request: Request) { return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); } + // Create import job record + const importJob = await db.dataImportJob.create({ + data: { + schoolId, + userId: session.userId, + type, + fileName: file.name, + fileSize: file.size, + status: 'processing', + }, + }); + const text = await file.text(); - const { headers, rows } = parseCsv(text); + let parsed: { headers: string[]; rows: ImportRow[] }; + + // Parse based on file type + if (file.name.endsWith('.json')) { + try { + parsed = parseJson(text); + } catch { + await db.dataImportJob.update({ + where: { id: importJob.id }, + data: { status: 'failed', errors: JSON.stringify([{ error: 'Invalid JSON format' }]) }, + }); + return NextResponse.json({ error: 'Invalid JSON format' }, { status: 400 }); + } + } else { + try { + parsed = parseCsv(text); + } catch { + await db.dataImportJob.update({ + where: { id: importJob.id }, + data: { status: 'failed', errors: JSON.stringify([{ error: 'Invalid CSV format' }]) }, + }); + return NextResponse.json({ error: 'Invalid CSV format' }, { status: 400 }); + } + } + + const { headers, rows } = parsed; if (rows.length === 0) { - return NextResponse.json({ error: 'No data rows found in CSV' }, { status: 400 }); + await db.dataImportJob.update({ + where: { id: importJob.id }, + data: { status: 'failed', totalRows: 0, errors: JSON.stringify([{ error: 'No data rows found' }]) }, + }); + return NextResponse.json({ error: 'No data rows found in file' }, { status: 400 }); + } + + // Apply column mapping if provided + let mappedRows = rows; + if (columnMapping) { + try { + const mapping: Record = JSON.parse(columnMapping); + mappedRows = rows.map((row) => { + const mapped: ImportRow = {}; + for (const [csvCol, dbField] of Object.entries(mapping)) { + if (dbField && row[csvCol] !== undefined) { + mapped[dbField] = row[csvCol]; + } + } + return mapped; + }); + } catch { + // If mapping fails, use original rows + } } const result: ImportResult = { created: 0, skipped: 0, errors: [] }; - if (type === 'students') { - for (const row of rows) { + // Import based on type + if (type === 'STUDENT') { + for (const row of mappedRows) { try { - const firstName = row['First Name'] || row['firstName'] || row['Vorname'] || ''; - const lastName = row['Last Name'] || row['lastName'] || row['Nachname'] || ''; - const externalId = row['External ID'] || row['externalId'] || row['ID'] || ''; - const dateOfBirth = row['Date of Birth'] || row['dateOfBirth'] || row['Geburtsdatum'] || ''; + const firstName = row['firstName'] || row['First Name'] || row['Vorname'] || ''; + const lastName = row['lastName'] || row['Last Name'] || row['Nachname'] || ''; + const externalId = row['externalId'] || row['External ID'] || row['ID'] || ''; + const dateOfBirth = row['dateOfBirth'] || row['Date of Birth'] || row['Geburtsdatum'] || ''; + const email = row['email'] || row['Email'] || ''; if (!firstName || !lastName) { result.skipped++; @@ -134,31 +200,34 @@ export const POST = withRateLimit(async function POST(request: Request) { result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`); } } - } else if (type === 'assessments') { - for (const row of rows) { + } else if (type === 'TEACHER') { + for (const row of mappedRows) { try { - const title = row['Title'] || row['title'] || row['Titel'] || ''; - const classGroupId = row['Class ID'] || row['classGroupId'] || ''; - const subjectId = row['Subject ID'] || row['subjectId'] || ''; - const date = row['Date'] || row['date'] || row['Datum'] || ''; - const assessmentType = row['Type'] || row['type'] || 'WRITTEN'; + const firstName = row['firstName'] || row['First Name'] || row['Vorname'] || ''; + const lastName = row['lastName'] || row['Last Name'] || row['Nachname'] || ''; + const email = row['email'] || row['Email'] || ''; + const role = row['role'] || row['Role'] || 'TEACHER'; - if (!title || !classGroupId) { + if (!firstName || !lastName || !email) { result.skipped++; - result.errors.push(`Row skipped: missing title or class ID`); + result.errors.push(`Row skipped: missing required fields`); continue; } - await db.assessment.create({ + const existing = await db.user.findUnique({ where: { email } }); + if (existing) { + result.skipped++; + continue; + } + + await db.user.create({ data: { - title, - classGroupId, - subjectId: subjectId || null, - teacherId: session.userId, - date: date ? new Date(date) : new Date(), - type: assessmentType, - maxScore: row['Max Score'] || row['maxScore'] ? parseFloat(row['Max Score'] || row['maxScore']) : null, - weight: row['Weight'] || row['weight'] ? parseFloat(row['Weight'] || row['weight']) : 1.0, + schoolId, + firstName, + lastName, + email, + role, + passwordHash: '$2a$10$default.placeholder.hash.value', }, }); result.created++; @@ -166,12 +235,12 @@ export const POST = withRateLimit(async function POST(request: Request) { result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`); } } - } else if (type === 'grades') { - for (const row of rows) { + } else if (type === 'GRADE') { + for (const row of mappedRows) { try { - const studentId = row['Student ID'] || row['studentId'] || ''; - const assessmentId = row['Assessment ID'] || row['assessmentId'] || ''; - const score = row['Score'] || row['score'] || ''; + const studentId = row['studentId'] || row['Student ID'] || ''; + const assessmentId = row['assessmentId'] || row['Assessment ID'] || ''; + const score = row['score'] || row['Score'] || ''; if (!studentId || !assessmentId) { result.skipped++; @@ -184,7 +253,60 @@ export const POST = withRateLimit(async function POST(request: Request) { studentId, assessmentId, score: score ? parseFloat(score) : null, - note: row['Note'] || row['note'] || null, + note: row['note'] || row['Note'] || null, + }, + }); + result.created++; + } catch (err) { + result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + } + } else if (type === 'ATTENDANCE') { + for (const row of mappedRows) { + try { + const studentId = row['studentId'] || row['Student ID'] || ''; + const date = row['date'] || row['Date'] || row['Datum'] || ''; + const status = row['status'] || row['Status'] || 'PRESENT'; + + if (!studentId || !date) { + result.skipped++; + result.errors.push(`Row skipped: missing student ID or date`); + continue; + } + + await db.attendanceRecord.create({ + data: { + studentId, + sessionId: row['sessionId'] || row['Session ID'] || '', + status, + date: new Date(date), + }, + }); + result.created++; + } catch (err) { + result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + } + } else if (type === 'COMPETENCY') { + for (const row of mappedRows) { + try { + const studentId = row['studentId'] || row['Student ID'] || ''; + const competencyId = row['competencyId'] || row['Competency ID'] || ''; + const level = row['level'] || row['Level'] || ''; + + if (!studentId || !competencyId) { + result.skipped++; + result.errors.push(`Row skipped: missing student ID or competency ID`); + continue; + } + + await db.learningProgressEntry.create({ + data: { + studentId, + competencyId, + teacherId: session.userId, + level: level ? parseInt(level) : 1, + note: row['note'] || row['Note'] || null, }, }); result.created++; @@ -194,18 +316,38 @@ export const POST = withRateLimit(async function POST(request: Request) { } } + // Update import job + await db.dataImportJob.update({ + where: { id: importJob.id }, + data: { + status: 'completed', + totalRows: rows.length, + successRows: result.created, + errorRows: result.skipped + result.errors.length, + errors: result.errors.length > 0 ? JSON.stringify(result.errors.slice(0, 50)) : null, + }, + }); + + // Create audit log await db.auditLog.create({ data: { userId: session.userId, schoolId: session.user?.schoolId ?? null, action: 'IMPORT', - entityType: 'DataImport', - entityId: null, - metadata: JSON.stringify({ type, created: result.created, skipped: result.skipped, errorCount: result.errors.length }), + entityType: 'DataImportJob', + entityId: importJob.id, + metadata: JSON.stringify({ + type, + created: result.created, + skipped: result.skipped, + errorCount: result.errors.length, + fileName: file.name, + }), }, }); return NextResponse.json({ + id: importJob.id, type, totalRows: rows.length, created: result.created, @@ -228,30 +370,27 @@ export async function GET(request: Request) { } const { searchParams } = new URL(request.url); - const type = searchParams.get('type') || 'students'; + const schoolId = searchParams.get('schoolId') || session.user?.schoolId; - let csvContent = ''; - let filename = ''; - - if (type === 'students') { - csvContent = 'First Name,Last Name,Date of Birth,External ID\nMax,Mustermann,2015-03-15,STU001\nAnna,Schmidt,2015-07-22,STU002\n'; - filename = 'sample_students.csv'; - } else if (type === 'assessments') { - csvContent = 'Title,Class ID,Subject ID,Date,Type,Max Score,Weight\nMath Test 1,cls_001,subj_math,2025-01-15,WRITTEN,100,1.0\nReading Test,cls_001,subj_german,2025-01-20,WRITTEN,50,1.0\n'; - filename = 'sample_assessments.csv'; - } else if (type === 'grades') { - csvContent = 'Student ID,Assessment ID,Score,Note\nstu_001,asmt_001,85,Good work\nstu_002,asmt_001,92,Excellent\n'; - filename = 'sample_grades.csv'; + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); } - return new NextResponse(csvContent, { - headers: { - 'Content-Type': 'text/csv; charset=utf-8', - 'Content-Disposition': `attachment; filename="${filename}"`, - }, + // For non-admin users, only show their own imports + const where: Record = { schoolId }; + if (session.user?.role === 'TEACHER') { + where.userId = session.userId; + } + + const jobs = await db.dataImportJob.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: 50, }); + + return NextResponse.json(jobs); } catch (error) { - console.error('Sample CSV download error:', error); + console.error('Data Import GET error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } diff --git a/src/app/api/school-events/[id]/feedback/route.ts b/src/app/api/school-events/[id]/feedback/route.ts new file mode 100644 index 0000000..6b72a85 --- /dev/null +++ b/src/app/api/school-events/[id]/feedback/route.ts @@ -0,0 +1,122 @@ +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 }, + }); + + if (!event) { + return NextResponse.json({ error: 'Event not found' }, { status: 404 }); + } + + const feedbacks = await db.eventFeedback.findMany({ + where: { eventId: id }, + include: { + user: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + // Calculate average rating + const avgRating = feedbacks.length > 0 + ? feedbacks.reduce((sum, f) => sum + f.rating, 0) / feedbacks.length + : 0; + + return NextResponse.json({ + feedbacks, + stats: { + total: feedbacks.length, + averageRating: Math.round(avgRating * 10) / 10, + ratingDistribution: [1, 2, 3, 4, 5].map(r => ({ + rating: r, + count: feedbacks.filter(f => f.rating === r).length, + })), + }, + }); + } catch (error) { + console.error('EventFeedback GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + 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 }); + } + + // Only allow feedback for completed events + if (event.status !== 'completed' && new Date() < new Date(event.startDate)) { + return NextResponse.json( + { error: 'Feedback can only be submitted after the event has ended' }, + { status: 400 } + ); + } + + const body = await request.json(); + const { userId, rating, comment } = body; + + if (!userId || !rating) { + return NextResponse.json({ error: 'userId and rating are required' }, { status: 400 }); + } + + if (rating < 1 || rating > 5) { + return NextResponse.json({ error: 'Rating must be between 1 and 5' }, { status: 400 }); + } + + // Check if already submitted feedback + const existing = await db.eventFeedback.findUnique({ + where: { eventId_userId: { eventId: id, userId } }, + }); + + if (existing) { + return NextResponse.json({ error: 'Feedback already submitted' }, { status: 409 }); + } + + const feedback = await db.eventFeedback.create({ + data: { + eventId: id, + userId, + rating, + comment: comment || null, + }, + include: { + user: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + return NextResponse.json(feedback, { status: 201 }); + } catch (error) { + console.error('EventFeedback 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 index ca7d2ec..b99cd2c 100755 --- a/src/app/api/school-events/[id]/register/route.ts +++ b/src/app/api/school-events/[id]/register/route.ts @@ -25,6 +25,11 @@ export async function POST( return NextResponse.json({ error: 'Event does not require registration' }, { status: 400 }); } + // Check registration deadline + if (event.registrationDeadline && new Date() > new Date(event.registrationDeadline)) { + return NextResponse.json({ error: 'Registration deadline has passed' }, { status: 400 }); + } + const body = await request.json(); const { userId, notes } = body; @@ -49,13 +54,14 @@ export async function POST( return NextResponse.json({ error: 'Already registered' }, { status: 409 }); } - // Check max participants - if (event.maxParticipants) { + // Check capacity (use capacity field if available, otherwise maxParticipants) + const maxCap = event.capacity ?? event.maxParticipants; + if (maxCap) { const count = await db.eventRegistration.count({ where: { eventId: id, status: 'registered' }, }); - if (count >= event.maxParticipants) { - return NextResponse.json({ error: 'Event is full' }, { status: 400 }); + if (count >= maxCap) { + return NextResponse.json({ error: 'Event is full', waitlist: true }, { status: 400 }); } } @@ -65,6 +71,11 @@ export async function POST( userId, notes: notes || null, }, + include: { + user: { + select: { id: true, firstName: true, lastName: true }, + }, + }, }); return NextResponse.json(registration, { status: 201 }); @@ -103,6 +114,11 @@ export async function PUT( const updated = await db.eventRegistration.update({ where: { id: registration.id }, data: { status }, + include: { + user: { + select: { id: true, firstName: true, lastName: true }, + }, + }, }); return NextResponse.json(updated); @@ -111,3 +127,47 @@ export async function PUT( 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 { searchParams } = new URL(request.url); + const userId = searchParams.get('userId'); + + if (!userId) { + return NextResponse.json({ error: 'userId is 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 }); + } + + // Cancel registration (soft delete via status) + const updated = await db.eventRegistration.update({ + where: { id: registration.id }, + data: { status: 'cancelled' }, + include: { + user: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('EventRegistration DELETE 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 index e7a804c..e17a89a 100755 --- a/src/app/api/school-events/[id]/route.ts +++ b/src/app/api/school-events/[id]/route.ts @@ -29,6 +29,13 @@ export async function GET( }, }, }, + feedbacks: { + include: { + user: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }, }, }); @@ -85,6 +92,14 @@ export async function PUT( requiresRegistration, maxParticipants, notes, + budget, + registrationDeadline, + capacity, + isRecurring, + recurrenceRule, + bannerImageUrl, + status, + feedbackForm, } = body; const updated = await db.schoolEvent.update({ @@ -103,6 +118,14 @@ export async function PUT( ...(requiresRegistration !== undefined && { requiresRegistration }), ...(maxParticipants !== undefined && { maxParticipants: maxParticipants || null }), ...(notes !== undefined && { notes }), + ...(budget !== undefined && { budget: budget ?? null }), + ...(registrationDeadline !== undefined && { registrationDeadline: registrationDeadline ? new Date(registrationDeadline) : null }), + ...(capacity !== undefined && { capacity: capacity ?? null }), + ...(isRecurring !== undefined && { isRecurring }), + ...(recurrenceRule !== undefined && { recurrenceRule: recurrenceRule || null }), + ...(bannerImageUrl !== undefined && { bannerImageUrl: bannerImageUrl || null }), + ...(status !== undefined && { status }), + ...(feedbackForm !== undefined && { feedbackForm: feedbackForm || null }), }, include: { organizer: { @@ -118,6 +141,13 @@ export async function PUT( }, }, }, + feedbacks: { + include: { + user: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }, }, }); diff --git a/src/app/api/school-events/route.ts b/src/app/api/school-events/route.ts index 8d9f846..72d1f2c 100755 --- a/src/app/api/school-events/route.ts +++ b/src/app/api/school-events/route.ts @@ -14,6 +14,11 @@ export async function GET(request: Request) { const eventType = searchParams.get('eventType'); const classGroupId = searchParams.get('classGroupId'); const upcoming = searchParams.get('upcoming'); + const search = searchParams.get('search'); + const status = searchParams.get('status'); + const startDateFrom = searchParams.get('startDateFrom'); + const startDateTo = searchParams.get('startDateTo'); + const requiresRegistration = searchParams.get('requiresRegistration'); if (!schoolId) { return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); @@ -30,11 +35,24 @@ export async function GET(request: Request) { if (eventType) where.eventType = eventType; if (classGroupId) where.classGroupId = classGroupId; + if (status) where.status = status; + if (requiresRegistration === 'true') where.requiresRegistration = true; + + if (search) { + where.title = { contains: search }; + } if (upcoming === 'true') { where.startDate = { gte: new Date() }; } + if (startDateFrom || startDateTo) { + const dateFilter: Record = {}; + if (startDateFrom) dateFilter.gte = new Date(startDateFrom); + if (startDateTo) dateFilter.lte = new Date(startDateTo); + where.startDate = dateFilter; + } + const events = await db.schoolEvent.findMany({ where, orderBy: { startDate: 'asc' }, @@ -52,6 +70,13 @@ export async function GET(request: Request) { }, }, }, + feedbacks: { + include: { + user: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }, }, }); @@ -86,6 +111,14 @@ export async function POST(request: Request) { maxParticipants, notes, isDemo, + budget, + registrationDeadline, + capacity, + isRecurring, + recurrenceRule, + bannerImageUrl, + status, + feedbackForm, } = body; if (!schoolId || !title || !eventType || !startDate) { @@ -116,6 +149,14 @@ export async function POST(request: Request) { maxParticipants: maxParticipants || null, notes: notes || null, isDemo: isDemo ?? false, + budget: budget ?? null, + registrationDeadline: registrationDeadline ? new Date(registrationDeadline) : null, + capacity: capacity ?? null, + isRecurring: isRecurring ?? false, + recurrenceRule: recurrenceRule || null, + bannerImageUrl: bannerImageUrl || null, + status: status || 'draft', + feedbackForm: feedbackForm || null, }, include: { organizer: { @@ -131,6 +172,13 @@ export async function POST(request: Request) { }, }, }, + feedbacks: { + include: { + user: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }, }, }); diff --git a/src/app/api/wellness/route.ts b/src/app/api/wellness/route.ts new file mode 100644 index 0000000..4e3fcc2 --- /dev/null +++ b/src/app/api/wellness/route.ts @@ -0,0 +1,252 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/wellness — Get wellness data for a student +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const schoolId = searchParams.get('schoolId'); + const studentId = searchParams.get('studentId'); + const role = searchParams.get('role') || 'STUDENT'; + const classGroupId = searchParams.get('classGroupId'); + const limit = parseInt(searchParams.get('limit') || '30'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + // Admin/VP: school-wide overview + if ((role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') && !studentId) { + const totalCheckins = await db.wellnessCheckin.count({ + where: { schoolId }, + }); + const totalStudents = await db.student.count({ + where: { schoolId, deletedAt: null }, + }); + const recentCheckins = await db.wellnessCheckin.findMany({ + where: { schoolId, date: { gte: new Date(Date.now() - 7 * 86400000) } }, + orderBy: { date: 'desc' }, + take: 50, + include: { student: { select: { id: true, firstName: true, lastName: true } } }, + }); + const avgScores = await db.wellnessScore.aggregate({ + where: { schoolId, date: { gte: new Date(Date.now() - 30 * 86400000) } }, + _avg: { overallScore: true, physicalScore: true, mentalScore: true, socialScore: true, academicScore: true }, + }); + const lowWellnessCount = await db.wellnessScore.count({ + where: { schoolId, overallScore: { lt: 40 }, date: { gte: new Date(Date.now() - 30 * 86400000) } }, + }); + + return NextResponse.json({ + type: 'school_overview', + totalCheckins, + totalStudents, + recentCheckins, + avgScores: { + overall: avgScores._avg.overallScore ?? 0, + physical: avgScores._avg.physicalScore ?? 0, + mental: avgScores._avg.mentalScore ?? 0, + social: avgScores._avg.socialScore ?? 0, + academic: avgScores._avg.academicScore ?? 0, + }, + lowWellnessAlerts: lowWellnessCount, + }); + } + + // Teacher: class overview + if (role === 'TEACHER' && classGroupId && !studentId) { + const enrollments = await db.enrollment.findMany({ + where: { classGroupId, endDate: null }, + select: { studentId: true }, + }); + const studentIds = enrollments.map(e => e.studentId); + + const checkins = await db.wellnessCheckin.findMany({ + where: { schoolId, studentId: { in: studentIds }, date: { gte: new Date(Date.now() - 30 * 86400000) } }, + orderBy: { date: 'desc' }, + take: limit, + include: { student: { select: { id: true, firstName: true, lastName: true } } }, + }); + const scores = await db.wellnessScore.findMany({ + where: { schoolId, studentId: { in: studentIds }, date: { gte: new Date(Date.now() - 30 * 86400000) } }, + orderBy: { date: 'desc' }, + }); + + return NextResponse.json({ + type: 'class_overview', + checkins, + scores, + studentCount: studentIds.length, + }); + } + + // Student or specific student view + if (studentId) { + const checkins = await db.wellnessCheckin.findMany({ + where: { schoolId, studentId }, + orderBy: { date: 'desc' }, + take: limit, + }); + const scores = await db.wellnessScore.findMany({ + where: { schoolId, studentId }, + orderBy: { date: 'desc' }, + take: limit, + }); + const latestScore = scores[0] ?? null; + + return NextResponse.json({ + type: 'student', + checkins, + scores, + latestScore, + }); + } + + return NextResponse.json({ error: 'Invalid parameters' }, { status: 400 }); + } catch (error) { + console.error('Error fetching wellness data:', error); + return NextResponse.json({ error: 'Failed to fetch wellness data' }, { status: 500 }); + } +} + +// POST /api/wellness — Submit daily check-in +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { schoolId, studentId, mood, sleepHours, sleepQuality, stressLevel, activityType, activityMinutes, mealsCount, waterGlasses, gratitudeEntry, notes } = body; + + if (!schoolId || !studentId || mood === undefined) { + return NextResponse.json({ error: 'schoolId, studentId, and mood are required' }, { status: 400 }); + } + + if (mood < 1 || mood > 5) { + return NextResponse.json({ error: 'Mood must be between 1 and 5' }, { status: 400 }); + } + + // Check for existing check-in today + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const existing = await db.wellnessCheckin.findFirst({ + where: { + schoolId, + studentId, + date: { gte: today, lt: tomorrow }, + }, + }); + + let checkin; + if (existing) { + checkin = await db.wellnessCheckin.update({ + where: { id: existing.id }, + data: { + mood, + sleepHours: sleepHours ?? null, + sleepQuality: sleepQuality ?? null, + stressLevel: stressLevel ?? null, + activityType: activityType ?? null, + activityMinutes: activityMinutes ?? null, + mealsCount: mealsCount ?? null, + waterGlasses: waterGlasses ?? null, + gratitudeEntry: gratitudeEntry ?? null, + notes: notes ?? null, + }, + }); + } else { + checkin = await db.wellnessCheckin.create({ + data: { + schoolId, + studentId, + date: new Date(), + mood, + sleepHours: sleepHours ?? null, + sleepQuality: sleepQuality ?? null, + stressLevel: stressLevel ?? null, + activityType: activityType ?? null, + activityMinutes: activityMinutes ?? null, + mealsCount: mealsCount ?? null, + waterGlasses: waterGlasses ?? null, + gratitudeEntry: gratitudeEntry ?? null, + notes: notes ?? null, + }, + }); + } + + // Calculate wellness score + const recentCheckins = await db.wellnessCheckin.findMany({ + where: { schoolId, studentId, date: { gte: new Date(Date.now() - 14 * 86400000) } }, + orderBy: { date: 'desc' }, + take: 14, + }); + + if (recentCheckins.length >= 1) { + const avgMood = recentCheckins.reduce((s, c) => s + c.mood, 0) / recentCheckins.length; + const sleepCheckins = recentCheckins.filter(c => c.sleepHours !== null); + const avgSleep = sleepCheckins.length > 0 ? sleepCheckins.reduce((s, c) => s + (c.sleepHours ?? 0), 0) / sleepCheckins.length : 7; + const stressCheckins = recentCheckins.filter(c => c.stressLevel !== null); + const avgStress = stressCheckins.length > 0 ? stressCheckins.reduce((s, c) => s + (c.stressLevel ?? 5), 0) / stressCheckins.length : 5; + const activityCheckins = recentCheckins.filter(c => c.activityMinutes !== null); + const avgActivity = activityCheckins.length > 0 ? activityCheckins.reduce((s, c) => s + (c.activityMinutes ?? 0), 0) / activityCheckins.length : 0; + + // Physical: based on sleep + activity + const physicalScore = Math.min(100, Math.max(0, + ((avgSleep >= 7 && avgSleep <= 9 ? 40 : avgSleep >= 6 ? 25 : 10) + + (avgActivity >= 30 ? 30 : avgActivity >= 15 ? 20 : 10) + + (avgMood >= 3 ? 30 : avgMood >= 2 ? 15 : 5)) + )); + + // Mental: based on mood + stress + const mentalScore = Math.min(100, Math.max(0, + ((avgMood / 5) * 50) + ((10 - avgStress) / 10) * 50 + )); + + // Social: based on mood (proxy) + const socialScore = Math.min(100, Math.max(0, (avgMood / 5) * 80 + 10)); + + // Academic: based on mood + stress + sleep + const academicScore = Math.min(100, Math.max(0, + ((avgMood / 5) * 35) + ((10 - avgStress) / 10) * 30 + ((avgSleep >= 7 ? 1 : avgSleep / 7) * 35) + )); + + const overallScore = Math.round((physicalScore * 0.25 + mentalScore * 0.3 + socialScore * 0.2 + academicScore * 0.25) * 10) / 10; + + // Upsert wellness score + const existingScore = await db.wellnessScore.findFirst({ + where: { schoolId, studentId, date: { gte: today, lt: tomorrow } }, + }); + + if (existingScore) { + await db.wellnessScore.update({ + where: { id: existingScore.id }, + data: { + overallScore, + physicalScore: Math.round(physicalScore * 10) / 10, + mentalScore: Math.round(mentalScore * 10) / 10, + socialScore: Math.round(socialScore * 10) / 10, + academicScore: Math.round(academicScore * 10) / 10, + }, + }); + } else { + await db.wellnessScore.create({ + data: { + schoolId, + studentId, + date: new Date(), + overallScore, + physicalScore: Math.round(physicalScore * 10) / 10, + mentalScore: Math.round(mentalScore * 10) / 10, + socialScore: Math.round(socialScore * 10) / 10, + academicScore: Math.round(academicScore * 10) / 10, + }, + }); + } + } + + return NextResponse.json({ checkin, success: true }); + } catch (error) { + console.error('Error saving wellness check-in:', error); + return NextResponse.json({ error: 'Failed to save check-in' }, { status: 500 }); + } +} diff --git a/src/app/api/wellness/summary/route.ts b/src/app/api/wellness/summary/route.ts new file mode 100644 index 0000000..a3c45f7 --- /dev/null +++ b/src/app/api/wellness/summary/route.ts @@ -0,0 +1,142 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/wellness/summary — Get wellness summary/stats +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const schoolId = searchParams.get('schoolId'); + const studentId = searchParams.get('studentId'); + const period = searchParams.get('period') || 'week'; // week or month + + if (!schoolId || !studentId) { + return NextResponse.json({ error: 'schoolId and studentId are required' }, { status: 400 }); + } + + const days = period === 'month' ? 30 : 7; + const startDate = new Date(Date.now() - days * 86400000); + + const checkins = await db.wellnessCheckin.findMany({ + where: { schoolId, studentId, date: { gte: startDate } }, + orderBy: { date: 'asc' }, + }); + + const scores = await db.wellnessScore.findMany({ + where: { schoolId, studentId, date: { gte: startDate } }, + orderBy: { date: 'asc' }, + }); + + if (checkins.length === 0) { + return NextResponse.json({ + type: 'summary', + period, + checkinCount: 0, + avgMood: 0, + avgSleepHours: 0, + avgSleepQuality: 0, + avgStressLevel: 0, + totalActivityMinutes: 0, + avgMealsCount: 0, + avgWaterGlasses: 0, + avgOverallScore: 0, + avgPhysicalScore: 0, + avgMentalScore: 0, + avgSocialScore: 0, + avgAcademicScore: 0, + gratitudeEntries: [], + moodDistribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }, + stressDistribution: {}, + recommendations: [], + }); + } + + const avgMood = checkins.reduce((s, c) => s + c.mood, 0) / checkins.length; + const sleepCheckins = checkins.filter(c => c.sleepHours !== null); + const avgSleepHours = sleepCheckins.length > 0 ? sleepCheckins.reduce((s, c) => s + (c.sleepHours ?? 0), 0) / sleepCheckins.length : 0; + const sleepQualityCheckins = checkins.filter(c => c.sleepQuality !== null); + const avgSleepQuality = sleepQualityCheckins.length > 0 ? sleepQualityCheckins.reduce((s, c) => s + (c.sleepQuality ?? 0), 0) / sleepQualityCheckins.length : 0; + const stressCheckins = checkins.filter(c => c.stressLevel !== null); + const avgStressLevel = stressCheckins.length > 0 ? stressCheckins.reduce((s, c) => s + (c.stressLevel ?? 0), 0) / stressCheckins.length : 0; + const activityCheckins = checkins.filter(c => c.activityMinutes !== null); + const totalActivityMinutes = activityCheckins.reduce((s, c) => s + (c.activityMinutes ?? 0), 0); + const mealsCheckins = checkins.filter(c => c.mealsCount !== null); + const avgMealsCount = mealsCheckins.length > 0 ? mealsCheckins.reduce((s, c) => s + (c.mealsCount ?? 0), 0) / mealsCheckins.length : 0; + const waterCheckins = checkins.filter(c => c.waterGlasses !== null); + const avgWaterGlasses = waterCheckins.length > 0 ? waterCheckins.reduce((s, c) => s + (c.waterGlasses ?? 0), 0) / waterCheckins.length : 0; + + const avgOverallScore = scores.length > 0 ? scores.reduce((s, sc) => s + sc.overallScore, 0) / scores.length : 0; + const avgPhysicalScore = scores.length > 0 ? scores.filter(s => s.physicalScore !== null).reduce((s, sc) => s + (sc.physicalScore ?? 0), 0) / scores.length : 0; + const avgMentalScore = scores.length > 0 ? scores.filter(s => s.mentalScore !== null).reduce((s, sc) => s + (sc.mentalScore ?? 0), 0) / scores.length : 0; + const avgSocialScore = scores.length > 0 ? scores.filter(s => s.socialScore !== null).reduce((s, sc) => s + (sc.socialScore ?? 0), 0) / scores.length : 0; + const avgAcademicScore = scores.length > 0 ? scores.filter(s => s.academicScore !== null).reduce((s, sc) => s + (sc.academicScore ?? 0), 0) / scores.length : 0; + + // Mood distribution + const moodDistribution: Record = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }; + checkins.forEach(c => { moodDistribution[c.mood] = (moodDistribution[c.mood] || 0) + 1; }); + + // Stress distribution + const stressDistribution: Record = {}; + stressCheckins.forEach(c => { + const level = c.stressLevel ?? 0; + stressDistribution[level] = (stressDistribution[level] || 0) + 1; + }); + + // Gratitude entries + const gratitudeEntries = checkins.filter(c => c.gratitudeEntry).map(c => ({ + date: c.date, + entry: c.gratitudeEntry, + })); + + // AI recommendations based on patterns + const recommendations: string[] = []; + if (avgSleepHours < 7) { + recommendations.push('Try to get at least 7-8 hours of sleep per night for better recovery.'); + } + if (avgStressLevel > 6) { + recommendations.push('Consider stress-reduction techniques like deep breathing, meditation, or talking to a counselor.'); + } + if (totalActivityMinutes < 150) { + recommendations.push('Aim for at least 30 minutes of physical activity most days of the week.'); + } + if (avgMood < 3) { + recommendations.push('Your mood has been consistently low. Consider reaching out to a trusted adult or counselor.'); + } + if (avgWaterGlasses < 6) { + recommendations.push('Try to drink at least 6-8 glasses of water daily for better hydration.'); + } + if (avgMealsCount < 3) { + recommendations.push('Regular meals are important for energy and concentration. Try to eat at least 3 meals per day.'); + } + if (avgSleepQuality < 3) { + recommendations.push('Improve sleep quality by reducing screen time before bed and maintaining a consistent sleep schedule.'); + } + if (recommendations.length === 0) { + recommendations.push('Great job! Your wellness indicators are looking good. Keep up the healthy habits!'); + } + + return NextResponse.json({ + type: 'summary', + period, + checkinCount: checkins.length, + avgMood: Math.round(avgMood * 10) / 10, + avgSleepHours: Math.round(avgSleepHours * 10) / 10, + avgSleepQuality: Math.round(avgSleepQuality * 10) / 10, + avgStressLevel: Math.round(avgStressLevel * 10) / 10, + totalActivityMinutes, + avgMealsCount: Math.round(avgMealsCount * 10) / 10, + avgWaterGlasses: Math.round(avgWaterGlasses * 10) / 10, + avgOverallScore: Math.round(avgOverallScore * 10) / 10, + avgPhysicalScore: Math.round(avgPhysicalScore * 10) / 10, + avgMentalScore: Math.round(avgMentalScore * 10) / 10, + avgSocialScore: Math.round(avgSocialScore * 10) / 10, + avgAcademicScore: Math.round(avgAcademicScore * 10) / 10, + gratitudeEntries, + moodDistribution, + stressDistribution, + recommendations, + }); + } catch (error) { + console.error('Error fetching wellness summary:', error); + return NextResponse.json({ error: 'Failed to fetch wellness summary' }, { status: 500 }); + } +} diff --git a/src/app/api/wellness/trends/route.ts b/src/app/api/wellness/trends/route.ts new file mode 100644 index 0000000..48f2c8f --- /dev/null +++ b/src/app/api/wellness/trends/route.ts @@ -0,0 +1,141 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/wellness/trends — Get wellness trends over time +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const schoolId = searchParams.get('schoolId'); + const studentId = searchParams.get('studentId'); + const days = parseInt(searchParams.get('days') || '30'); + const classGroupId = searchParams.get('classGroupId'); + const includeClassAvg = searchParams.get('includeClassAvg') === 'true'; + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + const startDate = new Date(Date.now() - days * 86400000); + + if (studentId) { + // Individual student trends + const checkins = await db.wellnessCheckin.findMany({ + where: { schoolId, studentId, date: { gte: startDate } }, + orderBy: { date: 'asc' }, + }); + + const scores = await db.wellnessScore.findMany({ + where: { schoolId, studentId, date: { gte: startDate } }, + orderBy: { date: 'asc' }, + }); + + // Build daily data points + const trendData = []; + for (let i = 0; i < days; i++) { + const dayDate = new Date(startDate); + dayDate.setDate(dayDate.getDate() + i); + const dayStr = dayDate.toISOString().split('T')[0]; + + const dayCheckin = checkins.find(c => c.date.toISOString().split('T')[0] === dayStr); + const dayScore = scores.find(s => s.date.toISOString().split('T')[0] === dayStr); + + trendData.push({ + date: dayStr, + mood: dayCheckin?.mood ?? null, + sleepHours: dayCheckin?.sleepHours ?? null, + sleepQuality: dayCheckin?.sleepQuality ?? null, + stressLevel: dayCheckin?.stressLevel ?? null, + activityMinutes: dayCheckin?.activityMinutes ?? null, + overallScore: dayScore?.overallScore ?? null, + physicalScore: dayScore?.physicalScore ?? null, + mentalScore: dayScore?.mentalScore ?? null, + socialScore: dayScore?.socialScore ?? null, + academicScore: dayScore?.academicScore ?? null, + }); + } + + // Class average for comparison (anonymized) + let classAvgData: Array<{ date: string; avgOverall: number; avgMood: number; avgStress: number }> = []; + if (includeClassAvg && classGroupId) { + const enrollments = await db.enrollment.findMany({ + where: { classGroupId, endDate: null }, + select: { studentId: true }, + }); + const classStudentIds = enrollments.map(e => e.studentId).filter(id => id !== studentId); + + if (classStudentIds.length > 0) { + const classScores = await db.wellnessScore.findMany({ + where: { schoolId, studentId: { in: classStudentIds }, date: { gte: startDate } }, + orderBy: { date: 'asc' }, + }); + const classCheckins = await db.wellnessCheckin.findMany({ + where: { schoolId, studentId: { in: classStudentIds }, date: { gte: startDate } }, + orderBy: { date: 'asc' }, + }); + + for (let i = 0; i < days; i++) { + const dayDate = new Date(startDate); + dayDate.setDate(dayDate.getDate() + i); + const dayStr = dayDate.toISOString().split('T')[0]; + + const dayScores = classScores.filter(s => s.date.toISOString().split('T')[0] === dayStr); + const dayCheckins = classCheckins.filter(c => c.date.toISOString().split('T')[0] === dayStr); + + if (dayScores.length > 0 || dayCheckins.length > 0) { + classAvgData.push({ + date: dayStr, + avgOverall: dayScores.length > 0 ? Math.round(dayScores.reduce((s, sc) => s + sc.overallScore, 0) / dayScores.length * 10) / 10 : 0, + avgMood: dayCheckins.length > 0 ? Math.round(dayCheckins.reduce((s, c) => s + c.mood, 0) / dayCheckins.length * 10) / 10 : 0, + avgStress: dayCheckins.filter(c => c.stressLevel !== null).length > 0 ? Math.round(dayCheckins.filter(c => c.stressLevel !== null).reduce((s, c) => s + (c.stressLevel ?? 0), 0) / dayCheckins.filter(c => c.stressLevel !== null).length * 10) / 10 : 0, + }); + } + } + } + } + + return NextResponse.json({ + type: 'trends', + studentId, + days, + trendData, + classAvgData, + }); + } + + // School-wide trends (for admin) + const allScores = await db.wellnessScore.findMany({ + where: { schoolId, date: { gte: startDate } }, + orderBy: { date: 'asc' }, + }); + + const schoolTrendData = []; + for (let i = 0; i < days; i++) { + const dayDate = new Date(startDate); + dayDate.setDate(dayDate.getDate() + i); + const dayStr = dayDate.toISOString().split('T')[0]; + + const dayScores = allScores.filter(s => s.date.toISOString().split('T')[0] === dayStr); + + if (dayScores.length > 0) { + schoolTrendData.push({ + date: dayStr, + avgOverall: Math.round(dayScores.reduce((s, sc) => s + sc.overallScore, 0) / dayScores.length * 10) / 10, + avgPhysical: Math.round(dayScores.filter(s => s.physicalScore !== null).reduce((s, sc) => s + (sc.physicalScore ?? 0), 0) / dayScores.length * 10) / 10, + avgMental: Math.round(dayScores.filter(s => s.mentalScore !== null).reduce((s, sc) => s + (sc.mentalScore ?? 0), 0) / dayScores.length * 10) / 10, + avgSocial: Math.round(dayScores.filter(s => s.socialScore !== null).reduce((s, sc) => s + (sc.socialScore ?? 0), 0) / dayScores.length * 10) / 10, + avgAcademic: Math.round(dayScores.filter(s => s.academicScore !== null).reduce((s, sc) => s + (sc.academicScore ?? 0), 0) / dayScores.length * 10) / 10, + studentCount: dayScores.length, + }); + } + } + + return NextResponse.json({ + type: 'school_trends', + days, + trendData: schoolTrendData, + }); + } catch (error) { + console.error('Error fetching wellness trends:', error); + return NextResponse.json({ error: 'Failed to fetch wellness trends' }, { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 5f2254f..81cd15d 100755 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -876,6 +876,168 @@ transform: translateY(0px) scale(0.99); } + /* ─── New Utility Classes (Round 24) ──────────────────────────────── */ + + /* .animate-pulse-slow — Slower pulse animation */ + @keyframes pulse-slow { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.7; transform: scale(1.05); } + } + .animate-pulse-slow { + animation: pulse-slow 3s ease-in-out infinite; + } + + /* .hover-lift — Hover lift effect */ + .hover-lift { + transition: transform 0.2s ease-out, box-shadow 0.2s ease-out; + } + .hover-lift:hover { + transform: translateY(-3px); + box-shadow: 0 12px 28px -8px oklch(0.55 0.17 155 / 0.2); + } + .dark .hover-lift:hover { + box-shadow: 0 12px 28px -8px oklch(0.65 0.17 155 / 0.3); + } + + /* .gradient-border — Border with gradient */ + .gradient-border { + position: relative; + border: none; + background-clip: padding-box; + } + .gradient-border::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + margin: -1.5px; + border-radius: inherit; + background: linear-gradient(135deg, oklch(0.55 0.17 155), oklch(0.55 0.14 175), oklch(0.65 0.22 330)); + } + .dark .gradient-border::before { + background: linear-gradient(135deg, oklch(0.65 0.17 155), oklch(0.65 0.14 175), oklch(0.75 0.22 330)); + } + + /* .pattern-dots — Dot pattern background */ + .pattern-dots { + background-image: radial-gradient(circle, oklch(0.55 0.17 155 / 0.12) 1px, transparent 1px); + background-size: 20px 20px; + } + .dark .pattern-dots { + background-image: radial-gradient(circle, oklch(0.65 0.17 155 / 0.15) 1px, transparent 1px); + background-size: 20px 20px; + } + + /* .pattern-grid — Grid pattern background */ + .pattern-grid { + background-image: + linear-gradient(oklch(0.55 0.17 155 / 0.05) 1px, transparent 1px), + linear-gradient(90deg, oklch(0.55 0.17 155 / 0.05) 1px, transparent 1px); + background-size: 32px 32px; + } + .dark .pattern-grid { + background-image: + linear-gradient(oklch(0.65 0.17 155 / 0.06) 1px, transparent 1px), + linear-gradient(90deg, oklch(0.65 0.17 155 / 0.06) 1px, transparent 1px); + background-size: 32px 32px; + } + + /* @keyframes slideInUp — Slide in from bottom */ + @keyframes slideInUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } + } + .animate-slide-in-up { + animation: slideInUp 0.4s ease-out; + } + + /* @keyframes scaleIn — Scale in from center */ + @keyframes scaleIn { + from { opacity: 0; transform: scale(0.9); } + to { opacity: 1; transform: scale(1); } + } + .animate-scale-in { + animation: scaleIn 0.3s ease-out; + } + + /* @keyframes fadeInScale — Fade + scale combined */ + @keyframes fadeInScale { + from { opacity: 0; transform: scale(0.95) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } + } + .animate-fade-in-scale { + animation: fadeInScale 0.4s ease-out; + } + + /* Notification badge pulse */ + @keyframes badge-pulse { + 0%, 100% { box-shadow: 0 0 0 0 oklch(0.55 0.17 155 / 0.4); } + 50% { box-shadow: 0 0 0 6px oklch(0.55 0.17 155 / 0); } + } + .animate-badge-pulse { + animation: badge-pulse 2s ease-in-out infinite; + } + .dark .animate-badge-pulse { + animation-name: badge-pulse-dark; + } + @keyframes badge-pulse-dark { + 0%, 100% { box-shadow: 0 0 0 0 oklch(0.65 0.17 155 / 0.5); } + 50% { box-shadow: 0 0 0 6px oklch(0.65 0.17 155 / 0); } + } + + /* Animated gradient header banner */ + @keyframes header-gradient-shift { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } + } + .animate-header-gradient { + background-size: 200% 200%; + animation: header-gradient-shift 6s ease infinite; + } + + /* Timeline connector line for activity entries */ + .timeline-connector { + position: relative; + } + .timeline-connector::before { + content: ''; + position: absolute; + left: 15px; + top: 36px; + bottom: -8px; + width: 2px; + background: oklch(0.90 0.01 155); + } + .dark .timeline-connector::before { + background: oklch(0.25 0.015 260); + } + .timeline-connector:last-child::before { + display: none; + } + + /* Input validation states */ + .input-valid { + border-color: oklch(0.55 0.17 155) !important; + box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.12); + } + .input-invalid { + border-color: oklch(0.577 0.245 27.325) !important; + box-shadow: 0 0 0 3px oklch(0.577 0.245 27.325 / 0.12); + } + + /* Social login button hover */ + .social-btn { + transition: all 0.2s ease-out; + } + .social-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px oklch(0 0 0 / 0.1); + } + .social-btn:active { + transform: translateY(0) scale(0.98); + } + /* ─── Danger Zone Styling ────────────────────────────────────────── */ .danger-zone { border: 2px dashed oklch(0.55 0.22 25 / 0.4); diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index 917efec..de12717 100755 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -68,6 +68,8 @@ import { Timer, LayoutGrid as LayoutGridIcon, BookOpen as BookOpenIconNav, + Database, + PartyPopper, } from 'lucide-react'; import { Sidebar, @@ -182,6 +184,9 @@ import StudentStudyPlannerView from './student-study-planner-view'; import ReportCardView from './report-card-view'; import SchoolLibraryView from './school-library-view'; import SeatingChartView from './seating-chart-view'; +import StudentWellnessView from './student-wellness-view'; +import DataImportExportView from './data-import-export-view'; +import SchoolEventsView from './school-events-view'; // Dynamic imports for heavy components with loading skeletons const AnalyticsView = dynamic(() => import('./analytics-view'), { @@ -251,8 +256,11 @@ const navSections: NavSection[] = [ { key: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' }, { key: 'seating-chart', icon: LayoutGridIcon, labelKey: 'nav.seating_chart' }, { key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' }, + { key: 'student-wellness', icon: Heart, labelKey: 'nav.student-wellness' }, { key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' }, { key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' }, + { key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' }, + { key: 'data-import-export', icon: Database, labelKey: 'nav.data-import-export' }, ], }, { @@ -293,8 +301,10 @@ const studentNavSections: NavSection[] = [ { key: 'seating-chart', icon: LayoutGridIcon, labelKey: 'nav.seating_chart' }, { key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' }, { key: 'student-study-planner', icon: Timer, labelKey: 'study_planner.title' }, + { key: 'student-wellness', icon: Heart, labelKey: 'nav.student-wellness' }, { key: 'resources', icon: FolderOpen, labelKey: 'nav.resources' }, { key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' }, + { key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' }, { key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' }, { key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' }, { key: 'settings', icon: Settings, labelKey: 'nav.settings' }, @@ -321,6 +331,7 @@ const parentNavSections: NavSection[] = [ { key: 'student-study-planner', icon: Timer, labelKey: 'study_planner.title' }, { key: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' }, { key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' }, + { key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' }, { key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' }, { key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' }, ], @@ -471,6 +482,9 @@ function renderView(view: ViewName) { case 'seating-chart': return ; case 'school-library': return ; case 'report-cards': return ; + case 'student-wellness': return ; + case 'data-import-export': return ; + case 'school-events': return ; default: return ; } } diff --git a/src/components/auth-view.tsx b/src/components/auth-view.tsx index c2d0407..600fa36 100755 --- a/src/components/auth-view.tsx +++ b/src/components/auth-view.tsx @@ -2,11 +2,12 @@ import React, { useState, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { BookOpen, Mail, Lock, User, ArrowRight, GraduationCap, Heart, Sparkles, Flower2, Eye, EyeOff, KeyRound, Info, Shield, Leaf, Users as UsersIcon, HelpCircle, BarChart3, LockKeyhole, GitBranch } from 'lucide-react'; +import { BookOpen, Mail, Lock, User, ArrowRight, GraduationCap, Heart, Sparkles, Flower2, Eye, EyeOff, KeyRound, Info, Shield, Leaf, Users as UsersIcon, HelpCircle, BarChart3, LockKeyhole, GitBranch, CheckCircle2, XCircle, Globe, Github, Chrome } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; import { useAppStore } from '@/lib/store'; @@ -338,8 +339,19 @@ export default function AuthView() { const [showPassword, setShowPassword] = useState(false); const [rememberMe, setRememberMe] = useState(true); const [showForgotInfo, setShowForgotInfo] = useState(false); + const [showForgotModal, setShowForgotModal] = useState(false); + const [forgotEmail, setForgotEmail] = useState(''); + const [forgotSent, setForgotSent] = useState(false); const setCurrentUser = useAppStore((s) => s.setCurrentUser); + // Input validation state + const emailValid = email.length > 0 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + const emailTouched = email.length > 0; + const passwordValid = password.length >= 6; + const passwordTouched = password.length > 0; + const firstNameValid = firstName.length > 0; + const lastNameValid = lastName.length > 0; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); @@ -591,12 +603,32 @@ export default function AuthView() {
- setFirstName(e.target.value)} className="pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm" placeholder={t('auth.firstName')} required /> + setFirstName(e.target.value)} className={`pl-10 pr-9 h-12 min-h-[44px] bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm transition-all duration-200 ${ + firstName.length > 0 && firstNameValid ? 'input-valid border-emerald-400 dark:border-emerald-500' + : firstName.length > 0 && !firstNameValid ? 'input-invalid border-red-300 dark:border-red-700' + : 'border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20' + }`} placeholder={t('auth.firstName')} required /> + {firstName.length > 0 && ( + + {firstNameValid ? : } + + )}
- setLastName(e.target.value)} className="h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm" placeholder={t('auth.lastName')} required /> +
+ setLastName(e.target.value)} className={`pr-9 h-12 min-h-[44px] bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm transition-all duration-200 ${ + lastName.length > 0 && lastNameValid ? 'input-valid border-emerald-400 dark:border-emerald-500' + : lastName.length > 0 && !lastNameValid ? 'input-invalid border-red-300 dark:border-red-700' + : 'border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20' + }`} placeholder={t('auth.lastName')} required /> + {lastName.length > 0 && ( + + {lastNameValid ? : } + + )} +
@@ -624,7 +656,7 @@ export default function AuthView() { )} - {/* Email field (always shown) */} + {/* Email field (always shown) with validation feedback */}
@@ -634,7 +666,11 @@ export default function AuthView() { type="email" value={email} onChange={(e) => setEmail(e.target.value)} - className={`pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm transition-all duration-200 ${ + className={`pl-10 pr-10 h-12 min-h-[44px] text-base bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm transition-all duration-200 ${ + emailTouched && emailValid ? 'input-valid border-emerald-400 dark:border-emerald-500 focus:border-emerald-500 focus:ring-emerald-400/20' + : emailTouched && !emailValid ? 'input-invalid border-red-300 dark:border-red-700 focus:border-red-400 focus:ring-red-400/20' + : 'border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20' + } ${ loginRole === 'student' ? 'focus:border-amber-400 focus:ring-amber-400/20' : loginRole === 'parent' ? 'focus:border-violet-400 focus:ring-violet-400/20' : '' @@ -642,17 +678,32 @@ export default function AuthView() { placeholder={loginRole === 'student' ? 'name@schule.de' : loginRole === 'parent' ? 'parent@email.de' : 'name@schule.de'} required /> + {/* Validation icon */} + {emailTouched && ( + + {emailValid ? ( + + ) : ( + + )} + + )}
- {/* Password field */} + {/* Password field with validation feedback */}
{mode === 'login' && ( setShowForgotInfo(!showForgotInfo)} + onClick={() => setShowForgotModal(true)} className="text-xs text-emerald-600 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 font-medium transition-colors" whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} @@ -668,15 +719,34 @@ export default function AuthView() { type={showPassword ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} - className="pl-9 pr-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm transition-all duration-200" + className={`pl-9 pr-12 h-12 min-h-[44px] bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm transition-all duration-200 ${ + passwordTouched && passwordValid ? 'input-valid border-emerald-400 dark:border-emerald-500 focus:border-emerald-500 focus:ring-emerald-400/20' + : passwordTouched && !passwordValid ? 'input-invalid border-red-300 dark:border-red-700 focus:border-red-400 focus:ring-red-400/20' + : 'border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20' + }`} placeholder="••••••" minLength={6} required /> + {/* Validation icon */} + {passwordTouched && ( + + {passwordValid ? ( + + ) : ( + + )} + + )} setShowPassword(!showPassword)} - className="absolute right-3.5 top-1/2 -translate-y-1/2 text-emerald-400 hover:text-emerald-600 dark:hover:text-emerald-300 transition-colors w-10 h-10 flex items-center justify-center" + className="absolute right-3.5 top-1/2 -translate-y-1/2 text-emerald-400 hover:text-emerald-600 dark:hover:text-emerald-300 transition-colors w-6 h-6 flex items-center justify-center" tabIndex={-1} aria-label={showPassword ? t('polish.hide_password') : t('polish.show_password')} whileHover={{ scale: 1.1 }} @@ -686,6 +756,30 @@ export default function AuthView() {
+ {/* Password strength indicator (register mode) */} + {mode === 'register' && passwordTouched && ( + +
+ {[1, 2, 3, 4].map((level) => ( +
= level * 2 + ? level <= 1 ? 'bg-red-400' + : level <= 2 ? 'bg-amber-400' + : level <= 3 ? 'bg-emerald-400' + : 'bg-teal-400' + : 'bg-gray-200 dark:bg-gray-700' + }`} + /> + ))} +
+

+ {password.length < 4 ? t('auth.password_weak') || 'Weak' : password.length < 6 ? t('auth.password_fair') || 'Fair' : password.length < 8 ? t('auth.password_good') || 'Good' : t('auth.password_strong') || 'Strong'} +

+ + )} + {/* Forgot Password info */} {showForgotInfo && ( @@ -859,6 +953,54 @@ export default function AuthView() { + {/* Social login buttons (visual only) */} + {mode === 'login' && ( + +
+
+ {t('auth.or_continue_with') || 'or continue with'} +
+
+
+ toast.info(t('auth.social_coming_soon') || 'Social login coming soon')} + > + + Google + + toast.info(t('auth.social_coming_soon') || 'Social login coming soon')} + > + + GitHub + + toast.info(t('auth.social_coming_soon') || 'Social login coming soon')} + > + + Moodle + +
+ + )} + {/* Features section below form */} @@ -873,6 +1015,87 @@ export default function AuthView() {

{t('app.tagline')}

+ + {/* Forgot Password Modal */} + + + + +
+ +
+ {t('auth.forgot_password')} +
+ + {t('auth.forgot_password_desc') || 'Enter your email address and we will send you a link to reset your password.'} + +
+
+ {forgotSent ? ( + +
+ +
+

{t('auth.forgot_email_sent') || 'Reset link sent!'}

+

{t('auth.forgot_check_inbox') || 'Check your inbox for the password reset link.'}

+
+ ) : ( + <> +
+ + setForgotEmail(e.target.value)} + className="pl-10 h-12 border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm" + placeholder="name@schule.de" + type="email" + /> +
+ {/* Demo credentials reminder */} +
+ +

+ {t('polish.demo_email')}: demo@competencetrack.org / {t('polish.demo_password')}: Demo2025! +

+
+ + )} +
+ + {forgotSent ? ( + + ) : ( + <> + + + + )} + +
+
); } diff --git a/src/components/dashboard-view.tsx b/src/components/dashboard-view.tsx index 3f48a78..3885026 100755 --- a/src/components/dashboard-view.tsx +++ b/src/components/dashboard-view.tsx @@ -1868,80 +1868,86 @@ export default function DashboardView() { > {/* ===== WELCOME HEADER ===== */} -
- {/* Top row: welcome + date */} -
-
- - - -
-

- {getTimeGreeting()}, {currentUser?.firstName}! -

-

{t('dashboard.overview')}

+ {/* Animated gradient header banner */} +
+ {/* Decorative pattern overlay */} +
+ {/* Decorative floating shapes */} +
+
+
+ +
+ + + +
+

+ {getTimeGreeting()}, {currentUser?.firstName}! +

+

{t('dashboard.overview')}

+
+
+ + + {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + })} + +
+
+ + {currentTime} +
-
-
- - {t('polish.today_date')}: - - {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { - weekday: 'long', - day: 'numeric', - month: 'long', - year: 'numeric', - })} - -
-
- - {t('polish.last_login')}: - - {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { - day: '2-digit', - month: 'short', - })}{' '} - {new Date().toLocaleTimeString(locale === 'de' ? 'de-DE' : 'en-GB', { - hour: '2-digit', - minute: '2-digit', - })} - -
+
+
- - {/* Tip of the day — enhanced with gradient border */} - -
-
- - - -

- {t('dashboard.daily_tip')}:{' '} - {dailyTip.text} -

-
-
+ {/* Tip of the day — enhanced with gradient border */} + + +
+
+ + + +

+ {t('dashboard.daily_tip')}:{' '} + {dailyTip.text} +

+
+ + + {/* ===== QUICK STATS SUMMARY ===== */}
@@ -2041,7 +2047,7 @@ export default function DashboardView() { - + {/* Gradient top border */}
@@ -2617,6 +2623,105 @@ export default function DashboardView() {
+ {/* ===== QUICK LINKS SECTION ===== */} + +
+ {[ + { icon: BookOpen, label: t('nav.classes'), view: 'classes' as ViewName, color: 'from-emerald-400 to-teal-500', bg: 'bg-emerald-50 dark:bg-emerald-900/20', border: 'border-emerald-200/40 dark:border-emerald-900/30' }, + { icon: Flower2, label: t('nav.flower'), view: 'flower' as ViewName, color: 'from-teal-400 to-emerald-500', bg: 'bg-teal-50 dark:bg-teal-900/20', border: 'border-teal-200/40 dark:border-teal-900/30' }, + { icon: ClipboardCheck, label: t('nav.assessments'), view: 'assessments' as ViewName, color: 'from-amber-400 to-orange-500', bg: 'bg-amber-50 dark:bg-amber-900/20', border: 'border-amber-200/40 dark:border-amber-900/30' }, + { icon: BarChart3, label: t('nav.grading'), view: 'grading' as ViewName, color: 'from-violet-400 to-purple-500', bg: 'bg-violet-50 dark:bg-violet-900/20', border: 'border-violet-200/40 dark:border-violet-900/30' }, + { icon: FileText, label: t('nav.reports'), view: 'reports' as ViewName, color: 'from-rose-400 to-pink-500', bg: 'bg-rose-50 dark:bg-rose-900/20', border: 'border-rose-200/40 dark:border-rose-900/30' }, + { icon: UserCheck, label: t('nav.attendance'), view: 'attendance' as ViewName, color: 'from-teal-400 to-cyan-500', bg: 'bg-teal-50 dark:bg-teal-900/20', border: 'border-teal-200/40 dark:border-teal-900/30' }, + ].map((link, i) => ( + setCurrentView(link.view)} + whileHover={{ scale: 1.05, y: -3 }} + whileTap={{ scale: 0.97 }} + transition={{ duration: 0.18, ease: 'easeOut' }} + initial={{ opacity: 0, y: 12 }} + animate={{ opacity: 1, y: 0 }} + className={`group flex flex-col items-center gap-2.5 p-4 rounded-xl ${link.bg} border ${link.border} hover-lift transition-shadow duration-200 hover:shadow-md`} + > +
+ +
+ {link.label} +
+ ))} +
+
+ + {/* ===== TODAY'S SCHEDULE MINI-CALENDAR ===== */} + + + +
+ +
+ +
+ {locale === 'de' ? 'Heutiger Stundenplan' : "Today's Schedule"} +
+ + {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'short', day: 'numeric', month: 'short' })} + +
+
+ +
+ {[ + { time: '08:00', subject: t('student.notebook_math'), room: 'R-101', status: 'completed', color: 'from-amber-400 to-orange-500' }, + { time: '09:00', subject: t('student.notebook_german'), room: 'R-203', status: 'completed', color: 'from-emerald-400 to-teal-500' }, + { time: '10:00', subject: t('student.notebook_english'), room: 'R-105', status: 'current', color: 'from-violet-400 to-purple-500' }, + { time: '11:00', subject: t('student.notebook_science'), room: 'Lab-1', status: 'upcoming', color: 'from-teal-400 to-cyan-500' }, + { time: '12:00', subject: locale === 'de' ? 'Sport' : 'PE', room: 'Gym', status: 'upcoming', color: 'from-rose-400 to-pink-500' }, + ].map((slot, i) => ( + + + {slot.time} + +
+ +
+
+

+ {slot.subject} +

+

{slot.room}

+
+ {slot.status === 'current' && ( + + )} + {slot.status === 'completed' && ( + + )} + + ))} +
+
+
+
+ {/* ===== CLASS OVERVIEW MINI CARD GRID ===== */} @@ -3036,7 +3141,7 @@ export default function DashboardView() {

{!n.read && ( - + {locale === 'de' ? 'Neu' : 'New'} )} diff --git a/src/components/data-import-export-view.tsx b/src/components/data-import-export-view.tsx new file mode 100644 index 0000000..0d47c1c --- /dev/null +++ b/src/components/data-import-export-view.tsx @@ -0,0 +1,1526 @@ +'use client'; + +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Database, + Upload, + Download, + History, + Trash2, + FileText, + FileSpreadsheet, + AlertCircle, + CheckCircle2, + XCircle, + Loader2, + ChevronRight, + ChevronDown, + MapPin, + Eye, + Play, + RefreshCw, + Archive, + Shield, + Users, + GraduationCap, + ClipboardCheck, + CalendarCheck, + BarChart3, + X, + Filter, + Calendar, + Clock, + HardDrive, + Sparkles, + Info, +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Separator } from '@/components/ui/separator'; +import { Checkbox } from '@/components/ui/checkbox'; +import { useAppStore } from '@/lib/store'; +import { t } from '@/lib/i18n'; +import { toast } from 'sonner'; + +// ── Types ────────────────────────────────────────────────────────── + +interface ImportJob { + id: string; + type: string; + fileName: string; + fileSize: number; + status: string; + totalRows: number; + successRows: number; + errorRows: number; + errors: string | null; + createdAt: string; +} + +interface ExportJob { + id: string; + type: string; + format: string; + fileName: string | null; + fileSize: number; + status: string; + fileData: string | null; + createdAt: string; +} + +interface DbStatistics { + students: number; + teachers: number; + grades: number; + attendance: number; + competencies: number; + classes: number; + subjects: number; + assessments: number; + reports: number; + total: number; +} + +interface CleanupInfo { + orphanedStudents: number; + orphanedEnrollments: number; + totalOrphans: number; +} + +interface ParsedData { + headers: string[]; + rows: Record[]; +} + +// ── Column mapping schema per import type ────────────────────────── + +const IMPORT_FIELD_SCHEMAS: Record = { + STUDENT: [ + { value: 'firstName', label: 'First Name', required: true }, + { value: 'lastName', label: 'Last Name', required: true }, + { value: 'externalId', label: 'External ID', required: false }, + { value: 'dateOfBirth', label: 'Date of Birth', required: false }, + { value: 'email', label: 'Email', required: false }, + ], + TEACHER: [ + { value: 'firstName', label: 'First Name', required: true }, + { value: 'lastName', label: 'Last Name', required: true }, + { value: 'email', label: 'Email', required: true }, + { value: 'role', label: 'Role', required: false }, + ], + GRADE: [ + { value: 'studentId', label: 'Student ID', required: true }, + { value: 'assessmentId', label: 'Assessment ID', required: true }, + { value: 'score', label: 'Score', required: false }, + { value: 'note', label: 'Note', required: false }, + ], + ATTENDANCE: [ + { value: 'studentId', label: 'Student ID', required: true }, + { value: 'date', label: 'Date', required: true }, + { value: 'status', label: 'Status', required: false }, + { value: 'sessionId', label: 'Session ID', required: false }, + ], + COMPETENCY: [ + { value: 'studentId', label: 'Student ID', required: true }, + { value: 'competencyId', label: 'Competency ID', required: true }, + { value: 'level', label: 'Level', required: false }, + { value: 'note', label: 'Note', required: false }, + ], +}; + +// ── Animated Counter ─────────────────────────────────────────────── + +function AnimatedCounter({ value, duration = 1000 }: { value: number; duration?: number }) { + const [display, setDisplay] = useState(0); + const prevRef = useRef(0); + + useEffect(() => { + const start = prevRef.current; + const diff = value - start; + if (diff === 0) { setDisplay(value); return; } + const startTime = performance.now(); + let raf: number; + const animate = (now: number) => { + const elapsed = now - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + setDisplay(Math.round(start + diff * eased)); + if (progress < 1) raf = requestAnimationFrame(animate); + }; + raf = requestAnimationFrame(animate); + prevRef.current = value; + return () => cancelAnimationFrame(raf); + }, [value, duration]); + + return {display.toLocaleString()}; +} + +// ── Status Badge ─────────────────────────────────────────────────── + +function StatusBadge({ status }: { status: string }) { + const config: Record = { + pending: { color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300', icon: Clock, label: t('data_import_export.status_pending') }, + processing: { color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', icon: Loader2, label: t('data_import_export.status_processing') }, + completed: { color: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300', icon: CheckCircle2, label: t('data_import_export.status_completed') }, + failed: { color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', icon: XCircle, label: t('data_import_export.status_failed') }, + }; + const c = config[status] || config.pending; + const Icon = c.icon; + return ( + + + {c.label} + + ); +} + +// ── Format file size ────────────────────────────────────────────── + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +// ── Main Component ───────────────────────────────────────────────── + +export default function DataImportExportView() { + const currentUser = useAppStore((s) => s.currentUser); + const [activeTab, setActiveTab] = useState('import'); + const [importType, setImportType] = useState('STUDENT'); + const [exportType, setExportType] = useState('STUDENT'); + const [exportFormat, setExportFormat] = useState('CSV'); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [classFilter, setClassFilter] = useState(''); + const [subjectFilter, setSubjectFilter] = useState(''); + const [includeHeaders, setIncludeHeaders] = useState(true); + const [includeMetadata, setIncludeMetadata] = useState(false); + + // Import state + const [dragActive, setDragActive] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [parsedData, setParsedData] = useState(null); + const [columnMapping, setColumnMapping] = useState>({}); + const [importing, setImporting] = useState(false); + const [importProgress, setImportProgress] = useState(0); + const [importResult, setImportResult] = useState | null>(null); + + // Export state + const [exporting, setExporting] = useState(false); + const [exportResult, setExportResult] = useState | null>(null); + + // History state + const [importJobs, setImportJobs] = useState([]); + const [exportJobs, setExportJobs] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + + // Management state + const [dbStats, setDbStats] = useState(null); + const [cleanupInfo, setCleanupInfo] = useState(null); + const [statsLoading, setStatsLoading] = useState(false); + const [confirmDialog, setConfirmDialog] = useState<{ open: boolean; type: string; onConfirm: () => void }>({ + open: false, + type: '', + onConfirm: () => {}, + }); + + const fileInputRef = useRef(null); + + // Check access + const role = currentUser?.role || ''; + const isAdmin = role === 'SUPER_ADMIN' || role === 'SCHOOL_ADMIN'; + const isVicePrincipal = role === 'VICE_PRINCIPAL'; + const isTeacher = role === 'TEACHER'; + const canImport = isAdmin || isVicePrincipal || isTeacher; + const canExport = isAdmin || isVicePrincipal || isTeacher; + const canManage = isAdmin || isVicePrincipal; + const canCleanup = isAdmin; + + const schoolId = currentUser?.schoolId || ''; + + // ── Parse CSV/JSON locally ────────────────────────────────────── + + const parseLocalFile = useCallback(async (file: File) => { + const text = await file.text(); + try { + if (file.name.endsWith('.json')) { + const data = JSON.parse(text); + const arr = Array.isArray(data) ? data : data.records || data.data || []; + if (arr.length === 0) { + toast.error(t('data_import_export.invalid_format')); + return; + } + const headers = Object.keys(arr[0]); + const rows = arr.slice(0, 10).map((item: Record) => { + const row: Record = {}; + headers.forEach((h) => { + row[h] = item[h] !== undefined && item[h] !== null ? String(item[h]) : ''; + }); + return row; + }); + setParsedData({ headers, rows }); + // Auto-map columns + const mapping: Record = {}; + const fields = IMPORT_FIELD_SCHEMAS[importType] || []; + headers.forEach((h) => { + const lower = h.toLowerCase().replace(/[^a-z0-9]/g, ''); + const match = fields.find((f) => f.value.toLowerCase() === lower); + if (match) mapping[h] = match.value; + }); + setColumnMapping(mapping); + } else { + // Parse CSV + const lines = text.split(/\r?\n/).filter((l) => l.trim()); + if (lines.length < 2) { + toast.error(t('data_import_export.invalid_format')); + return; + } + const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, '')); + const rows: Record[] = []; + for (let i = 1; i < Math.min(lines.length, 11); i++) { + const values: string[] = []; + let current = ''; + let inQuotes = false; + for (const char of lines[i]) { + if (char === '"') inQuotes = !inQuotes; + else if (char === ',' && !inQuotes) { values.push(current.trim()); current = ''; } + else current += char; + } + values.push(current.trim()); + const row: Record = {}; + headers.forEach((h, idx) => { row[h] = values[idx] ?? ''; }); + rows.push(row); + } + setParsedData({ headers, rows }); + // Auto-map columns + const mapping: Record = {}; + const fields = IMPORT_FIELD_SCHEMAS[importType] || []; + headers.forEach((h) => { + const lower = h.toLowerCase().replace(/[^a-z0-9]/g, ''); + const match = fields.find((f) => f.value.toLowerCase() === lower); + if (match) mapping[h] = match.value; + }); + setColumnMapping(mapping); + } + } catch { + toast.error(t('data_import_export.invalid_format')); + } + }, [importType]); + + // ── Handle file selection ─────────────────────────────────────── + + const handleFileSelect = useCallback((file: File) => { + if (file.size > 10 * 1024 * 1024) { + toast.error(t('data_import_export.file_too_large')); + return; + } + if (!file.name.endsWith('.csv') && !file.name.endsWith('.json')) { + toast.error(t('data_import_export.invalid_format')); + return; + } + setSelectedFile(file); + setImportResult(null); + setImportProgress(0); + parseLocalFile(file); + }, [parseLocalFile]); + + // ── Drag & Drop handlers ──────────────────────────────────────── + + const handleDrag = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDragIn = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(true); + }, []); + + const handleDragOut = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + handleFileSelect(e.dataTransfer.files[0]); + } + }, [handleFileSelect]); + + // ── Import handler ────────────────────────────────────────────── + + const handleImport = useCallback(async () => { + if (!selectedFile || !schoolId) return; + setImporting(true); + setImportProgress(0); + + // Simulate progress + const progressInterval = setInterval(() => { + setImportProgress((prev) => { + if (prev >= 90) { clearInterval(progressInterval); return 90; } + return prev + Math.random() * 15; + }); + }, 300); + + try { + const formData = new FormData(); + formData.append('file', selectedFile); + formData.append('type', importType); + formData.append('schoolId', schoolId); + if (Object.keys(columnMapping).length > 0) { + formData.append('columnMapping', JSON.stringify(columnMapping)); + } + + const res = await fetch('/api/data-import', { method: 'POST', body: formData }); + const data = await res.json(); + + clearInterval(progressInterval); + setImportProgress(100); + + if (res.ok) { + setImportResult(data); + toast.success(t('data_import_export.import_success')); + } else { + toast.error(data.error || t('data_import_export.import_error')); + } + } catch { + clearInterval(progressInterval); + toast.error(t('data_import_export.import_error')); + } finally { + setImporting(false); + } + }, [selectedFile, schoolId, importType, columnMapping]); + + // ── Export handler ────────────────────────────────────────────── + + const handleExport = useCallback(async () => { + if (!schoolId) return; + setExporting(true); + setExportResult(null); + + try { + const filters: Record = { schoolId }; + if (classFilter) filters.classId = classFilter; + if (subjectFilter) filters.subjectId = subjectFilter; + if (dateFrom) filters.dateFrom = dateFrom; + if (dateTo) filters.dateTo = dateTo; + + const res = await fetch('/api/data-export/jobs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: exportType, + format: exportFormat, + filters, + }), + }); + const data = await res.json(); + + if (res.ok) { + setExportResult(data); + toast.success(t('data_import_export.export_success')); + } else { + toast.error(data.error || t('data_import_export.export_error')); + } + } catch { + toast.error(t('data_import_export.export_error')); + } finally { + setExporting(false); + } + }, [schoolId, exportType, exportFormat, classFilter, subjectFilter, dateFrom, dateTo]); + + // ── Download handler ──────────────────────────────────────────── + + const handleDownload = useCallback((fileData: string, fileName: string) => { + try { + const binary = atob(fileData); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + const blob = new Blob([bytes]); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); + } catch { + toast.error(t('data_import_export.operation_error')); + } + }, []); + + // ── Load history ──────────────────────────────────────────────── + + const loadHistory = useCallback(async () => { + if (!schoolId) return; + setHistoryLoading(true); + try { + const [importRes, exportRes] = await Promise.all([ + fetch(`/api/data-import?schoolId=${schoolId}`), + fetch(`/api/data-export/jobs?schoolId=${schoolId}`), + ]); + if (importRes.ok) setImportJobs(await importRes.json()); + if (exportRes.ok) setExportJobs(await exportRes.json()); + } catch { + // ignore + } finally { + setHistoryLoading(false); + } + }, [schoolId]); + + // ── Load statistics ───────────────────────────────────────────── + + const loadStats = useCallback(async () => { + if (!schoolId) return; + setStatsLoading(true); + try { + const res = await fetch(`/api/data-cleanup?schoolId=${schoolId}`); + if (res.ok) { + const data = await res.json(); + setDbStats(data.statistics); + setCleanupInfo(data.cleanup); + } + } catch { + // ignore + } finally { + setStatsLoading(false); + } + }, [schoolId]); + + // ── Cleanup handler ───────────────────────────────────────────── + + const handleCleanup = useCallback(async (operation: string) => { + if (!schoolId) return; + try { + const res = await fetch('/api/data-cleanup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ operation, schoolId }), + }); + const data = await res.json(); + if (res.ok) { + toast.success(t('data_import_export.operation_success')); + loadStats(); + } else { + toast.error(data.error || t('data_import_export.operation_error')); + } + } catch { + toast.error(t('data_import_export.operation_error')); + } + setConfirmDialog({ open: false, type: '', onConfirm: () => {} }); + }, [schoolId, loadStats]); + + // ── Delete export job ─────────────────────────────────────────── + + const handleDeleteExport = useCallback(async (id: string) => { + try { + const res = await fetch(`/api/data-export/jobs/${id}`, { method: 'DELETE' }); + if (res.ok) { + setExportJobs((prev) => prev.filter((j) => j.id !== id)); + toast.success(t('data_import_export.operation_success')); + } + } catch { + toast.error(t('data_import_export.operation_error')); + } + }, []); + + // ── Load data on tab change ───────────────────────────────────── + + useEffect(() => { + if (activeTab === 'history') loadHistory(); + if (activeTab === 'management') loadStats(); + }, [activeTab, loadHistory, loadStats]); + + // ── No access view ────────────────────────────────────────────── + + if (!canImport && !canExport) { + return ( +
+ + + +

{t('data_import_export.no_access')}

+

{t('data_import_export.no_access_desc')}

+
+ ); + } + + // ── Stat cards data ───────────────────────────────────────────── + + const statCards = dbStats ? [ + { label: t('data_import_export.students'), value: dbStats.students, icon: GraduationCap, color: 'from-emerald-500 to-teal-600' }, + { label: t('data_import_export.teachers'), value: dbStats.teachers, icon: Users, color: 'from-teal-500 to-cyan-600' }, + { label: t('data_import_export.grades'), value: dbStats.grades, icon: ClipboardCheck, color: 'from-cyan-500 to-sky-600' }, + { label: t('data_import_export.attendance_records'), value: dbStats.attendance, icon: CalendarCheck, color: 'from-sky-500 to-blue-600' }, + { label: t('data_import_export.competency_records'), value: dbStats.competencies, icon: BarChart3, color: 'from-violet-500 to-purple-600' }, + { label: t('data_import_export.total_records'), value: dbStats.total, icon: Database, color: 'from-emerald-500 to-emerald-700' }, + ] : []; + + // ── Render ─────────────────────────────────────────────────────── + + return ( +
+ {/* Header Banner */} + +
+
+
+
+ +
+
+

{t('data_import_export.title')}

+

{t('data_import_export.subtitle')}

+
+
+
+
+
+ + + {/* Main Tabs */} + + + + + {t('data_import_export.tab_import')} + + + + {t('data_import_export.tab_export')} + + + + {t('data_import_export.tab_history')} + + + + {t('data_import_export.tab_management')} + + + + {/* ── IMPORT TAB ──────────────────────────────────────────── */} + + {canImport ? ( + <> + {/* Import Type Selection */} + + + + + + {t('data_import_export.import_type')} + + + +
+ {[ + { value: 'STUDENT', icon: GraduationCap, label: t('data_import_export.type_student') }, + { value: 'TEACHER', icon: Users, label: t('data_import_export.type_teacher') }, + { value: 'GRADE', icon: ClipboardCheck, label: t('data_import_export.type_grade') }, + { value: 'ATTENDANCE', icon: CalendarCheck, label: t('data_import_export.type_attendance') }, + { value: 'COMPETENCY', icon: BarChart3, label: t('data_import_export.type_competency') }, + ].map((item) => ( + { + setImportType(item.value); + setParsedData(null); + setSelectedFile(null); + setColumnMapping({}); + setImportResult(null); + }} + className={`flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all ${ + importType === item.value + ? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30 shadow-md' + : 'border-muted hover:border-emerald-300 dark:hover:border-emerald-700' + }`} + > + + + {item.label} + + + ))} +
+
+
+
+ + {/* File Upload Zone */} + + + + + + {t('data_import_export.upload_title')} + + {t('data_import_export.upload_subtitle')} + + +
fileInputRef.current?.click()} + className={`relative cursor-pointer rounded-xl border-2 border-dashed p-8 md:p-12 text-center transition-all ${ + dragActive + ? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30 scale-[1.02]' + : selectedFile + ? 'border-emerald-400 bg-emerald-50/50 dark:bg-emerald-950/20' + : 'border-muted hover:border-emerald-300 dark:hover:border-emerald-700' + }`} + > + { + if (e.target.files?.[0]) handleFileSelect(e.target.files[0]); + }} + /> + +
+ +
+
+

+ {dragActive ? t('data_import_export.drag_drop_active') : t('data_import_export.drag_drop')} +

+

+ {t('data_import_export.supported_formats')} • {t('data_import_export.max_file_size')} +

+
+
+ {selectedFile && !dragActive && ( +
+ + {selectedFile.name} + ({formatFileSize(selectedFile.size)}) + +
+ )} +
+
+
+
+ + {/* Column Mapping */} + + {parsedData && ( + + + + + + {t('data_import_export.column_mapping')} + + {t('data_import_export.column_mapping_desc')} + + +
+ {parsedData.headers.map((header) => ( +
+
+ {header} +
+ +
+ +
+
+ ))} +
+
+
+
+ )} +
+ + {/* Data Preview */} + + {parsedData && parsedData.rows.length > 0 && ( + + + + + + {t('data_import_export.preview')} + + {t('data_import_export.preview_desc')} + + +
+ + + + + {parsedData.headers.map((h) => ( + + ))} + + + + {parsedData.rows.map((row, idx) => ( + + + {parsedData.headers.map((h) => ( + + ))} + + ))} + +
#{h}
{idx + 1}{row[h] || '—'}
+
+
+
+
+ )} +
+ + {/* Import Progress & Result */} + + {(importing || importResult) && ( + + + + + {importing ? ( + + ) : ( + + )} + {t('data_import_export.import_progress')} + + + + + {importing && ( +

{t('data_import_export.importing')}

+ )} + {importResult && ( + +
+
+

{String(importResult.totalRows ?? 0)}

+

{t('data_import_export.total_rows')}

+
+
+

{String(importResult.created ?? 0)}

+

{t('data_import_export.success_rows')}

+
+
+

{String(importResult.skipped ?? 0)}

+

{t('data_import_export.error_rows')}

+
+
+ {Array.isArray(importResult.errors) && importResult.errors.length > 0 && ( +
+

{t('data_import_export.validation_errors')}

+
+ {(importResult.errors as string[]).map((err: string, idx: number) => ( +

{err}

+ ))} +
+
+ )} +
+ )} +
+
+
+ )} +
+ + {/* Start Import Button */} + + + + + ) : ( + + )} +
+ + {/* ── EXPORT TAB ──────────────────────────────────────────── */} + + {canExport ? ( + <> +
+ {/* Export Configuration */} + + + + + + {t('data_import_export.export_title')} + + + + {/* Export Type */} +
+ +
+ {[ + { value: 'STUDENT', icon: GraduationCap, label: t('data_import_export.type_student') }, + { value: 'GRADE', icon: ClipboardCheck, label: t('data_import_export.type_grade') }, + { value: 'ATTENDANCE', icon: CalendarCheck, label: t('data_import_export.type_attendance') }, + { value: 'COMPETENCY', icon: BarChart3, label: t('data_import_export.type_competency') }, + { value: 'REPORT', icon: FileText, label: t('data_import_export.type_report') }, + ].map((item) => ( + setExportType(item.value)} + className={`flex items-center gap-2 p-2.5 rounded-lg border-2 transition-all text-sm ${ + exportType === item.value + ? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30' + : 'border-muted hover:border-emerald-300' + }`} + > + + + {item.label} + + + ))} +
+
+ + {/* Export Format */} +
+ +
+ {[ + { value: 'CSV', icon: FileSpreadsheet, label: t('data_import_export.format_csv') }, + { value: 'JSON', icon: FileText, label: t('data_import_export.format_json') }, + ].map((item) => ( + setExportFormat(item.value)} + className={`flex items-center gap-2 px-4 py-2.5 rounded-lg border-2 transition-all ${ + exportFormat === item.value + ? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30' + : 'border-muted hover:border-emerald-300' + }`} + > + + + {item.label} + + + ))} +
+
+ + + + {/* Date Range */} +
+ +
+
+ + setDateFrom(e.target.value)} /> +
+
+ + setDateTo(e.target.value)} /> +
+
+
+ + {/* Class & Subject Filters */} +
+
+ + setClassFilter(e.target.value)} /> +
+
+ + setSubjectFilter(e.target.value)} /> +
+
+ + {/* Options */} +
+ +
+
+ setIncludeHeaders(!!v)} /> + +
+
+ setIncludeMetadata(!!v)} /> + +
+
+
+ + {/* Export Button */} + +
+
+
+ + {/* Export Templates & Result */} +
+ {/* Export Templates */} + + + + + + {t('data_import_export.export_templates')} + + + + {[ + { key: 'template_full', types: ['STUDENT', 'GRADE', 'ATTENDANCE', 'COMPETENCY'], formats: ['CSV', 'JSON'] }, + { key: 'template_grades_only', types: ['GRADE'], formats: ['CSV'] }, + { key: 'template_attendance_only', types: ['ATTENDANCE'], formats: ['CSV'] }, + { key: 'template_competency_only', types: ['COMPETENCY'], formats: ['CSV', 'JSON'] }, + ].map((template) => ( + { + setExportType(template.types[0]); + setExportFormat(template.formats[0]); + }} + className="w-full flex items-center justify-between p-3 rounded-lg border border-muted hover:border-emerald-300 dark:hover:border-emerald-700 transition-all" + > +
+ + {t(`data_import_export.${template.key}`)} +
+ +
+ ))} +
+
+
+ + {/* Export Result */} + + {exportResult && ( + + + + + + {t('data_import_export.export_success')} + + + +
+
+

{String(exportResult.recordCount ?? 0)}

+

{t('data_import_export.records')}

+
+
+

{formatFileSize(Number(exportResult.fileSize ?? 0))}

+

{t('data_import_export.file_size')}

+
+
+ {exportResult.fileData && ( + + )} +
+
+
+ )} +
+
+
+ + ) : ( + + )} +
+ + {/* ── HISTORY TAB ─────────────────────────────────────────── */} + +
+

{t('data_import_export.history_title')}

+ +
+ + {/* Import History */} + + + + + {t('data_import_export.import_history')} + + + + {importJobs.length === 0 ? ( +
+ +

{t('data_import_export.no_history')}

+
+ ) : ( +
+ + + + + + + + + + + + + {importJobs.map((job) => ( + + + + + + + + + ))} + +
{t('data_import_export.file_name')}{t('data_import_export.status')}{t('data_import_export.total_rows')}{t('data_import_export.success_rows')}{t('data_import_export.error_rows')}{t('data_import_export.created_at')}
+
+ + {job.fileName} +
+
{job.totalRows}{job.successRows}{job.errorRows}{new Date(job.createdAt).toLocaleDateString()}
+
+ )} +
+
+ + {/* Export History */} + + + + + {t('data_import_export.history_title')} + + + + {exportJobs.length === 0 ? ( +
+ +

{t('data_import_export.no_history')}

+
+ ) : ( +
+ + + + + + + + + + + + + {exportJobs.map((job) => ( + + + + + + + + + ))} + +
{t('data_import_export.file_name')}{t('data_import_export.export_format')}{t('data_import_export.file_size')}{t('data_import_export.status')}{t('data_import_export.created_at')}
+
+ + {job.fileName || '—'} +
+
{job.format}{formatFileSize(job.fileSize)}{new Date(job.createdAt).toLocaleDateString()} +
+ {job.status === 'completed' && job.fileData && ( + + )} + {isAdmin && ( + + )} +
+
+
+ )} +
+
+
+ + {/* ── MANAGEMENT TAB ──────────────────────────────────────── */} + + {canManage ? ( + <> + {/* Database Statistics */} + + + +
+ + + {t('data_import_export.db_statistics')} + + +
+
+ + {statsLoading && !dbStats ? ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ ))} +
+ ) : dbStats ? ( +
+ {statCards.map((card) => ( + +
+
+ +

+ +

+

{card.label}

+
+ + ))} +
+ ) : null} + + +
+ + {/* Cleanup Tools */} +
+ + + + + + {t('data_import_export.cleanup_tools')} + + + + {/* Orphaned Records */} +
+
+
+ +
+
+

{t('data_import_export.remove_orphans')}

+

+ {t('data_import_export.remove_orphans_desc')} + {cleanupInfo && ` (${cleanupInfo.totalOrphans} ${t('data_import_export.records')})`} +

+
+
+ +
+ + {/* Bulk Delete */} +
+
+
+ +
+
+

{t('data_import_export.bulk_delete')}

+

{t('data_import_export.bulk_delete_desc')}

+
+
+ +
+
+
+
+ + {/* Backup & Restore */} + + + + + + {t('data_import_export.backup')} + + + + {/* Create Backup */} +
+
+
+ +
+
+

{t('data_import_export.backup')}

+

{t('data_import_export.backup_desc')}

+
+
+ +
+ + {/* Restore Backup */} +
+
+
+ +
+
+

{t('data_import_export.restore')}

+

{t('data_import_export.restore_desc')}

+
+
+ +
+
+
+
+
+ + {/* Info Banner */} + +
+ +
+

Data Management Best Practices

+
    +
  • Always create a backup before performing bulk operations
  • +
  • Review the data preview before importing to catch errors early
  • +
  • Use column mapping to ensure correct field alignment
  • +
  • Run cleanup periodically to remove orphaned records
  • +
  • Export data regularly for archival purposes
  • +
+
+
+
+ + ) : ( + + )} + + + + {/* Confirm Dialog */} + setConfirmDialog({ open, type: confirmDialog.type, onConfirm: confirmDialog.onConfirm })}> + + + + + {confirmDialog.type === 'cleanup' ? t('data_import_export.confirm_cleanup') : t('data_import_export.confirm_delete')} + + + {confirmDialog.type === 'cleanup' ? t('data_import_export.confirm_cleanup_msg') : t('data_import_export.confirm_delete_msg')} + + + + + + + + +
+ ); +} + +// ── No Access Card ───────────────────────────────────────────────── + +function NoAccessCard() { + return ( +
+
+ +
+

{t('data_import_export.no_access')}

+

{t('data_import_export.no_access_desc')}

+
+ ); +} diff --git a/src/components/school-events-view.tsx b/src/components/school-events-view.tsx new file mode 100644 index 0000000..f1ba740 --- /dev/null +++ b/src/components/school-events-view.tsx @@ -0,0 +1,1844 @@ +'use client'; + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + CalendarDays, + Plus, + Search, + Filter, + MapPin, + Clock, + Users, + Star, + Edit, + Trash2, + Eye, + ChevronLeft, + ChevronRight, + Calendar, + List, + X, + Check, + AlertTriangle, + TrendingUp, + BarChart3, + DollarSign, + Timer, + PartyPopper, + GraduationCap, + BookOpen, + Heart, + Bus, + Coffee, + Megaphone, + Palmtree, + ArrowRight, + UserPlus, + MessageSquare, + Send, + Ban, + MoreHorizontal, + Copy, + CheckCircle2, + UserCheck, + ExternalLink, +} from 'lucide-react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Progress } from '@/components/ui/progress'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useAppStore } from '@/lib/store'; +import { t } from '@/lib/i18n'; + +/* ─── Types ──────────────────────────────────────────────────────── */ + +interface OrganizerInfo { + id: string; + firstName: string; + lastName: string; +} + +interface ClassGroupInfo { + id: string; + name: string; +} + +interface RegistrationInfo { + id: string; + userId: string; + status: string; + notes: string | null; + createdAt: string; + user: { id: string; firstName: string; lastName: string }; +} + +interface FeedbackInfo { + id: string; + userId: string; + rating: number; + comment: string | null; + createdAt: string; + user: { id: string; firstName: string; lastName: string }; +} + +interface SchoolEvent { + id: string; + schoolId: string; + title: string; + description: string | null; + eventType: string; + startDate: string; + endDate: string | null; + location: string | null; + organizerId: string | null; + classGroupId: string | null; + isAllSchool: boolean; + isPublic: boolean; + requiresRegistration: boolean; + maxParticipants: number | null; + notes: string | null; + budget: number | null; + registrationDeadline: string | null; + capacity: number | null; + isRecurring: boolean; + recurrenceRule: string | null; + bannerImageUrl: string | null; + status: string; + feedbackForm: string | null; + createdAt: string; + updatedAt: string; + organizer: OrganizerInfo | null; + classGroup: ClassGroupInfo | null; + registrations: RegistrationInfo[]; + feedbacks: FeedbackInfo[]; +} + +/* ─── Constants ──────────────────────────────────────────────────── */ + +const EVENT_TYPES = [ + { value: 'sports_day', labelKey: 'se.type_sports', icon: PartyPopper, color: 'bg-orange-500' }, + { value: 'cultural', labelKey: 'se.type_cultural', icon: Coffee, color: 'bg-purple-500' }, + { value: 'academic', labelKey: 'se.type_academic', icon: BookOpen, color: 'bg-blue-500' }, + { value: 'social', labelKey: 'se.type_social', icon: Heart, color: 'bg-pink-500' }, + { value: 'parent_meeting', labelKey: 'se.type_parent_meeting', icon: Users, color: 'bg-amber-500' }, + { value: 'holiday', labelKey: 'se.type_holiday', icon: Palmtree, color: 'bg-green-500' }, + { value: 'field_trip', labelKey: 'se.type_field_trip', icon: Bus, color: 'bg-cyan-500' }, + { value: 'assembly', labelKey: 'se.type_assembly', icon: Megaphone, color: 'bg-red-500' }, + { value: 'concert', labelKey: 'se.type_concert', icon: Star, color: 'bg-indigo-500' }, + { value: 'graduation', labelKey: 'se.type_graduation', icon: GraduationCap, color: 'bg-emerald-500' }, + { value: 'fair', labelKey: 'se.type_fair', icon: PartyPopper, color: 'bg-rose-500' }, +]; + +const EVENT_STATUSES = [ + { value: 'draft', labelKey: 'se.status_draft', color: 'bg-gray-500' }, + { value: 'published', labelKey: 'se.status_published', color: 'bg-emerald-500' }, + { value: 'cancelled', labelKey: 'se.status_cancelled', color: 'bg-red-500' }, + { value: 'completed', labelKey: 'se.status_completed', color: 'bg-blue-500' }, +]; + +const RECURRENCE_TYPES = [ + { value: 'daily', labelKey: 'se.recurrence_daily' }, + { value: 'weekly', labelKey: 'se.recurrence_weekly' }, + { value: 'monthly', labelKey: 'se.recurrence_monthly' }, + { value: 'custom', labelKey: 'se.recurrence_custom' }, +]; + +/* ─── Helpers ────────────────────────────────────────────────────── */ + +function getEventTypeConfig(type: string) { + return EVENT_TYPES.find(et => et.value === type) || EVENT_TYPES[0]; +} + +function getStatusConfig(status: string) { + return EVENT_STATUSES.find(s => s.value === status) || EVENT_STATUSES[0]; +} + +function formatDate(dateStr: string | null): string { + if (!dateStr) return ''; + return new Date(dateStr).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); +} + +function formatTime(dateStr: string): string { + return new Date(dateStr).toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + }); +} + +function formatDateTime(dateStr: string): string { + return new Date(dateStr).toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function getDaysUntil(dateStr: string): number { + const now = new Date(); + const target = new Date(dateStr); + const diff = target.getTime() - now.getTime(); + return Math.ceil(diff / (1000 * 60 * 60 * 24)); +} + +function getCalendarDays(year: number, month: number): (number | null)[][] { + const firstDay = new Date(year, month, 1).getDay(); + const adjustedFirstDay = firstDay === 0 ? 6 : firstDay - 1; + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const days: (number | null)[][] = []; + let week: (number | null)[] = []; + + for (let i = 0; i < adjustedFirstDay; i++) { + week.push(null); + } + + for (let day = 1; day <= daysInMonth; day++) { + week.push(day); + if (week.length === 7) { + days.push(week); + week = []; + } + } + + if (week.length > 0) { + while (week.length < 7) week.push(null); + days.push(week); + } + + return days; +} + +/* ─── Animated Counter ───────────────────────────────────────────── */ + +function AnimatedCounter({ value, duration = 1.5 }: { value: number; duration?: number }) { + const [display, setDisplay] = useState(0); + + useEffect(() => { + let start = 0; + const step = value / (duration * 60); + const timer = setInterval(() => { + start += step; + if (start >= value) { + setDisplay(value); + clearInterval(timer); + } else { + setDisplay(Math.floor(start)); + } + }, 1000 / 60); + return () => clearInterval(timer); + }, [value, duration]); + + return {display}; +} + +/* ─── Main Component ─────────────────────────────────────────────── */ + +export default function SchoolEventsView() { + const currentUser = useAppStore((s) => s.currentUser); + const locale = useAppStore((s) => s.locale); + + // Data state + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // View state + const [viewMode, setViewMode] = useState<'calendar' | 'list'>('list'); + const [calendarYear, setCalendarYear] = useState(new Date().getFullYear()); + const [calendarMonth, setCalendarMonth] = useState(new Date().getMonth()); + + // Filter state + const [searchQuery, setSearchQuery] = useState(''); + const [filterType, setFilterType] = useState('all'); + const [filterStatus, setFilterStatus] = useState('all'); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + + // Dialog state + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [showDetailDialog, setShowDetailDialog] = useState(false); + const [showFeedbackDialog, setShowFeedbackDialog] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [selectedEvent, setSelectedEvent] = useState(null); + const [editingEvent, setEditingEvent] = useState(null); + + // Form state + const [formData, setFormData] = useState({ + title: '', + description: '', + eventType: 'sports_day', + startDate: '', + endDate: '', + location: '', + organizerId: '', + classGroupId: '', + isAllSchool: true, + isPublic: true, + requiresRegistration: false, + maxParticipants: '', + capacity: '', + notes: '', + budget: '', + registrationDeadline: '', + isRecurring: false, + recurrenceRule: '', + bannerImageUrl: '', + status: 'draft', + }); + + // Feedback form state + const [feedbackRating, setFeedbackRating] = useState(0); + const [feedbackComment, setFeedbackComment] = useState(''); + const [feedbackHover, setFeedbackHover] = useState(0); + + // Action state + const [submitting, setSubmitting] = useState(false); + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); + + const schoolId = currentUser?.schoolId; + const isAdmin = currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN' || currentUser?.role === 'VICE_PRINCIPAL'; + const isTeacher = currentUser?.role === 'TEACHER'; + const isStudent = currentUser?.role === 'STUDENT'; + const isParent = currentUser?.role === 'PARENT'; + + /* ─── Data Fetching ─────────────────────────────────────────────── */ + + const fetchEvents = useCallback(async () => { + if (!schoolId) return; + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ schoolId }); + if (filterType !== 'all') params.set('eventType', filterType); + if (filterStatus !== 'all') params.set('status', filterStatus); + if (searchQuery) params.set('search', searchQuery); + if (dateFrom) params.set('startDateFrom', dateFrom); + if (dateTo) params.set('startDateTo', dateTo); + + const res = await fetch(`/api/school-events?${params.toString()}`); + if (!res.ok) throw new Error(t('se.error_fetch')); + const data = await res.json(); + setEvents(data); + } catch (err) { + setError(t('se.error_fetch')); + console.error(err); + } finally { + setLoading(false); + } + }, [schoolId, filterType, filterStatus, searchQuery, dateFrom, dateTo]); + + useEffect(() => { + fetchEvents(); + }, [fetchEvents]); + + /* ─── Computed Values ────────────────────────────────────────────── */ + + const filteredEvents = useMemo(() => { + return events; + }, [events]); + + const stats = useMemo(() => { + const total = events.length; + const upcoming = events.filter(e => new Date(e.startDate) > new Date() && e.status !== 'cancelled').length; + const published = events.filter(e => e.status === 'published').length; + const completed = events.filter(e => e.status === 'completed').length; + const totalBudget = events.reduce((sum, e) => sum + (e.budget || 0), 0); + const totalRegistrations = events.reduce((sum, e) => sum + e.registrations.filter(r => r.status === 'registered').length, 0); + const avgRating = events.reduce((sum, e) => { + const ratings = e.feedbacks.map(f => f.rating); + return ratings.length > 0 ? sum + ratings.reduce((a, b) => a + b, 0) / ratings.length : sum; + }, 0); + const ratedEvents = events.filter(e => e.feedbacks.length > 0).length; + const overallAvgRating = ratedEvents > 0 ? avgRating / ratedEvents : 0; + + const typeDistribution: Record = {}; + events.forEach(e => { + typeDistribution[e.eventType] = (typeDistribution[e.eventType] || 0) + 1; + }); + + return { + total, upcoming, published, completed, + totalBudget, totalRegistrations, + overallAvgRating: Math.round(overallAvgRating * 10) / 10, + typeDistribution, + }; + }, [events]); + + const calendarEvents = useMemo(() => { + return events.filter(e => { + const d = new Date(e.startDate); + return d.getFullYear() === calendarYear && d.getMonth() === calendarMonth; + }); + }, [events, calendarYear, calendarMonth]); + + /* ─── Form Handlers ──────────────────────────────────────────────── */ + + const resetForm = useCallback(() => { + setFormData({ + title: '', + description: '', + eventType: 'sports_day', + startDate: '', + endDate: '', + location: '', + organizerId: '', + classGroupId: '', + isAllSchool: true, + isPublic: true, + requiresRegistration: false, + maxParticipants: '', + capacity: '', + notes: '', + budget: '', + registrationDeadline: '', + isRecurring: false, + recurrenceRule: '', + bannerImageUrl: '', + status: 'draft', + }); + setEditingEvent(null); + }, []); + + const openCreateDialog = useCallback(() => { + resetForm(); + setShowCreateDialog(true); + }, [resetForm]); + + const openEditDialog = useCallback((event: SchoolEvent) => { + setEditingEvent(event); + setFormData({ + title: event.title, + description: event.description || '', + eventType: event.eventType, + startDate: event.startDate ? new Date(event.startDate).toISOString().slice(0, 16) : '', + endDate: event.endDate ? new Date(event.endDate).toISOString().slice(0, 16) : '', + location: event.location || '', + organizerId: event.organizerId || '', + classGroupId: event.classGroupId || '', + isAllSchool: event.isAllSchool, + isPublic: event.isPublic, + requiresRegistration: event.requiresRegistration, + maxParticipants: event.maxParticipants?.toString() || '', + capacity: event.capacity?.toString() || '', + notes: event.notes || '', + budget: event.budget?.toString() || '', + registrationDeadline: event.registrationDeadline ? new Date(event.registrationDeadline).toISOString().slice(0, 16) : '', + isRecurring: event.isRecurring, + recurrenceRule: event.recurrenceRule || '', + bannerImageUrl: event.bannerImageUrl || '', + status: event.status, + }); + setShowCreateDialog(true); + }, []); + + const handleSubmit = useCallback(async () => { + if (!formData.title || !formData.eventType || !formData.startDate) { + setToast({ message: t('se.error_required_fields'), type: 'error' }); + return; + } + + setSubmitting(true); + try { + const payload: Record = { + schoolId, + title: formData.title, + description: formData.description || null, + eventType: formData.eventType, + startDate: formData.startDate, + endDate: formData.endDate || null, + location: formData.location || null, + organizerId: formData.organizerId || null, + classGroupId: formData.classGroupId || null, + isAllSchool: formData.isAllSchool, + isPublic: formData.isPublic, + requiresRegistration: formData.requiresRegistration, + maxParticipants: formData.maxParticipants ? parseInt(formData.maxParticipants) : null, + capacity: formData.capacity ? parseInt(formData.capacity) : null, + notes: formData.notes || null, + budget: formData.budget ? parseFloat(formData.budget) : null, + registrationDeadline: formData.registrationDeadline || null, + isRecurring: formData.isRecurring, + recurrenceRule: formData.isRecurring ? formData.recurrenceRule : null, + bannerImageUrl: formData.bannerImageUrl || null, + status: formData.status, + }; + + if (editingEvent) { + const res = await fetch(`/api/school-events/${editingEvent.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(t('se.error_update')); + setToast({ message: t('se.event_updated'), type: 'success' }); + } else { + const res = await fetch('/api/school-events', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(t('se.error_create')); + setToast({ message: t('se.event_created'), type: 'success' }); + } + + setShowCreateDialog(false); + resetForm(); + fetchEvents(); + } catch (err) { + setToast({ message: editingEvent ? t('se.error_update') : t('se.error_create'), type: 'error' }); + console.error(err); + } finally { + setSubmitting(false); + } + }, [formData, editingEvent, schoolId, resetForm, fetchEvents]); + + const handleDelete = useCallback(async () => { + if (!selectedEvent) return; + try { + const res = await fetch(`/api/school-events/${selectedEvent.id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(t('se.error_delete')); + setToast({ message: t('se.event_deleted'), type: 'success' }); + setShowDeleteConfirm(false); + setSelectedEvent(null); + setShowDetailDialog(false); + fetchEvents(); + } catch (err) { + setToast({ message: t('se.error_delete'), type: 'error' }); + console.error(err); + } + }, [selectedEvent, fetchEvents]); + + const handleRegister = useCallback(async (eventId: string) => { + if (!currentUser?.id) return; + try { + const res = await fetch(`/api/school-events/${eventId}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: currentUser.id }), + }); + if (!res.ok) { + const data = await res.json(); + if (data.waitlist) { + setToast({ message: t('se.event_full_waitlist'), type: 'error' }); + } else { + throw new Error(data.error); + } + return; + } + setToast({ message: t('se.registration_success'), type: 'success' }); + fetchEvents(); + } catch (err) { + setToast({ message: t('se.error_register'), type: 'error' }); + console.error(err); + } + }, [currentUser, fetchEvents]); + + const handleCancelRegistration = useCallback(async (eventId: string) => { + if (!currentUser?.id) return; + try { + const res = await fetch(`/api/school-events/${eventId}/register?userId=${currentUser.id}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error(t('se.error_cancel_registration')); + setToast({ message: t('se.registration_cancelled'), type: 'success' }); + fetchEvents(); + } catch (err) { + setToast({ message: t('se.error_cancel_registration'), type: 'error' }); + console.error(err); + } + }, [currentUser, fetchEvents]); + + const handleSubmitFeedback = useCallback(async () => { + if (!selectedEvent || !currentUser?.id || feedbackRating === 0) return; + try { + const res = await fetch(`/api/school-events/${selectedEvent.id}/feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: currentUser.id, + rating: feedbackRating, + comment: feedbackComment, + }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error); + } + setToast({ message: t('se.feedback_submitted'), type: 'success' }); + setShowFeedbackDialog(false); + setFeedbackRating(0); + setFeedbackComment(''); + fetchEvents(); + } catch (err) { + setToast({ message: t('se.error_feedback'), type: 'error' }); + console.error(err); + } + }, [selectedEvent, currentUser, feedbackRating, feedbackComment, fetchEvents]); + + const handleMarkAttendance = useCallback(async (eventId: string, regId: string, status: string) => { + try { + const registration = events.find(e => e.id === eventId)?.registrations.find(r => r.id === regId); + if (!registration) return; + const res = await fetch(`/api/school-events/${eventId}/register`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: registration.userId, status }), + }); + if (!res.ok) throw new Error(t('se.error_update')); + setToast({ message: t('se.attendance_updated'), type: 'success' }); + fetchEvents(); + } catch (err) { + setToast({ message: t('se.error_update'), type: 'error' }); + console.error(err); + } + }, [events, fetchEvents]); + + /* ─── Toast ──────────────────────────────────────────────────────── */ + + useEffect(() => { + if (toast) { + const timer = setTimeout(() => setToast(null), 3000); + return () => clearTimeout(timer); + } + }, [toast]); + + /* ─── Calendar Navigation ────────────────────────────────────────── */ + + const prevMonth = () => { + if (calendarMonth === 0) { + setCalendarMonth(11); + setCalendarYear(y => y - 1); + } else { + setCalendarMonth(m => m - 1); + } + }; + + const nextMonth = () => { + if (calendarMonth === 11) { + setCalendarMonth(0); + setCalendarYear(y => y + 1); + } else { + setCalendarMonth(m => m + 1); + } + }; + + const monthName = new Date(calendarYear, calendarMonth).toLocaleDateString( + locale === 'de' ? 'de-DE' : 'en-US', + { month: 'long', year: 'numeric' } + ); + + const calendarDays = useMemo( + () => getCalendarDays(calendarYear, calendarMonth), + [calendarYear, calendarMonth] + ); + + const dayNames = locale === 'de' + ? ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] + : ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + + const isUserRegistered = useCallback((event: SchoolEvent) => { + return event.registrations.some( + r => r.userId === currentUser?.id && r.status === 'registered' + ); + }, [currentUser]); + + const hasUserFeedback = useCallback((event: SchoolEvent) => { + return event.feedbacks.some(f => f.userId === currentUser?.id); + }, [currentUser]); + + /* ─── Render ─────────────────────────────────────────────────────── */ + + return ( +
+ {/* Toast Notification */} + + {toast && ( + + {toast.message} + + )} + + + {/* Header Banner */} +
+
+ +
+

+ + {t('se.title')} +

+

{t('se.subtitle')}

+
+ {(isAdmin || isTeacher) && ( + + )} +
+
+
+ + {/* Stats Cards */} +
+
+ {[ + { label: t('se.total_events'), value: stats.total, icon: CalendarDays, color: 'from-emerald-500 to-teal-500' }, + { label: t('se.upcoming_events'), value: stats.upcoming, icon: Timer, color: 'from-amber-500 to-orange-500' }, + { label: t('se.total_registrations'), value: stats.totalRegistrations, icon: Users, color: 'from-blue-500 to-indigo-500' }, + { label: t('se.total_budget'), value: stats.totalBudget, icon: DollarSign, color: 'from-purple-500 to-pink-500', isCurrency: true }, + ].map((stat, i) => ( + + + +
+
+

{stat.label}

+

+ {stat.isCurrency ? ( + <> € + ) : ( + + )} +

+
+
+ +
+
+
+
+
+ ))} +
+
+ + {/* Main Content */} +
+ {/* Filters & Search */} + + +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ + +
+ setDateFrom(e.target.value)} + className="w-full sm:w-36" + placeholder={t('se.date_from')} + /> + setDateTo(e.target.value)} + className="w-full sm:w-36" + placeholder={t('se.date_to')} + /> +
+
+ + +
+
+
+
+ + {/* Admin Stats Panel */} + {isAdmin && ( + + + + + + {t('se.event_statistics')} + + + +
+ {/* Type Distribution */} +
+

{t('se.events_by_type')}

+
+ {Object.entries(stats.typeDistribution) + .sort((a, b) => b[1] - a[1]) + .map(([type, count]) => { + const config = getEventTypeConfig(type); + const pct = stats.total > 0 ? (count / stats.total) * 100 : 0; + return ( +
+
+ {t(config.labelKey)} + + {count} +
+ ); + })} +
+
+ {/* Quick Stats */} +
+

{t('se.overview')}

+
+
+ {t('se.published_events')} + {stats.published} +
+
+ {t('se.completed_events')} + {stats.completed} +
+
+ {t('se.avg_rating')} +
+ + {stats.overallAvgRating} +
+
+
+ {t('se.total_budget')} + {stats.totalBudget.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' })} +
+
+
+
+ + + + )} + + {/* Loading State */} + {loading && ( +
+
+
+ {t('se.loading')} +
+
+ )} + + {/* Error State */} + {error && !loading && ( + + + +

{error}

+ +
+
+ )} + + {/* Calendar View */} + {!loading && !error && viewMode === 'calendar' && ( + + + +
+ +

{monthName}

+ +
+
+ +
+ {/* Day headers */} + {dayNames.map(day => ( +
+ {day} +
+ ))} + {/* Calendar days */} + {calendarDays.map((week, wi) => + week.map((day, di) => { + const dayEvents = day + ? calendarEvents.filter(e => { + const d = new Date(e.startDate); + return d.getDate() === day; + }) + : []; + return ( +
{ + if (day && (isAdmin || isTeacher)) { + const dateStr = `${calendarYear}-${String(calendarMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}T09:00`; + setFormData(prev => ({ ...prev, startDate: dateStr })); + openCreateDialog(); + } + }} + > + {day && ( + <> + {day} +
+ {dayEvents.slice(0, 3).map(event => { + const config = getEventTypeConfig(event.eventType); + return ( + + + +
{ + e.stopPropagation(); + setSelectedEvent(event); + setShowDetailDialog(true); + }} + > + {event.title} +
+
+ +

{event.title}

+

{formatTime(event.startDate)}

+ {event.location &&

{event.location}

} +
+
+
+ ); + })} + {dayEvents.length > 3 && ( +
+ +{dayEvents.length - 3} {t('se.more')} +
+ )} +
+ + )} +
+ ); + }) + )} +
+
+
+
+ )} + + {/* List View */} + {!loading && !error && viewMode === 'list' && ( +
+ {filteredEvents.length === 0 ? ( + + + +

{t('se.no_events')}

+ {(isAdmin || isTeacher) && ( + + )} +
+
+ ) : ( +
+ {filteredEvents.map((event, i) => { + const typeConfig = getEventTypeConfig(event.eventType); + const statusConfig = getStatusConfig(event.status); + const daysUntil = getDaysUntil(event.startDate); + const registeredCount = event.registrations.filter(r => r.status === 'registered').length; + const maxCap = event.capacity ?? event.maxParticipants; + const capacityPct = maxCap ? Math.min((registeredCount / maxCap) * 100, 100) : 0; + const isFull = maxCap ? registeredCount >= maxCap : false; + const userRegistered = isUserRegistered(event); + const TypeIcon = typeConfig.icon; + + return ( + + { setSelectedEvent(event); setShowDetailDialog(true); }}> + {/* Banner */} + {event.bannerImageUrl && ( +
+ {event.title} +
+ )} + {!event.bannerImageUrl && ( +
+ )} + + {/* Header */} +
+
+
+ + + {t(typeConfig.labelKey)} + + + {t(statusConfig.labelKey)} + +
+

{event.title}

+
+ {(isAdmin || (isTeacher && event.organizerId === currentUser?.id)) && ( +
+ + +
+ )} +
+ + {/* Details */} +
+
+ + {formatDate(event.startDate)} + {formatTime(event.startDate)} + {event.endDate && ( + + + {formatDate(event.endDate)} + + )} +
+ {event.location && ( +
+ + {event.location} +
+ )} + {event.organizer && ( +
+ + {event.organizer.firstName} {event.organizer.lastName} +
+ )} +
+ + {/* Registration Info */} + {event.requiresRegistration && ( +
+
+ + {t('se.registered')}: {registeredCount}/{maxCap || '∞'} + + {isFull && ( + + {t('se.full')} + + )} +
+ {maxCap && ( + + )} + {/* Registration Deadline Countdown */} + {event.registrationDeadline && daysUntil > 0 && ( +
+ + {t('se.deadline_in')}: {getDaysUntil(event.registrationDeadline)} {t('se.days')} +
+ )} +
+ )} + + {/* Countdown Badge */} + {daysUntil > 0 && daysUntil <= 7 && event.status === 'published' && ( +
+ + + {t('se.in')} {daysUntil} {t('se.days')} + +
+ )} + + {/* User Actions */} + {(isStudent || isParent) && event.requiresRegistration && event.status === 'published' && ( +
e.stopPropagation()}> + {userRegistered ? ( + + ) : ( + + )} +
+ )} + + {/* Feedback indicator */} + {event.feedbacks.length > 0 && ( +
+ + + {(event.feedbacks.reduce((s, f) => s + f.rating, 0) / event.feedbacks.length).toFixed(1)} + + ({event.feedbacks.length}) +
+ )} +
+ + + ); + })} +
+ )} +
+ )} +
+ + {/* Create/Edit Event Dialog */} + { if (!open) { setShowCreateDialog(false); resetForm(); } }}> + + + + + {editingEvent ? t('se.edit_event') : t('se.create_event')} + + + {editingEvent ? t('se.edit_event_desc') : t('se.create_event_desc')} + + + +
+
+ + setFormData(prev => ({ ...prev, title: e.target.value }))} + placeholder={t('se.event_title_placeholder')} + /> +
+ +
+ +