From 6e5af13778e58cd1e71ebe85bd42498304c81402 Mon Sep 17 00:00:00 2001 From: Mustafa Cetin Date: Wed, 29 Jul 2026 23:42:26 +0000 Subject: [PATCH] Round 23: Report Card Generator, Seating Chart, School Library, Parent Portal Enhancement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Report Card Generator: Professional report cards with PDF export - Subject grades table with competency levels - Competency radar chart, teacher comments - Template management, batch generation - Status workflow: draft → review → published → archived - Seating Chart View: Visual classroom layout - Grid-based classroom with desk positions - Multiple layout templates (rows, groups, U-shape, circle) - Click-to-assign student placement - Randomize and smart arrange features - Student info popover - School Library View: Book lending system - Book catalog with search and filter - Checkout system with due dates and renewals - Overdue tracking with notifications - Reservation queue system - Statistics dashboard with charts - Enhanced Parent Portal: Comprehensive dashboard - Child overview with multiple children selector - Academic progress with competency radar chart - Attendance & illness with approval - Communication, schedule, and reports tabs - Bug fixes: i18n breadcrumb keys for new views - Styling: Gradient headers, animations, hover effects across all new views - 200+ i18n keys added, 15+ API routes, 3+ Prisma models --- prisma/schema.prisma | 163 +- src/app/api/library/books/[id]/route.ts | 144 ++ src/app/api/library/books/route.ts | 163 ++ src/app/api/library/checkouts/[id]/route.ts | 163 ++ src/app/api/library/checkouts/route.ts | 192 ++ .../api/library/reservations/[id]/route.ts | 59 + src/app/api/library/reservations/route.ts | 134 ++ src/app/api/library/stats/route.ts | 142 ++ .../api/parent-portal/child-progress/route.ts | 217 ++ .../api/parent-portal/child-schedule/route.ts | 184 ++ src/app/api/parent-portal/route.ts | 118 +- src/app/api/report-cards/[id]/route.ts | 219 ++ src/app/api/report-cards/export/route.ts | 101 + src/app/api/report-cards/generate/route.ts | 184 ++ src/app/api/report-cards/route.ts | 199 ++ src/app/api/report-cards/templates/route.ts | 95 + .../api/seating-charts/[id]/arrange/route.ts | 226 ++ src/app/api/seating-charts/[id]/route.ts | 233 ++ src/app/api/seating-charts/route.ts | 187 ++ src/app/globals.css | 95 + src/components/app-layout.tsx | 16 + src/components/parent-portal-view.tsx | 1808 +++++++++++----- src/components/report-card-view.tsx | 1877 +++++++++++++++++ src/components/school-library-view.tsx | 1522 +++++++++++++ src/components/seating-chart-view.tsx | 1371 ++++++++++++ src/lib/i18n.ts | 694 ++++++ src/lib/store.ts | 5 +- worklog.md | 235 +++ 28 files changed, 10252 insertions(+), 494 deletions(-) create mode 100644 src/app/api/library/books/[id]/route.ts create mode 100644 src/app/api/library/books/route.ts create mode 100644 src/app/api/library/checkouts/[id]/route.ts create mode 100644 src/app/api/library/checkouts/route.ts create mode 100644 src/app/api/library/reservations/[id]/route.ts create mode 100644 src/app/api/library/reservations/route.ts create mode 100644 src/app/api/library/stats/route.ts create mode 100644 src/app/api/parent-portal/child-progress/route.ts create mode 100644 src/app/api/parent-portal/child-schedule/route.ts create mode 100644 src/app/api/report-cards/[id]/route.ts create mode 100644 src/app/api/report-cards/export/route.ts create mode 100644 src/app/api/report-cards/generate/route.ts create mode 100644 src/app/api/report-cards/route.ts create mode 100644 src/app/api/report-cards/templates/route.ts create mode 100644 src/app/api/seating-charts/[id]/arrange/route.ts create mode 100644 src/app/api/seating-charts/[id]/route.ts create mode 100644 src/app/api/seating-charts/route.ts create mode 100644 src/components/report-card-view.tsx create mode 100644 src/components/school-library-view.tsx create mode 100644 src/components/seating-chart-view.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2c46976..050b758 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -57,6 +57,7 @@ model School { backups Backup[] behaviorInterventions BehaviorIntervention[] reportSchedules ReportSchedule[] + reportCardTemplates ReportCardTemplate[] homeworks Homework[] announcements Announcement[] notificationPreferences NotificationPreferences[] @@ -99,6 +100,10 @@ model School { examPlans ExamPlan[] studyPlans StudyPlan[] studySessions StudySession[] + libraryBooks LibraryBook[] + bookCheckouts BookCheckout[] + bookReservations BookReservation[] + seatingCharts SeatingChart[] } model User { @@ -179,6 +184,9 @@ model User { // Class responsible teacher responsibleClasses ClassGroup[] @relation("ResponsibleTeacher") examPlans ExamPlan[] + seatingCharts SeatingChart[] + bookCheckouts BookCheckout[] + bookReservations BookReservation[] } model SchoolYear { @@ -236,6 +244,7 @@ model ClassGroup { schoolEvents SchoolEvent[] aiTestGenerations AITestGeneration[] examPlans ExamPlan[] + seatingCharts SeatingChart[] } model ClassGroupTeacher { @@ -294,6 +303,8 @@ model Student { studyPlans StudyPlan[] studySessions StudySession[] gradingAnnotations GradingAnnotation[] + bookCheckouts BookCheckout[] + bookReservations BookReservation[] @@index([userId]) } @@ -552,15 +563,41 @@ model Report { period String generatedByUserId String generatedAt DateTime @default(now()) - status String @default("DRAFT") // DRAFT, FINAL + status String @default("DRAFT") // DRAFT, REVIEW, PUBLISHED, ARCHIVED, FINAL pdfFilePath String? includesGrades Boolean @default(false) + // Report card extended fields + teacherComments String? // teacher comments text + attendanceSummary String? // JSON: { present: N, absent: N, excused: N, late: N, total: N } + overallAssessment String? // overall assessment text + templateId String? // report card template reference + reviewedByUserId String? // reviewer user ID + reviewedAt DateTime? // review timestamp + publishedAt DateTime? // publish timestamp + student Student @relation(fields: [studentId], references: [id]) classGroup ClassGroup @relation(fields: [classGroupId], references: [id]) schoolYear SchoolYear @relation(fields: [schoolYearId], references: [id]) generatedByUser User @relation(fields: [generatedByUserId], references: [id]) sections ReportSection[] + template ReportCardTemplate? @relation(fields: [templateId], references: [id]) +} + +model ReportCardTemplate { + id String @id @default(cuid()) + schoolId String + name String + description String? + sections String @default("[]") // JSON: array of section definitions + gradingScale String @default("{}") + layout String @default("default") // default, detailed, compact + isDefault Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + reports Report[] } model ReportSection { @@ -2642,3 +2679,127 @@ model StudySession { @@index([status]) @@map("study_sessions") } + +// ─── School Library ────────────────────────────────────────────────── + +model LibraryBook { + id String @id @default(cuid()) + schoolId String + title String + author String + isbn String? + publisher String? + publishYear Int? + category String // Fiction, Non-Fiction, Science, History, etc. + readingLevel String? // elementary, middle, high, advanced + language String @default("de") + description String? + coverGradient String? // JSON: {from, to} gradient colors for placeholder + totalCopies Int @default(1) + availableCopies Int @default(1) + location String? // Shelf/room location + tags String? // JSON array of tag strings + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + checkouts BookCheckout[] + reservations BookReservation[] + + @@index([schoolId]) + @@index([category]) + @@index([readingLevel]) + @@index([author]) + @@map("library_books") +} + +model BookCheckout { + id String @id @default(cuid()) + schoolId String + bookId String + studentId String + checkedOutBy String // userId of the person who processed the checkout + checkoutDate DateTime @default(now()) + dueDate DateTime + returnDate DateTime? + condition String @default("good") // excellent, good, fair, poor + returnCondition String? // Condition upon return + renewalCount Int @default(0) + status String @default("active") // active, returned, overdue, lost + fineAmount Float @default(0) + finePaid Boolean @default(false) + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + book LibraryBook @relation(fields: [bookId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + checkedOutByUser User @relation(fields: [checkedOutBy], references: [id]) + + @@index([schoolId]) + @@index([bookId]) + @@index([studentId]) + @@index([status]) + @@index([dueDate]) + @@map("book_checkouts") +} + +model BookReservation { + id String @id @default(cuid()) + schoolId String + bookId String + studentId String + userId String // The user who made the reservation (could be parent) + queuePosition Int @default(1) + status String @default("waiting") // waiting, notified, fulfilled, cancelled + notifiedAt DateTime? + fulfilledAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + book LibraryBook @relation(fields: [bookId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@index([schoolId]) + @@index([bookId]) + @@index([studentId]) + @@index([status]) + @@map("book_reservations") +} + +// ─── Seating Charts ────────────────────────────────────────────────── + +model SeatingChart { + id String @id @default(cuid()) + schoolId String + classGroupId String + teacherId String + name String + layoutType String @default("rows") // rows, groups, u-shape, circle, custom + rows Int @default(5) + columns Int @default(5) + gap Int @default(2) // Gap between desks (in grid units) + arrangement String? // JSON: [{ studentId, row, col }] — student seat assignments + showDoor Boolean @default(true) + showWindows Boolean @default(true) + doorPosition String @default("left") // left, right, back + windowPosition String @default("right") // left, right, both + isTemplate Boolean @default(false) + isDefault Boolean @default(false) + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + classGroup ClassGroup @relation(fields: [classGroupId], references: [id]) + teacher User @relation(fields: [teacherId], references: [id]) + + @@index([schoolId]) + @@index([classGroupId]) + @@index([teacherId]) + @@map("seating_charts") +} diff --git a/src/app/api/library/books/[id]/route.ts b/src/app/api/library/books/[id]/route.ts new file mode 100644 index 0000000..5e48401 --- /dev/null +++ b/src/app/api/library/books/[id]/route.ts @@ -0,0 +1,144 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: Get a single book ────────────────────────────────────────── +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 book = await db.libraryBook.findUnique({ + where: { id }, + include: { + checkouts: { + where: { status: { in: ['active', 'overdue'] } }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { checkoutDate: 'desc' }, + }, + reservations: { + where: { status: 'waiting' }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + user: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { createdAt: 'asc' }, + }, + }, + }); + + if (!book) { + return NextResponse.json({ error: 'Book not found' }, { status: 404 }); + } + + return NextResponse.json(book); + } catch (error) { + console.error('LibraryBook GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── PUT: Update a book ────────────────────────────────────────────── +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const userRole = session.user?.role; + if ( + userRole !== 'SCHOOL_ADMIN' && + userRole !== 'VICE_PRINCIPAL' && + userRole !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json(); + + const existing = await db.libraryBook.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Book not found' }, { status: 404 }); + } + + const updateData: Record = {}; + const allowedFields = [ + 'title', 'author', 'isbn', 'publisher', 'publishYear', + 'category', 'readingLevel', 'language', 'description', + 'totalCopies', 'availableCopies', 'location', + ]; + + for (const field of allowedFields) { + if (body[field] !== undefined) updateData[field] = body[field]; + } + + if (body.coverGradient) updateData.coverGradient = JSON.stringify(body.coverGradient); + if (body.tags) updateData.tags = JSON.stringify(body.tags); + + const book = await db.libraryBook.update({ + where: { id }, + data: updateData, + }); + + return NextResponse.json(book); + } catch (error) { + console.error('LibraryBook PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── DELETE: Remove a book ────────────────────────────────────────── +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 userRole = session.user?.role; + if ( + userRole !== 'SCHOOL_ADMIN' && + userRole !== 'VICE_PRINCIPAL' && + userRole !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + + // Check for active checkouts + const activeCheckouts = await db.bookCheckout.count({ + where: { bookId: id, status: { in: ['active', 'overdue'] } }, + }); + + if (activeCheckouts > 0) { + return NextResponse.json( + { error: 'Cannot delete book with active checkouts' }, + { status: 400 } + ); + } + + await db.libraryBook.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('LibraryBook DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/library/books/route.ts b/src/app/api/library/books/route.ts new file mode 100644 index 0000000..bd27889 --- /dev/null +++ b/src/app/api/library/books/route.ts @@ -0,0 +1,163 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: List/search books ────────────────────────────────────────── +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined; + const search = searchParams.get('search'); + const category = searchParams.get('category'); + const readingLevel = searchParams.get('readingLevel'); + const availability = searchParams.get('availability'); // all, available, checked_out + const limit = parseInt(searchParams.get('limit') ?? '50'); + const offset = parseInt(searchParams.get('offset') ?? '0'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + const where: Record = { schoolId }; + + if (search) { + where.OR = [ + { title: { contains: search } }, + { author: { contains: search } }, + { isbn: { contains: search } }, + { category: { contains: search } }, + ]; + } + + if (category) where.category = category; + if (readingLevel) where.readingLevel = readingLevel; + + if (availability === 'available') { + where.availableCopies = { gt: 0 }; + } else if (availability === 'checked_out') { + where.availableCopies = 0; + } + + const [books, total] = await Promise.all([ + db.libraryBook.findMany({ + where, + include: { + _count: { select: { checkouts: true, reservations: true } }, + }, + orderBy: { title: 'asc' }, + take: limit, + skip: offset, + }), + db.libraryBook.count({ where }), + ]); + + // Get active checkouts count per book + const activeCheckouts = await db.bookCheckout.groupBy({ + by: ['bookId'], + where: { schoolId, status: 'active' }, + _count: { id: true }, + }); + + const activeCheckoutMap = Object.fromEntries( + activeCheckouts.map((c) => [c.bookId, c._count.id]) + ); + + // Get waiting reservations count per book + const waitingReservations = await db.bookReservation.groupBy({ + by: ['bookId'], + where: { schoolId, status: 'waiting' }, + _count: { id: true }, + }); + + const waitingReservationMap = Object.fromEntries( + waitingReservations.map((r) => [r.bookId, r._count.id]) + ); + + const enriched = books.map((book) => ({ + ...book, + activeCheckouts: activeCheckoutMap[book.id] || 0, + waitingReservations: waitingReservationMap[book.id] || 0, + isAvailable: book.availableCopies > 0, + })); + + return NextResponse.json({ books: enriched, total }); + } catch (error) { + console.error('LibraryBooks GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── POST: Add a new book ────────────────────────────────────────── +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const userRole = session.user?.role; + if ( + userRole !== 'SCHOOL_ADMIN' && + userRole !== 'VICE_PRINCIPAL' && + userRole !== 'SUPER_ADMIN' && + userRole !== 'TEACHER' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { + title, + author, + isbn, + publisher, + publishYear, + category, + readingLevel, + language, + description, + coverGradient, + totalCopies, + location, + tags, + schoolId, + } = body; + + if (!title || !author || !schoolId || !category) { + return NextResponse.json( + { error: 'title, author, schoolId, and category are required' }, + { status: 400 } + ); + } + + const book = await db.libraryBook.create({ + data: { + schoolId, + title, + author, + isbn: isbn || null, + publisher: publisher || null, + publishYear: publishYear || null, + category, + readingLevel: readingLevel || null, + language: language || 'de', + description: description || null, + coverGradient: coverGradient ? JSON.stringify(coverGradient) : null, + totalCopies: totalCopies ?? 1, + availableCopies: totalCopies ?? 1, + location: location || null, + tags: tags ? JSON.stringify(tags) : null, + }, + }); + + return NextResponse.json(book, { status: 201 }); + } catch (error) { + console.error('LibraryBooks POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/library/checkouts/[id]/route.ts b/src/app/api/library/checkouts/[id]/route.ts new file mode 100644 index 0000000..7e71a78 --- /dev/null +++ b/src/app/api/library/checkouts/[id]/route.ts @@ -0,0 +1,163 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── PUT: Return a book (or renew) ────────────────────────────────── +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const userRole = session.user?.role; + if ( + userRole !== 'TEACHER' && + userRole !== 'SCHOOL_ADMIN' && + userRole !== 'VICE_PRINCIPAL' && + userRole !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json(); + const { action, returnCondition, notes, fineAmount } = body; + + const checkout = await db.bookCheckout.findUnique({ + where: { id }, + include: { book: true }, + }); + + if (!checkout) { + return NextResponse.json({ error: 'Checkout not found' }, { status: 404 }); + } + + if (action === 'return') { + // Return the book + const updated = await db.bookCheckout.update({ + where: { id }, + data: { + status: 'returned', + returnDate: new Date(), + returnCondition: returnCondition || checkout.condition, + notes: notes || checkout.notes, + fineAmount: fineAmount ?? checkout.fineAmount, + }, + include: { + book: { select: { id: true, title: true, author: true } }, + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + // Increase available copies + await db.libraryBook.update({ + where: { id: checkout.bookId }, + data: { availableCopies: { increment: 1 } }, + }); + + // Check if there are waiting reservations to notify + const nextReservation = await db.bookReservation.findFirst({ + where: { bookId: checkout.bookId, status: 'waiting' }, + orderBy: { createdAt: 'asc' }, + }); + + if (nextReservation) { + await db.bookReservation.update({ + where: { id: nextReservation.id }, + data: { status: 'notified', notifiedAt: new Date() }, + }); + } + + return NextResponse.json(updated); + } else if (action === 'renew') { + // Renew the checkout (extend due date by 14 days) + if (checkout.status === 'returned' || checkout.status === 'lost') { + return NextResponse.json( + { error: 'Cannot renew a returned or lost checkout' }, + { status: 400 } + ); + } + + if (checkout.renewalCount >= 3) { + return NextResponse.json( + { error: 'Maximum renewals reached (3)' }, + { status: 400 } + ); + } + + const newDueDate = new Date(checkout.dueDate); + newDueDate.setDate(newDueDate.getDate() + 14); + + const updated = await db.bookCheckout.update({ + where: { id }, + data: { + dueDate: newDueDate, + renewalCount: { increment: 1 }, + status: 'active', + }, + include: { + book: { select: { id: true, title: true, author: true } }, + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(updated); + } else if (action === 'mark_overdue') { + const updated = await db.bookCheckout.update({ + where: { id }, + data: { status: 'overdue' }, + }); + return NextResponse.json(updated); + } else if (action === 'mark_lost') { + const updated = await db.bookCheckout.update({ + where: { id }, + data: { status: 'lost' }, + }); + // Decrease total copies + await db.libraryBook.update({ + where: { id: checkout.bookId }, + data: { totalCopies: { decrement: 1 } }, + }); + return NextResponse.json(updated); + } + + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); + } catch (error) { + console.error('BookCheckout PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── DELETE: Remove a checkout record ────────────────────────────── +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 userRole = session.user?.role; + if ( + userRole !== 'SCHOOL_ADMIN' && + userRole !== 'VICE_PRINCIPAL' && + userRole !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + await db.bookCheckout.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('BookCheckout DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/library/checkouts/route.ts b/src/app/api/library/checkouts/route.ts new file mode 100644 index 0000000..af6a709 --- /dev/null +++ b/src/app/api/library/checkouts/route.ts @@ -0,0 +1,192 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: List checkouts ────────────────────────────────────────────── +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined; + const status = searchParams.get('status'); + const studentId = searchParams.get('studentId'); + const bookId = searchParams.get('bookId'); + const overdue = searchParams.get('overdue') === 'true'; + const limit = parseInt(searchParams.get('limit') ?? '50'); + const offset = parseInt(searchParams.get('offset') ?? '0'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + const where: Record = { schoolId }; + + if (status) where.status = status; + if (studentId) where.studentId = studentId; + if (bookId) where.bookId = bookId; + + if (overdue) { + where.status = 'active'; + where.dueDate = { lt: new Date() }; + } + + // Role-based filtering + if (session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ + where: { userId: session.userId }, + select: { id: true }, + }); + if (student) { + where.studentId = student.id; + } + } else if (session.user?.role === 'PARENT') { + const parentLinks = await db.parentStudentLink.findMany({ + where: { parentId: session.userId }, + select: { studentId: true }, + }); + const studentIds = parentLinks.map((l) => l.studentId); + where.studentId = { in: studentIds }; + } + + const [checkouts, total] = await Promise.all([ + db.bookCheckout.findMany({ + where, + include: { + book: { select: { id: true, title: true, author: true, category: true } }, + student: { select: { id: true, firstName: true, lastName: true } }, + checkedOutByUser: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { checkoutDate: 'desc' }, + take: limit, + skip: offset, + }), + db.bookCheckout.count({ where }), + ]); + + // Mark overdue checkouts + const now = new Date(); + const enriched = checkouts.map((checkout) => { + const isOverdue = checkout.status === 'active' && new Date(checkout.dueDate) < now; + const daysOverdue = isOverdue + ? Math.floor((now.getTime() - new Date(checkout.dueDate).getTime()) / (1000 * 60 * 60 * 24)) + : 0; + return { + ...checkout, + isOverdue, + daysOverdue, + }; + }); + + // Auto-update overdue status in the background + if (status === 'active' || !status) { + const overdueIds = enriched + .filter((c) => c.isOverdue && c.status === 'active') + .map((c) => c.id); + + if (overdueIds.length > 0) { + await db.bookCheckout.updateMany({ + where: { id: { in: overdueIds } }, + data: { status: 'overdue' }, + }); + } + } + + return NextResponse.json({ checkouts: enriched, total }); + } catch (error) { + console.error('BookCheckouts GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── POST: Checkout a book ────────────────────────────────────────── +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const userRole = session.user?.role; + if ( + userRole !== 'TEACHER' && + userRole !== 'SCHOOL_ADMIN' && + userRole !== 'VICE_PRINCIPAL' && + userRole !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { bookId, studentId, schoolId, dueDate, condition, notes } = body; + + if (!bookId || !studentId || !schoolId) { + return NextResponse.json( + { error: 'bookId, studentId, and schoolId are required' }, + { status: 400 } + ); + } + + // Check book availability + const book = await db.libraryBook.findUnique({ where: { id: bookId } }); + if (!book) { + return NextResponse.json({ error: 'Book not found' }, { status: 404 }); + } + + if (book.availableCopies <= 0) { + return NextResponse.json({ error: 'No copies available' }, { status: 400 }); + } + + // Check if student already has this book checked out + const existingCheckout = await db.bookCheckout.findFirst({ + where: { + bookId, + studentId, + status: { in: ['active', 'overdue'] }, + }, + }); + + if (existingCheckout) { + return NextResponse.json( + { error: 'Student already has this book checked out' }, + { status: 400 } + ); + } + + // Calculate due date (default 14 days from now) + const checkoutDueDate = dueDate + ? new Date(dueDate) + : new Date(Date.now() + 14 * 24 * 60 * 60 * 1000); + + const checkout = await db.bookCheckout.create({ + data: { + schoolId, + bookId, + studentId, + checkedOutBy: session.userId, + dueDate: checkoutDueDate, + condition: condition || 'good', + notes: notes || null, + }, + include: { + book: { select: { id: true, title: true, author: true } }, + student: { select: { id: true, firstName: true, lastName: true } }, + checkedOutByUser: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + // Decrease available copies + await db.libraryBook.update({ + where: { id: bookId }, + data: { availableCopies: { decrement: 1 } }, + }); + + return NextResponse.json(checkout, { status: 201 }); + } catch (error) { + console.error('BookCheckout POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/library/reservations/[id]/route.ts b/src/app/api/library/reservations/[id]/route.ts new file mode 100644 index 0000000..a169f06 --- /dev/null +++ b/src/app/api/library/reservations/[id]/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── DELETE: Cancel a reservation ────────────────────────────────── +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 reservation = await db.bookReservation.findUnique({ + where: { id }, + }); + + if (!reservation) { + return NextResponse.json({ error: 'Reservation not found' }, { status: 404 }); + } + + // Only the user who made the reservation, or admin, can cancel + const userRole = session.user?.role; + const isOwner = reservation.userId === session.userId; + const isAdmin = userRole === 'SCHOOL_ADMIN' || userRole === 'VICE_PRINCIPAL' || userRole === 'SUPER_ADMIN'; + + if (!isOwner && !isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + await db.bookReservation.update({ + where: { id }, + data: { status: 'cancelled' }, + }); + + // Re-calculate queue positions for remaining reservations + const remaining = await db.bookReservation.findMany({ + where: { bookId: reservation.bookId, status: 'waiting' }, + orderBy: { createdAt: 'asc' }, + }); + + await Promise.all( + remaining.map((r, index) => + db.bookReservation.update({ + where: { id: r.id }, + data: { queuePosition: index + 1 }, + }) + ) + ); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('BookReservation DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/library/reservations/route.ts b/src/app/api/library/reservations/route.ts new file mode 100644 index 0000000..49643f8 --- /dev/null +++ b/src/app/api/library/reservations/route.ts @@ -0,0 +1,134 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: List reservations ────────────────────────────────────────── +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined; + const bookId = searchParams.get('bookId'); + const studentId = searchParams.get('studentId'); + const status = searchParams.get('status'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + const where: Record = { schoolId }; + + if (bookId) where.bookId = bookId; + if (studentId) where.studentId = studentId; + if (status) where.status = status; + + // Role-based filtering + if (session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ + where: { userId: session.userId }, + select: { id: true }, + }); + if (student) { + where.studentId = student.id; + } + } else if (session.user?.role === 'PARENT') { + const parentLinks = await db.parentStudentLink.findMany({ + where: { parentId: session.userId }, + select: { studentId: true }, + }); + const studentIds = parentLinks.map((l) => l.studentId); + where.studentId = { in: studentIds }; + } + + const reservations = await db.bookReservation.findMany({ + where, + include: { + book: { select: { id: true, title: true, author: true, availableCopies: true, totalCopies: true } }, + student: { select: { id: true, firstName: true, lastName: true } }, + user: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { createdAt: 'asc' }, + }); + + return NextResponse.json({ reservations }); + } catch (error) { + console.error('BookReservations GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── POST: Reserve a book ────────────────────────────────────────── +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.json(); + const { bookId, studentId, schoolId } = body; + + if (!bookId || !studentId || !schoolId) { + return NextResponse.json( + { error: 'bookId, studentId, and schoolId are required' }, + { status: 400 } + ); + } + + // Check book exists + const book = await db.libraryBook.findUnique({ where: { id: bookId } }); + if (!book) { + return NextResponse.json({ error: 'Book not found' }, { status: 404 }); + } + + // Check if already reserved + const existingReservation = await db.bookReservation.findFirst({ + where: { + bookId, + studentId, + status: { in: ['waiting', 'notified'] }, + }, + }); + + if (existingReservation) { + return NextResponse.json( + { error: 'Already reserved this book' }, + { status: 400 } + ); + } + + // Calculate queue position + const maxPosition = await db.bookReservation.findFirst({ + where: { bookId, status: 'waiting' }, + orderBy: { queuePosition: 'desc' }, + select: { queuePosition: true }, + }); + + const queuePosition = (maxPosition?.queuePosition ?? 0) + 1; + + const reservation = await db.bookReservation.create({ + data: { + schoolId, + bookId, + studentId, + userId: session.userId, + queuePosition, + status: 'waiting', + }, + include: { + book: { select: { id: true, title: true, author: true } }, + student: { select: { id: true, firstName: true, lastName: true } }, + user: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(reservation, { status: 201 }); + } catch (error) { + console.error('BookReservation POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/library/stats/route.ts b/src/app/api/library/stats/route.ts new file mode 100644 index 0000000..fd13843 --- /dev/null +++ b/src/app/api/library/stats/route.ts @@ -0,0 +1,142 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: Library statistics ────────────────────────────────────────── +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined; + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + const now = new Date(); + + // Basic counts + const [ + totalBooks, + totalCopies, + availableCopies, + activeCheckouts, + overdueCheckouts, + totalCheckouts, + totalReservations, + waitingReservations, + ] = await Promise.all([ + db.libraryBook.count({ where: { schoolId } }), + db.libraryBook.aggregate({ where: { schoolId }, _sum: { totalCopies: true } }), + db.libraryBook.aggregate({ where: { schoolId }, _sum: { availableCopies: true } }), + db.bookCheckout.count({ where: { schoolId, status: 'active' } }), + db.bookCheckout.count({ where: { schoolId, status: 'overdue' } }), + db.bookCheckout.count({ where: { schoolId } }), + db.bookReservation.count({ where: { schoolId } }), + db.bookReservation.count({ where: { schoolId, status: 'waiting' } }), + ]); + + // Overdue rate + const overdueRate = totalCheckouts > 0 + ? Math.round((overdueCheckouts / totalCheckouts) * 100) + : 0; + + // Most popular books (by checkout count) + const popularBooks = await db.bookCheckout.groupBy({ + by: ['bookId'], + where: { schoolId }, + _count: { id: true }, + orderBy: { _count: { id: 'desc' } }, + take: 10, + }); + + const popularBookIds = popularBooks.map((b) => b.bookId); + const popularBookDetails = await db.libraryBook.findMany({ + where: { id: { in: popularBookIds } }, + select: { id: true, title: true, author: true, category: true }, + }); + + const popularBookMap = Object.fromEntries( + popularBookDetails.map((b) => [b.id, b]) + ); + + const popularBooksWithCount = popularBooks.map((b) => ({ + ...popularBookMap[b.bookId], + checkoutCount: b._count.id, + })); + + // Category distribution + const categoryDistribution = await db.libraryBook.groupBy({ + by: ['category'], + where: { schoolId }, + _count: { id: true }, + }); + + // Checkout trends (last 30 days, by day) + const thirtyDaysAgo = new Date(now); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const recentCheckouts = await db.bookCheckout.findMany({ + where: { + schoolId, + checkoutDate: { gte: thirtyDaysAgo }, + }, + select: { checkoutDate: true }, + }); + + // Group by day + const checkoutTrends: Record = {}; + for (const checkout of recentCheckouts) { + const dateKey = new Date(checkout.checkoutDate).toISOString().split('T')[0]; + checkoutTrends[dateKey] = (checkoutTrends[dateKey] || 0) + 1; + } + + const trendData = Object.entries(checkoutTrends) + .map(([date, count]) => ({ date, count })) + .sort((a, b) => a.date.localeCompare(b.date)); + + // Overdue checkouts list + const overdueList = await db.bookCheckout.findMany({ + where: { schoolId, status: 'overdue' }, + include: { + book: { select: { id: true, title: true, author: true } }, + student: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { dueDate: 'asc' }, + take: 20, + }); + + const overdueWithDays = overdueList.map((checkout) => { + const daysOverdue = Math.floor( + (now.getTime() - new Date(checkout.dueDate).getTime()) / (1000 * 60 * 60 * 24) + ); + return { ...checkout, daysOverdue }; + }); + + return NextResponse.json({ + totalBooks, + totalCopies: totalCopies._sum.totalCopies || 0, + availableCopies: availableCopies._sum.availableCopies || 0, + checkedOut: activeCheckouts, + overdue: overdueCheckouts, + totalCheckouts, + totalReservations, + waitingReservations, + overdueRate, + popularBooks: popularBooksWithCount, + categoryDistribution: categoryDistribution.map((c) => ({ + category: c.category, + count: c._count.id, + })), + checkoutTrends: trendData, + overdueList: overdueWithDays, + }); + } catch (error) { + console.error('LibraryStats GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/parent-portal/child-progress/route.ts b/src/app/api/parent-portal/child-progress/route.ts new file mode 100644 index 0000000..5901dd0 --- /dev/null +++ b/src/app/api/parent-portal/child-progress/route.ts @@ -0,0 +1,217 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// GET: Returns detailed academic progress for a child +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 !== 'PARENT') { + return NextResponse.json({ error: 'Forbidden - Parent role required' }, { status: 403 }); + } + + const parentId = session.userId; + const schoolId = session.user?.schoolId; + const { searchParams } = new URL(request.url); + const studentId = searchParams.get('studentId'); + + if (!studentId || !schoolId) { + return NextResponse.json({ error: 'studentId and schoolId are required' }, { status: 400 }); + } + + // Verify parent is linked to this student + const parentLink = await db.parentStudentLink.findFirst({ + where: { parentId, studentId }, + }); + if (!parentLink) { + return NextResponse.json({ error: 'You can only view progress for your children' }, { status: 403 }); + } + + // Get all computed grades for the student + const grades = await db.computedGrade.findMany({ + where: { studentId }, + include: { + subject: { select: { id: true, name: true, code: true } }, + classGroup: { select: { id: true, name: true } }, + schoolYear: { select: { id: true, name: true } }, + }, + orderBy: { computedAt: 'desc' }, + }); + + // Group grades by subject + const subjectGrades: Record; + average: number; + trend: 'up' | 'down' | 'stable'; + }> = {}; + + for (const grade of grades) { + const sid = grade.subjectId; + if (!subjectGrades[sid]) { + subjectGrades[sid] = { + subject: grade.subject, + grades: [], + average: 0, + trend: 'stable', + }; + } + subjectGrades[sid].grades.push({ + id: grade.id, + computedValue: grade.computedValue, + period: grade.period, + computedAt: grade.computedAt.toISOString(), + classGroup: grade.classGroup, + schoolYear: grade.schoolYear, + }); + } + + // Calculate averages and trends + for (const sid of Object.keys(subjectGrades)) { + const sg = subjectGrades[sid]; + const values = sg.grades.map((g) => g.computedValue); + sg.average = values.length > 0 + ? Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 10) / 10 + : 0; + + // Trend: compare last two periods + if (values.length >= 2) { + const latest = values[0]; + const previous = values[1]; + if (latest > previous + 0.3) sg.trend = 'up'; + else if (latest < previous - 0.3) sg.trend = 'down'; + else sg.trend = 'stable'; + } + } + + // Class averages for comparison + const studentEnrollments = await db.enrollment.findMany({ + where: { studentId, endDate: null }, + select: { classGroupId: true }, + }); + const classIds = studentEnrollments.map((e) => e.classGroupId); + + const classAverages: Record = {}; + if (classIds.length > 0) { + const classGrades = await db.computedGrade.findMany({ + where: { + classGroupId: { in: classIds }, + student: { enrollments: { some: { classGroupId: { in: classIds }, endDate: null } } }, + }, + include: { subject: { select: { id: true } } }, + }); + + const bySubject: Record = {}; + for (const cg of classGrades) { + const sid = cg.subjectId; + if (!bySubject[sid]) bySubject[sid] = []; + bySubject[sid].push(cg.computedValue); + } + for (const [sid, vals] of Object.entries(bySubject)) { + classAverages[sid] = Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 10) / 10; + } + } + + // Competency progress data for radar chart + const learningProgress = await db.learningProgress.findMany({ + where: { studentId }, + include: { + competency: { select: { id: true, code: true, title: true, subject: { select: { id: true, name: true } } } }, + }, + orderBy: { assessedAt: 'desc' }, + }); + + // Group by subject for radar chart + const competencyBySubject: Record; + averageLevel: number; + }> = {}; + + for (const lp of learningProgress) { + const sid = lp.competency.subject?.id ?? 'unknown'; + if (!competencyBySubject[sid]) { + competencyBySubject[sid] = { + subject: lp.competency.subject ?? { id: sid, name: 'Unknown' }, + competencies: [], + averageLevel: 0, + }; + } + // Only keep the latest for each competency + const existing = competencyBySubject[sid].competencies.find((c) => c.id === lp.competencyId); + if (!existing) { + competencyBySubject[sid].competencies.push({ + id: lp.competencyId, + code: lp.competency.code, + title: lp.competency.title, + level: lp.level, + assessedAt: lp.assessedAt.toISOString(), + }); + } + } + + for (const sid of Object.keys(competencyBySubject)) { + const cs = competencyBySubject[sid]; + const levels = cs.competencies.map((c) => c.level); + cs.averageLevel = levels.length > 0 + ? Math.round((levels.reduce((a, b) => a + b, 0) / levels.length) * 10) / 10 + : 0; + } + + // Assessment results + const assessments = await db.assessmentResult.findMany({ + where: { + studentId, + assessment: { schoolId }, + }, + include: { + assessment: { + select: { + id: true, + title: true, + type: true, + date: true, + subject: { select: { id: true, name: true } }, + }, + }, + }, + orderBy: { assessedAt: 'desc' }, + take: 20, + }); + + return NextResponse.json({ + subjectGrades: Object.values(subjectGrades), + classAverages, + competencyBySubject: Object.values(competencyBySubject), + assessments: assessments.map((a) => ({ + id: a.id, + score: a.score, + maxScore: a.maxScore, + grade: a.grade, + feedback: a.feedback, + assessedAt: a.assessedAt.toISOString(), + assessment: a.assessment, + })), + }); + } catch (error) { + console.error('Child Progress GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/parent-portal/child-schedule/route.ts b/src/app/api/parent-portal/child-schedule/route.ts new file mode 100644 index 0000000..cf726a8 --- /dev/null +++ b/src/app/api/parent-portal/child-schedule/route.ts @@ -0,0 +1,184 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { differenceInCalendarDays } from 'date-fns'; + +// GET: Returns the weekly schedule for a child +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 !== 'PARENT') { + return NextResponse.json({ error: 'Forbidden - Parent role required' }, { status: 403 }); + } + + const parentId = session.userId; + const schoolId = session.user?.schoolId; + const { searchParams } = new URL(request.url); + const studentId = searchParams.get('studentId'); + + if (!studentId || !schoolId) { + return NextResponse.json({ error: 'studentId and schoolId are required' }, { status: 400 }); + } + + // Verify parent is linked to this student + const parentLink = await db.parentStudentLink.findFirst({ + where: { parentId, studentId }, + }); + if (!parentLink) { + return NextResponse.json({ error: 'You can only view schedule for your children' }, { status: 403 }); + } + + // Get student's class groups + const enrollments = await db.enrollment.findMany({ + where: { studentId, endDate: null }, + select: { classGroupId: true, classGroup: { select: { id: true, name: true } } }, + }); + const classIds = enrollments.map((e) => e.classGroupId); + + // Timetable slots + const timetableSlots = classIds.length > 0 + ? await db.timetableSlot.findMany({ + where: { + schoolId, + classGroupId: { in: classIds }, + deletedAt: null, + }, + include: { + subject: { select: { id: true, name: true, code: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + classGroup: { select: { id: true, name: true } }, + }, + orderBy: [{ dayOfWeek: 'asc' }, { period: 'asc' }], + }) + : []; + + // Upcoming exams + const upcomingExams = classIds.length > 0 + ? await db.calendarEvent.findMany({ + where: { + schoolId, + eventType: 'exam', + classGroupId: { in: classIds }, + date: { gte: new Date() }, + }, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + }, + orderBy: { date: 'asc' }, + take: 15, + }) + : []; + + // School events + const schoolEvents = await db.schoolEvent.findMany({ + where: { + schoolId, + deletedAt: null, + startDate: { gte: new Date() }, + }, + orderBy: { startDate: 'asc' }, + take: 10, + select: { + id: true, + title: true, + description: true, + startDate: true, + endDate: true, + eventType: true, + location: true, + organizer: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + // Counseling appointments + const student = await db.student.findUnique({ + where: { id: studentId }, + select: { userId: true }, + }); + + const counselingAppointments = student?.userId + ? await db.counselingAppointment.findMany({ + where: { studentId: student.userId, schoolId }, + orderBy: { scheduledAt: 'desc' }, + take: 10, + select: { + id: true, + requestType: true, + status: true, + scheduledAt: true, + description: true, + counselor: { select: { firstName: true, lastName: true } }, + }, + }) + : []; + + // Homework due + const homeworkDue = classIds.length > 0 + ? await db.homework.findMany({ + where: { + schoolId, + classGroupId: { in: classIds }, + dueDate: { gte: new Date() }, + isPublished: true, + deletedAt: null, + }, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + }, + orderBy: { dueDate: 'asc' }, + take: 10, + }) + : []; + + return NextResponse.json({ + timetableSlots: timetableSlots.map((s) => ({ + id: s.id, + dayOfWeek: s.dayOfWeek, + period: s.period, + startTime: s.startTime, + endTime: s.endTime, + isBreak: s.isBreak, + subject: s.subject ? { id: s.subject.id, name: s.subject.name, code: s.subject.code } : null, + teacher: s.teacher ? { id: s.teacher.id, firstName: s.teacher.firstName, lastName: s.teacher.lastName } : null, + classGroup: { id: s.classGroup.id, name: s.classGroup.name }, + })), + upcomingExams: upcomingExams.map((exam) => ({ + id: exam.id, + title: exam.title, + date: exam.date.toISOString(), + startTime: exam.startTime, + endTime: exam.endTime, + subject: exam.subject ? { id: exam.subject.id, name: exam.subject.name } : null, + classGroup: exam.classGroup ? { id: exam.classGroup.id, name: exam.classGroup.name } : null, + daysUntil: differenceInCalendarDays(new Date(exam.date), new Date()), + })), + schoolEvents: schoolEvents.map((e) => ({ + ...e, + startDate: e.startDate.toISOString(), + endDate: e.endDate?.toISOString() ?? null, + })), + counselingAppointments: counselingAppointments.map((a) => ({ + ...a, + scheduledAt: a.scheduledAt?.toISOString() ?? null, + })), + homeworkDue: homeworkDue.map((h) => ({ + id: h.id, + title: h.title, + description: h.description, + dueDate: h.dueDate.toISOString(), + homeworkType: h.homeworkType, + subject: h.subject ? { id: h.subject.id, name: h.subject.name } : null, + classGroup: { id: h.classGroup.id, name: h.classGroup.name }, + })), + }); + } catch (error) { + console.error('Child Schedule GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/parent-portal/route.ts b/src/app/api/parent-portal/route.ts index 4ecb5b1..9c5f753 100644 --- a/src/app/api/parent-portal/route.ts +++ b/src/app/api/parent-portal/route.ts @@ -59,6 +59,7 @@ export async function GET(request: Request) { for (const child of children) { const studentId = child.studentId; + const classIds = child.classGroups.map((c) => c.id); // Illness reports const illnessReports = await db.illnessReport.findMany({ @@ -74,12 +75,12 @@ export async function GET(request: Request) { parentApprovalStatus: true, status: true, reporterType: true, + documentUrl: true, createdAt: true, }, }); // Upcoming exams (calendar events with type 'exam') - const classIds = child.classGroups.map((c) => c.id); const upcomingExams = classIds.length > 0 ? await db.calendarEvent.findMany({ where: { @@ -157,6 +158,24 @@ export async function GET(request: Request) { }, }); + // Homework due + const homeworkDue = classIds.length > 0 + ? await db.homework.findMany({ + where: { + schoolId, + classGroupId: { in: classIds }, + dueDate: { gte: new Date() }, + isPublished: true, + deletedAt: null, + }, + include: { + subject: { select: { id: true, name: true } }, + }, + orderBy: { dueDate: 'asc' }, + take: 10, + }) + : []; + childrenData.push({ ...child, illnessReports, @@ -168,6 +187,12 @@ export async function GET(request: Request) { attendanceSummary, counselingAppointments, disciplinaryCases, + homeworkDue: homeworkDue.map((h) => ({ + id: h.id, + title: h.title, + dueDate: h.dueDate.toISOString(), + subject: h.subject ? { id: h.subject.id, name: h.subject.name } : null, + })), }); } @@ -184,9 +209,100 @@ export async function GET(request: Request) { orderBy: { createdAt: 'desc' }, }); + // School announcements for parents + const announcements = await db.announcement.findMany({ + where: { + schoolId, + deletedAt: null, + OR: [ + { targetAudience: 'all' }, + { targetAudience: 'parents' }, + ], + OR: [ + { expiresAt: null }, + { expiresAt: { gt: new Date() } }, + ], + }, + orderBy: { createdAt: 'desc' }, + take: 10, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + // Communication rooms for this parent's children + const childUserIds = children + .filter((c) => c.userId) + .map((c) => c.userId as string); + + const communicationRooms = childUserIds.length > 0 + ? await db.communicationRoom.findMany({ + where: { + studentId: { in: childUserIds }, + status: { in: ['requested', 'active'] }, + }, + orderBy: { updatedAt: 'desc' }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + messages: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { id: true, content: true, createdAt: true, senderId: true }, + }, + }, + }) + : []; + + // School events + const schoolEvents = await db.schoolEvent.findMany({ + where: { + schoolId, + deletedAt: null, + startDate: { gte: new Date() }, + }, + orderBy: { startDate: 'asc' }, + take: 5, + select: { + id: true, + title: true, + startDate: true, + eventType: true, + location: true, + }, + }); + return NextResponse.json({ children: childrenData, pendingApprovals, + announcements: announcements.map((a) => ({ + id: a.id, + title: a.title, + content: a.content, + priority: a.priority, + announcementType: a.announcementType, + isPinned: a.isPinned, + createdAt: a.createdAt.toISOString(), + author: a.author ? { firstName: a.author.firstName, lastName: a.author.lastName } : null, + })), + communicationRooms: communicationRooms.map((r) => ({ + id: r.id, + status: r.status, + roomType: r.roomType, + student: r.student, + teacher: r.teacher, + lastMessage: r.messages[0] ? { + id: r.messages[0].id, + content: r.messages[0].content, + createdAt: r.messages[0].createdAt.toISOString(), + senderId: r.messages[0].senderId, + } : null, + updatedAt: r.updatedAt.toISOString(), + })), + schoolEvents: schoolEvents.map((e) => ({ + ...e, + startDate: e.startDate.toISOString(), + })), }); } catch (error) { console.error('Parent Portal GET error:', error); diff --git a/src/app/api/report-cards/[id]/route.ts b/src/app/api/report-cards/[id]/route.ts new file mode 100644 index 0000000..25a107e --- /dev/null +++ b/src/app/api/report-cards/[id]/route.ts @@ -0,0 +1,219 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const report = await db.report.findUnique({ + where: { id }, + include: { + student: { select: { id: true, firstName: true, lastName: true, avatarUrl: true, dateOfBirth: true } }, + classGroup: { + select: { id: true, name: true, gradeLevel: true, schoolType: true, responsibleTeacher: { select: { id: true, firstName: true, lastName: true } } }, + }, + schoolYear: { select: { id: true, label: true, startDate: true, endDate: true } }, + generatedByUser: { select: { id: true, firstName: true, lastName: true } }, + sections: { + orderBy: { order: 'asc' }, + include: { + competencyCategory: { select: { id: true, name: true, color: true } }, + }, + }, + template: { select: { id: true, name: true, layout: true, gradingScale: true, sections: true } }, + }, + }); + + if (!report) { + return NextResponse.json({ error: 'Report not found' }, { status: 404 }); + } + + // Role-based access check + if (session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ where: { userId: session.userId } }); + if (!student || student.id !== report.studentId || report.status !== 'PUBLISHED') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } else if (session.user?.role === 'PARENT') { + const link = await db.parentStudentLink.findFirst({ + where: { parentId: session.userId, studentId: report.studentId }, + }); + if (!link || report.status !== 'PUBLISHED') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + // Fetch computed grades for this student/class/schoolYear + const computedGrades = await db.computedGrade.findMany({ + where: { + studentId: report.studentId, + classGroupId: report.classGroupId, + schoolYearId: report.schoolYearId, + }, + include: { + subject: { select: { id: true, name: true } }, + }, + orderBy: { subject: { name: 'asc' } }, + }); + + return NextResponse.json({ ...report, computedGrades }); + } catch (error) { + console.error('ReportCard GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +const updateReportCardSchema = z.object({ + status: z.enum(['DRAFT', 'REVIEW', 'PUBLISHED', 'ARCHIVED', 'FINAL']).optional(), + includesGrades: z.boolean().optional(), + pdfFilePath: z.string().optional(), + teacherComments: z.string().nullable().optional(), + attendanceSummary: z.string().nullable().optional(), + overallAssessment: z.string().nullable().optional(), + templateId: z.string().nullable().optional(), + period: z.string().optional(), + sections: z + .array( + z.object({ + id: z.string().optional(), + competencyCategoryId: z.string().nullable().optional(), + generatedText: z.string().min(1), + order: z.number().int().default(0), + }) + ) + .optional(), +}); + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user?.role !== 'SUPER_ADMIN' && + session.user?.role !== 'SCHOOL_ADMIN' && + session.user?.role !== 'VICE_PRINCIPAL' && + session.user?.role !== 'TEACHER' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json(); + const parsed = updateReportCardSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const { sections, ...updateData } = parsed.data; + + // Check if teacher owns this report + if (session.user?.role === 'TEACHER') { + const existing = await db.report.findUnique({ where: { id } }); + if (!existing || existing.generatedByUserId !== session.userId) { + return NextResponse.json({ error: 'You can only edit your own reports' }, { status: 403 }); + } + // Teachers can only submit for review, not publish + if (updateData.status === 'PUBLISHED') { + return NextResponse.json({ error: 'Teachers cannot publish reports directly' }, { status: 403 }); + } + } + + // Handle status transitions + const statusUpdate: Record = {}; + if (updateData.status === 'REVIEW') { + statusUpdate.reviewedByUserId = session.userId; + statusUpdate.reviewedAt = new Date(); + } + if (updateData.status === 'PUBLISHED') { + statusUpdate.publishedAt = new Date(); + } + + // Update sections if provided + if (sections && sections.length > 0) { + // Delete existing sections and recreate + await db.reportSection.deleteMany({ where: { reportId: id } }); + await db.reportSection.createMany({ + data: sections.map((s) => ({ + reportId: id, + competencyCategoryId: s.competencyCategoryId ?? null, + generatedText: s.generatedText, + order: s.order, + })), + }); + } + + const report = await db.report.update({ + where: { id }, + data: { + ...updateData, + ...statusUpdate, + }, + include: { + student: { select: { id: true, firstName: true, lastName: true, avatarUrl: true } }, + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + schoolYear: { select: { id: true, label: true } }, + generatedByUser: { select: { id: true, firstName: true, lastName: true } }, + sections: { + orderBy: { order: 'asc' }, + include: { + competencyCategory: { select: { id: true, name: true, color: true } }, + }, + }, + template: { select: { id: true, name: true, layout: true } }, + }, + }); + + return NextResponse.json(report); + } catch (error) { + console.error('ReportCard PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if ( + session.user?.role !== 'SUPER_ADMIN' && + session.user?.role !== 'SCHOOL_ADMIN' && + session.user?.role !== 'VICE_PRINCIPAL' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + + await db.reportSection.deleteMany({ where: { reportId: id } }); + await db.report.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('ReportCard DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/report-cards/export/route.ts b/src/app/api/report-cards/export/route.ts new file mode 100644 index 0000000..1cf5931 --- /dev/null +++ b/src/app/api/report-cards/export/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const exportSchema = z.object({ + reportIds: z.array(z.string().min(1)).min(1), +}); + +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 parsed = exportSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const { reportIds } = parsed.data; + + const reports = await db.report.findMany({ + where: { id: { in: reportIds } }, + include: { + student: { select: { id: true, firstName: true, lastName: true, avatarUrl: true, dateOfBirth: true } }, + classGroup: { + select: { + id: true, name: true, gradeLevel: true, schoolType: true, + responsibleTeacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }, + schoolYear: { select: { id: true, label: true } }, + generatedByUser: { select: { id: true, firstName: true, lastName: true } }, + sections: { + orderBy: { order: 'asc' }, + include: { + competencyCategory: { select: { id: true, name: true, color: true } }, + }, + }, + template: { select: { id: true, name: true, layout: true, gradingScale: true } }, + }, + }); + + // Get school info for branding + const schoolId = session.user?.schoolId; + let school = null; + if (schoolId) { + school = await db.school.findUnique({ + where: { id: schoolId }, + select: { + id: true, name: true, motto: true, logoUrl: true, + primaryColor: true, secondaryColor: true, address: true, phone: true, + }, + }); + } + + // Get computed grades for each report + const reportsWithGrades = await Promise.all( + reports.map(async (report) => { + const computedGrades = await db.computedGrade.findMany({ + where: { + studentId: report.studentId, + classGroupId: report.classGroupId, + schoolYearId: report.schoolYearId, + }, + include: { + subject: { select: { id: true, name: true } }, + }, + orderBy: { subject: { name: 'asc' } }, + }); + return { ...report, computedGrades }; + }) + ); + + // Return the data for client-side PDF generation + // The client will use window.print() or a print-specific CSS + return NextResponse.json({ + reports: reportsWithGrades, + school, + exportDate: new Date().toISOString(), + }); + } catch (error) { + console.error('ReportCards export error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/report-cards/generate/route.ts b/src/app/api/report-cards/generate/route.ts new file mode 100644 index 0000000..c903c05 --- /dev/null +++ b/src/app/api/report-cards/generate/route.ts @@ -0,0 +1,184 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const batchGenerateSchema = z.object({ + classGroupId: z.string().min(1), + schoolYearId: z.string().min(1), + period: z.string().min(1), + templateId: z.string().optional(), + includesGrades: z.boolean().default(true), +}); + +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' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = batchGenerateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const { classGroupId, schoolYearId, period, templateId, includesGrades } = parsed.data; + + // Get all enrolled students in the class + const enrollments = await db.enrollment.findMany({ + where: { + classGroupId, + schoolYearId, + endDate: null, + }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + if (enrollments.length === 0) { + return NextResponse.json({ error: 'No students found in this class' }, { status: 400 }); + } + + // Get competency categories for the class + const competencyAssignments = await db.classCompetencyAssignment.findMany({ + where: { classGroupId, schoolYearId }, + include: { + competencyTemplate: { + include: { + categories: { + include: { competencies: true }, + orderBy: { order: 'asc' }, + }, + }, + }, + subject: { select: { id: true, name: true } }, + }, + }); + + // Get computed grades for all students + const computedGrades = await db.computedGrade.findMany({ + where: { classGroupId, schoolYearId }, + include: { subject: { select: { id: true, name: true } } }, + }); + + const createdReports = []; + + for (const enrollment of enrollments) { + // Check if report already exists for this student/period + const existing = await db.report.findFirst({ + where: { + studentId: enrollment.studentId, + classGroupId, + schoolYearId, + period, + }, + }); + + if (existing) continue; // Skip if report already exists + + // Build sections from competency categories + const sections: { competencyCategoryId: string | null; generatedText: string; order: number }[] = []; + + for (const assignment of competencyAssignments) { + const studentGrades = computedGrades.filter( + (g) => g.studentId === enrollment.studentId && g.subjectId === assignment.subjectId + ); + + for (const category of assignment.competencyTemplate.categories) { + // Get learning progress for this student in this category + const progressEntries = await db.learningProgressEntry.findMany({ + where: { + studentId: enrollment.studentId, + classGroupId, + competency: { categoryId: category.id }, + }, + orderBy: { date: 'desc' }, + take: 5, + }); + + const avgMastery = + progressEntries.length > 0 + ? progressEntries.reduce((sum, e) => sum + e.masteryLevelValue, 0) / progressEntries.length + : 0; + + const gradeText = studentGrades.length > 0 + ? ` | Note: ${studentGrades[0].overriddenValue ?? studentGrades[0].computedValue}` + : ''; + + const masteryLabel = avgMastery >= 3.5 ? 'weit entwickelt' : avgMastery >= 2.5 ? 'kompetent' : avgMastery >= 1.5 ? 'entwickelt sich' : 'noch nicht erreicht'; + + sections.push({ + competencyCategoryId: category.id, + generatedText: `${category.name}: ${masteryLabel}${gradeText}`, + order: sections.length, + }); + } + } + + // Get attendance summary + const attendanceRecords = await db.attendanceRecord.findMany({ + where: { + studentId: enrollment.studentId, + session: { + classGroupId, + date: { gte: new Date(new Date().getFullYear(), 0, 1) }, + }, + }, + }); + + const attendanceSummary = JSON.stringify({ + present: attendanceRecords.filter((r) => r.status === 'PRESENT').length, + absent: attendanceRecords.filter((r) => r.status === 'ABSENT').length, + excused: attendanceRecords.filter((r) => r.status === 'EXCUSED').length, + late: attendanceRecords.filter((r) => r.status === 'LATE').length, + total: attendanceRecords.length, + }); + + const report = await db.report.create({ + data: { + studentId: enrollment.studentId, + classGroupId, + schoolYearId, + period, + generatedByUserId: session.userId, + includesGrades, + status: 'DRAFT', + templateId: templateId ?? null, + attendanceSummary, + sections: sections.length > 0 + ? { create: sections } + : undefined, + }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + classGroup: { select: { id: true, name: true } }, + }, + }); + + createdReports.push(report); + } + + return NextResponse.json({ + generated: createdReports.length, + skipped: enrollments.length - createdReports.length, + reports: createdReports, + }); + } catch (error) { + console.error('ReportCards batch generate error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/report-cards/route.ts b/src/app/api/report-cards/route.ts new file mode 100644 index 0000000..d0870e6 --- /dev/null +++ b/src/app/api/report-cards/route.ts @@ -0,0 +1,199 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const createReportCardSchema = z.object({ + studentId: z.string().min(1), + classGroupId: z.string().min(1), + schoolYearId: z.string().min(1), + period: z.string().min(1), + includesGrades: z.boolean().default(false), + teacherComments: z.string().optional(), + attendanceSummary: z.string().optional(), + overallAssessment: z.string().optional(), + templateId: z.string().optional(), + sections: z + .array( + z.object({ + competencyCategoryId: z.string().optional(), + generatedText: z.string().min(1), + order: z.number().int().default(0), + }) + ) + .optional(), +}); + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const studentId = searchParams.get('studentId'); + const classGroupId = searchParams.get('classGroupId'); + const schoolYearId = searchParams.get('schoolYearId'); + const status = searchParams.get('status'); + const period = searchParams.get('period'); + const templateId = searchParams.get('templateId'); + + const where: Record = {}; + if (studentId) where.studentId = studentId; + if (classGroupId) where.classGroupId = classGroupId; + if (schoolYearId) where.schoolYearId = schoolYearId; + if (status) where.status = status; + if (period) where.period = period; + if (templateId) where.templateId = templateId; + + // Role-based filtering + if (session.user?.role === 'STUDENT') { + // Students can only see their own published reports + const student = await db.student.findFirst({ + where: { userId: session.userId }, + }); + if (student) { + where.studentId = student.id; + where.status = 'PUBLISHED'; + } else { + return NextResponse.json([]); + } + } else if (session.user?.role === 'PARENT') { + // Parents can only see their children's published reports + const parentLinks = await db.parentStudentLink.findMany({ + where: { parentId: session.userId }, + }); + const childIds = parentLinks.map((l) => l.studentId); + where.studentId = { in: childIds }; + where.status = 'PUBLISHED'; + } else if (session.user?.role === 'TEACHER') { + // Teachers see reports for their classes + if (!classGroupId) { + const teacherClasses = await db.classGroupTeacher.findMany({ + where: { userId: session.userId }, + }); + const classIds = teacherClasses.map((c) => c.classGroupId); + where.classGroupId = { in: classIds }; + } + } + + const reports = await db.report.findMany({ + where, + orderBy: { generatedAt: 'desc' }, + include: { + student: { select: { id: true, firstName: true, lastName: true, avatarUrl: true } }, + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + schoolYear: { select: { id: true, label: true } }, + generatedByUser: { select: { id: true, firstName: true, lastName: true } }, + sections: { + orderBy: { order: 'asc' }, + include: { + competencyCategory: { select: { id: true, name: true, color: true } }, + }, + }, + template: { select: { id: true, name: true, layout: true } }, + }, + }); + + return NextResponse.json(reports); + } catch (error) { + console.error('ReportCards 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 parsed = createReportCardSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const { sections, ...reportData } = parsed.data; + + // If attendance summary not provided, try to compute it + let attendanceSummary = reportData.attendanceSummary; + if (!attendanceSummary) { + const attendanceRecords = await db.attendanceRecord.findMany({ + where: { + studentId: reportData.studentId, + session: { + classGroupId: reportData.classGroupId, + date: { gte: new Date(new Date().getFullYear(), 0, 1) }, + }, + }, + include: { session: true }, + }); + const summary = { + present: attendanceRecords.filter((r) => r.status === 'PRESENT').length, + absent: attendanceRecords.filter((r) => r.status === 'ABSENT').length, + excused: attendanceRecords.filter((r) => r.status === 'EXCUSED').length, + late: attendanceRecords.filter((r) => r.status === 'LATE').length, + total: attendanceRecords.length, + }; + attendanceSummary = JSON.stringify(summary); + } + + const report = await db.report.create({ + data: { + studentId: reportData.studentId, + classGroupId: reportData.classGroupId, + schoolYearId: reportData.schoolYearId, + period: reportData.period, + generatedByUserId: session.userId, + includesGrades: reportData.includesGrades, + status: 'DRAFT', + teacherComments: reportData.teacherComments, + attendanceSummary, + overallAssessment: reportData.overallAssessment, + templateId: reportData.templateId, + sections: sections + ? { + create: sections.map((s) => ({ + competencyCategoryId: s.competencyCategoryId ?? null, + generatedText: s.generatedText, + order: s.order, + })), + } + : undefined, + }, + include: { + student: { select: { id: true, firstName: true, lastName: true, avatarUrl: true } }, + classGroup: { select: { id: true, name: true } }, + schoolYear: { select: { id: true, label: true } }, + generatedByUser: { select: { id: true, firstName: true, lastName: true } }, + sections: { + orderBy: { order: 'asc' }, + include: { + competencyCategory: { select: { id: true, name: true } }, + }, + }, + template: { select: { id: true, name: true, layout: true } }, + }, + }); + + return NextResponse.json(report, { status: 201 }); + } catch (error) { + console.error('ReportCards POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/report-cards/templates/route.ts b/src/app/api/report-cards/templates/route.ts new file mode 100644 index 0000000..4e17a89 --- /dev/null +++ b/src/app/api/report-cards/templates/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const createTemplateSchema = z.object({ + name: z.string().min(1), + description: z.string().optional(), + sections: z.string().default('[]'), + gradingScale: z.string().default('{}'), + layout: z.enum(['default', 'detailed', 'compact']).default('default'), + isDefault: z.boolean().default(false), +}); + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId; + + if (!schoolId) { + return NextResponse.json({ error: 'School ID required' }, { status: 400 }); + } + + const templates = await db.reportCardTemplate.findMany({ + where: { schoolId }, + orderBy: [{ isDefault: 'desc' }, { name: 'asc' }], + }); + + return NextResponse.json(templates); + } catch (error) { + console.error('ReportCardTemplates 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' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const schoolId = session.user?.schoolId; + if (!schoolId) { + return NextResponse.json({ error: 'School ID required' }, { status: 400 }); + } + + const body = await request.json(); + const parsed = createTemplateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + // If this is set as default, unset other defaults + if (parsed.data.isDefault) { + await db.reportCardTemplate.updateMany({ + where: { schoolId, isDefault: true }, + data: { isDefault: false }, + }); + } + + const template = await db.reportCardTemplate.create({ + data: { + schoolId, + name: parsed.data.name, + description: parsed.data.description, + sections: parsed.data.sections, + gradingScale: parsed.data.gradingScale, + layout: parsed.data.layout, + isDefault: parsed.data.isDefault, + }, + }); + + return NextResponse.json(template, { status: 201 }); + } catch (error) { + console.error('ReportCardTemplates POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/seating-charts/[id]/arrange/route.ts b/src/app/api/seating-charts/[id]/arrange/route.ts new file mode 100644 index 0000000..73f40ff --- /dev/null +++ b/src/app/api/seating-charts/[id]/arrange/route.ts @@ -0,0 +1,226 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── PUT: Update seating arrangement (drag-and-drop, randomize, smart arrange) ── +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const userRole = session.user?.role; + if ( + userRole !== 'TEACHER' && + userRole !== 'SCHOOL_ADMIN' && + userRole !== 'VICE_PRINCIPAL' && + userRole !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const chart = await db.seatingChart.findFirst({ where: { id } }); + + if (!chart) { + return NextResponse.json({ error: 'Seating chart not found' }, { status: 404 }); + } + + // Only the creator or admin can edit + if (userRole === 'TEACHER' && chart.teacherId !== session.userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { action, arrangement, studentId, fromRow, fromCol, toRow, toCol } = body; + + let currentArrangement: Array<{ studentId: string; row: number; col: number }> = []; + if (chart.arrangement) { + try { + currentArrangement = JSON.parse(chart.arrangement); + } catch { /* ignore */ } + } + + if (action === 'move' && studentId) { + // Move a single student from one seat to another + const existingTarget = currentArrangement.find( + (a) => a.row === toRow && a.col === toCol + ); + const existingSource = currentArrangement.find( + (a) => a.studentId === studentId + ); + + if (existingSource) { + if (existingTarget) { + // Swap: move target student to source position + existingTarget.row = existingSource.row; + existingTarget.col = existingSource.col; + } + existingSource.row = toRow; + existingSource.col = toCol; + } else { + // New placement + if (existingTarget) { + // Remove target from their seat (they become unassigned) + currentArrangement = currentArrangement.filter( + (a) => a.studentId !== existingTarget.studentId + ); + } + currentArrangement.push({ studentId, row: toRow, col: toCol }); + } + } else if (action === 'randomize') { + // Shuffle all assigned students randomly + const assignedStudents = currentArrangement.map((a) => a.studentId); + // Fisher-Yates shuffle + for (let i = assignedStudents.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [assignedStudents[i], assignedStudents[j]] = [assignedStudents[j], assignedStudents[i]]; + } + // Reassign to seats + const positions = currentArrangement.map((a) => ({ row: a.row, col: a.col })); + currentArrangement = assignedStudents.map((studentId, i) => ({ + studentId, + row: positions[i]?.row ?? Math.floor(i / chart.columns), + col: positions[i]?.col ?? i % chart.columns, + })); + } else if (action === 'smart-arrange') { + // Smart arrange: keep problem students apart based on behavior data + const enrolledStudents = await db.enrollment.findMany({ + where: { classGroupId: chart.classGroupId, endDate: null }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + const studentIds = enrolledStudents.map((e) => e.student.id); + + // Get behavior data + const behaviorIncidents = await db.behaviorIncident.findMany({ + where: { + studentId: { in: studentIds }, + severity: { in: ['major', 'severe'] }, + }, + select: { studentId: true, severity: true }, + }); + + const behaviorScore = new Map(); + for (const incident of behaviorIncidents) { + const weight = incident.severity === 'severe' ? 3 : 1; + behaviorScore.set(incident.studentId, (behaviorScore.get(incident.studentId) || 0) + weight); + } + + // Sort: high behavior score students first, to place them first + const sortedStudents = [...studentIds].sort((a, b) => { + return (behaviorScore.get(b) || 0) - (behaviorScore.get(a) || 0); + }); + + // Generate all seat positions + const allPositions: Array<{ row: number; col: number }> = []; + for (let r = 0; r < chart.rows; r++) { + for (let c = 0; c < chart.columns; c++) { + allPositions.push({ row: r, col: c }); + } + } + + // Place high-behavior students with maximum distance + const newArrangement: Array<{ studentId: string; row: number; col: number }> = []; + const usedPositions = new Set(); + + for (const sId of sortedStudents) { + let bestPos = allPositions[0]; + let bestMinDist = -1; + + for (const pos of allPositions) { + const key = `${pos.row}-${pos.col}`; + if (usedPositions.has(key)) continue; + + // Calculate minimum distance to already-placed high-behavior students + let minDist = Infinity; + for (const placed of newArrangement) { + const placedScore = behaviorScore.get(placed.studentId) || 0; + if (placedScore >= 2) { + const dist = Math.abs(placed.row - pos.row) + Math.abs(placed.col - pos.col); + minDist = Math.min(minDist, dist); + } + } + + if (minDist > bestMinDist) { + bestMinDist = minDist; + bestPos = pos; + } + } + + const posKey = `${bestPos.row}-${bestPos.col}`; + usedPositions.add(posKey); + newArrangement.push({ studentId: sId, row: bestPos.row, col: bestPos.col }); + } + + currentArrangement = newArrangement; + } else if (action === 'assign' && arrangement) { + // Full arrangement replacement + currentArrangement = arrangement; + } else if (action === 'clear') { + // Clear all assignments + currentArrangement = []; + } else if (action === 'assign-all') { + // Assign all unassigned students to empty seats + const enrolledStudents = await db.enrollment.findMany({ + where: { classGroupId: chart.classGroupId, endDate: null }, + include: { + student: { select: { id: true } }, + }, + }); + + const assignedIds = new Set(currentArrangement.map((a) => a.studentId)); + const unassigned = enrolledStudents + .map((e) => e.student.id) + .filter((sId) => !assignedIds.has(sId)); + + // Find empty positions + const usedPositions = new Set( + currentArrangement.map((a) => `${a.row}-${a.col}`) + ); + const emptyPositions: Array<{ row: number; col: number }> = []; + for (let r = 0; r < chart.rows; r++) { + for (let c = 0; c < chart.columns; c++) { + if (!usedPositions.has(`${r}-${c}`)) { + emptyPositions.push({ row: r, col: c }); + } + } + } + + // Assign unassigned students to empty positions + for (let i = 0; i < Math.min(unassigned.length, emptyPositions.length); i++) { + currentArrangement.push({ + studentId: unassigned[i], + row: emptyPositions[i].row, + col: emptyPositions[i].col, + }); + } + } + + // Save the updated arrangement + await db.seatingChart.update({ + where: { id }, + data: { arrangement: JSON.stringify(currentArrangement) }, + }); + + // Also update ClassGroup's seatingOrder for backward compatibility + await db.classGroup.update({ + where: { id: chart.classGroupId }, + data: { seatingOrder: JSON.stringify(currentArrangement) }, + }); + + return NextResponse.json({ + success: true, + arrangement: currentArrangement, + }); + } catch (error) { + console.error('SeatingChart Arrange PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/seating-charts/[id]/route.ts b/src/app/api/seating-charts/[id]/route.ts new file mode 100644 index 0000000..a8d02af --- /dev/null +++ b/src/app/api/seating-charts/[id]/route.ts @@ -0,0 +1,233 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: Get a single seating chart ────────────────────────────────────────── +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 chart = await db.seatingChart.findFirst({ + where: { id }, + include: { + classGroup: { + select: { + id: true, + name: true, + gradeLevel: true, + schoolId: true, + }, + }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + if (!chart) { + return NextResponse.json({ error: 'Seating chart not found' }, { status: 404 }); + } + + // Access check + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== chart.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Get enrolled students for the class + const enrollments = await db.enrollment.findMany({ + where: { classGroupId: chart.classGroupId, endDate: null }, + include: { + student: { + select: { + id: true, + firstName: true, + lastName: true, + avatarUrl: true, + avatarInitials: true, + }, + }, + }, + }); + + const students = enrollments.map((e) => e.student); + + // Get behavior data for smart arrange + const behaviorIncidents = await db.behaviorIncident.findMany({ + where: { + studentId: { in: students.map((s) => s.id) }, + severity: { in: ['major', 'severe'] }, + }, + select: { studentId: true, severity: true }, + }); + + const behaviorMap = new Map(); + for (const incident of behaviorIncidents) { + const weight = incident.severity === 'severe' ? 3 : 1; + behaviorMap.set(incident.studentId, (behaviorMap.get(incident.studentId) || 0) + weight); + } + + return NextResponse.json({ + ...chart, + arrangement: chart.arrangement ? JSON.parse(chart.arrangement) : [], + students: students.map((s) => ({ + ...s, + behaviorScore: behaviorMap.get(s.id) || 0, + })), + }); + } catch (error) { + console.error('SeatingChart GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── PUT: Update a seating chart ────────────────────────────────────────── +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const userRole = session.user?.role; + if ( + userRole !== 'TEACHER' && + userRole !== 'SCHOOL_ADMIN' && + userRole !== 'VICE_PRINCIPAL' && + userRole !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const chart = await db.seatingChart.findFirst({ where: { id } }); + + if (!chart) { + return NextResponse.json({ error: 'Seating chart not found' }, { status: 404 }); + } + + // Only the creator or admin can edit + if ( + userRole === 'TEACHER' && + chart.teacherId !== session.userId + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { + name, + layoutType, + rows, + columns, + gap, + arrangement, + showDoor, + showWindows, + doorPosition, + windowPosition, + isTemplate, + isDefault, + notes, + } = body; + + // If isDefault is being set to true, unset any existing default for this class + if (isDefault) { + await db.seatingChart.updateMany({ + where: { classGroupId: chart.classGroupId, isDefault: true }, + data: { isDefault: false }, + }); + } + + const updated = await db.seatingChart.update({ + where: { id }, + data: { + ...(name !== undefined && { name }), + ...(layoutType !== undefined && { layoutType }), + ...(rows !== undefined && { rows }), + ...(columns !== undefined && { columns }), + ...(gap !== undefined && { gap }), + ...(arrangement !== undefined && { arrangement: JSON.stringify(arrangement) }), + ...(showDoor !== undefined && { showDoor }), + ...(showWindows !== undefined && { showWindows }), + ...(doorPosition !== undefined && { doorPosition }), + ...(windowPosition !== undefined && { windowPosition }), + ...(isTemplate !== undefined && { isTemplate }), + ...(isDefault !== undefined && { isDefault }), + ...(notes !== undefined && { notes }), + }, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + // Also update the ClassGroup's seatingOrder for backward compatibility + if (arrangement && Array.isArray(arrangement)) { + await db.classGroup.update({ + where: { id: chart.classGroupId }, + data: { seatingOrder: JSON.stringify(arrangement) }, + }); + } + + return NextResponse.json({ + ...updated, + arrangement: updated.arrangement ? JSON.parse(updated.arrangement) : [], + }); + } catch (error) { + console.error('SeatingChart PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── DELETE: Delete a seating chart ────────────────────────────────────────── +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 userRole = session.user?.role; + if ( + userRole !== 'TEACHER' && + userRole !== 'SCHOOL_ADMIN' && + userRole !== 'VICE_PRINCIPAL' && + userRole !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const chart = await db.seatingChart.findFirst({ where: { id } }); + + if (!chart) { + return NextResponse.json({ error: 'Seating chart not found' }, { status: 404 }); + } + + // Only the creator or admin can delete + if ( + userRole === 'TEACHER' && + chart.teacherId !== session.userId + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + await db.seatingChart.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('SeatingChart DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/seating-charts/route.ts b/src/app/api/seating-charts/route.ts new file mode 100644 index 0000000..d067ba5 --- /dev/null +++ b/src/app/api/seating-charts/route.ts @@ -0,0 +1,187 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: List seating charts with filters ────────────────────────────────── +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined; + const classGroupId = searchParams.get('classGroupId'); + const teacherId = searchParams.get('teacherId'); + const isTemplate = searchParams.get('isTemplate'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + const where: Record = { schoolId }; + + if (classGroupId) where.classGroupId = classGroupId; + if (teacherId) where.teacherId = teacherId; + if (isTemplate !== null && isTemplate !== undefined) { + where.isTemplate = isTemplate === 'true'; + } + + // Role-based access + if (session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ + where: { userId: session.userId }, + select: { id: true }, + }); + if (student) { + const enrollments = await db.enrollment.findMany({ + where: { studentId: student.id }, + select: { classGroupId: true }, + }); + const classIds = enrollments.map((e) => e.classGroupId); + where.classGroupId = { in: classIds }; + } + } else if (session.user?.role === 'PARENT') { + const parentLinks = await db.parentStudentLink.findMany({ + where: { parentId: session.userId }, + select: { studentId: true }, + }); + const studentIds = parentLinks.map((l) => l.studentId); + const enrollments = await db.enrollment.findMany({ + where: { studentId: { in: studentIds } }, + select: { classGroupId: true }, + }); + const classIds = enrollments.map((e) => e.classGroupId); + where.classGroupId = { in: classIds }; + } else if (session.user?.role === 'TEACHER') { + // Teachers see their own charts by default, or all if classGroupId is specified + if (!teacherId && !classGroupId) { + where.teacherId = session.userId; + } + } + + const charts = await db.seatingChart.findMany({ + where, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { updatedAt: 'desc' }, + }); + + // Parse arrangement JSON for each chart + const result = charts.map((chart) => ({ + ...chart, + arrangement: chart.arrangement ? JSON.parse(chart.arrangement) : [], + })); + + return NextResponse.json({ charts: result }); + } catch (error) { + console.error('SeatingCharts GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── POST: Create a seating chart ────────────────────────────────────────── +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const userRole = session.user?.role; + if ( + userRole !== 'TEACHER' && + userRole !== 'SCHOOL_ADMIN' && + userRole !== 'VICE_PRINCIPAL' && + userRole !== 'SUPER_ADMIN' + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { + name, + classGroupId, + schoolId, + layoutType, + rows, + columns, + gap, + arrangement, + showDoor, + showWindows, + doorPosition, + windowPosition, + isTemplate, + isDefault, + notes, + } = body; + + if (!name || !schoolId || !classGroupId) { + return NextResponse.json( + { error: 'name, schoolId, and classGroupId are required' }, + { status: 400 } + ); + } + + // Verify class belongs to school + const classGroup = await db.classGroup.findFirst({ + where: { id: classGroupId, schoolId, deletedAt: null }, + }); + if (!classGroup) { + return NextResponse.json({ error: 'Class not found' }, { status: 404 }); + } + + // If isDefault is true, unset any existing default for this class + if (isDefault) { + await db.seatingChart.updateMany({ + where: { classGroupId, isDefault: true }, + data: { isDefault: false }, + }); + } + + const chart = await db.seatingChart.create({ + data: { + schoolId, + classGroupId, + teacherId: session.userId, + name, + layoutType: layoutType || 'rows', + rows: rows ?? 5, + columns: columns ?? 5, + gap: gap ?? 2, + arrangement: arrangement ? JSON.stringify(arrangement) : null, + showDoor: showDoor ?? true, + showWindows: showWindows ?? true, + doorPosition: doorPosition || 'left', + windowPosition: windowPosition || 'right', + isTemplate: isTemplate ?? false, + isDefault: isDefault ?? false, + notes: notes || null, + }, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + // Also update the ClassGroup's seatingOrder for backward compatibility + if (arrangement && Array.isArray(arrangement)) { + await db.classGroup.update({ + where: { id: classGroupId }, + data: { seatingOrder: JSON.stringify(arrangement) }, + }); + } + + return NextResponse.json({ + ...chart, + arrangement: chart.arrangement ? JSON.parse(chart.arrangement) : [], + }, { status: 201 }); + } catch (error) { + console.error('SeatingCharts POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 9a6317f..5f2254f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3212,4 +3212,99 @@ .class-card-active:hover::before { opacity: 1; } + + /* Report Card View - Badge shimmer animation */ + @keyframes shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } + } + + /* Report Card View - Print styles */ + @media print { + .report-card-print { + box-shadow: none !important; + border: 1px solid #e5e7eb !important; + } + .report-card-print * { + color-adjust: exact; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + .no-print { + display: none !important; + } + } + + /* Seating Chart - Desk hover glow */ + @keyframes desk-glow { + 0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); } + 50% { box-shadow: 0 0 8px 2px rgba(16, 185, 129, 0.3); } + } + + /* Seating Chart - Empty desk dashed border animation */ + @keyframes dash-march { + to { stroke-dashoffset: -12; } + } + + /* Library - Book card hover lift */ + .book-card-hover { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + .book-card-hover:hover { + transform: translateY(-4px) scale(1.01); + box-shadow: 0 12px 24px -8px rgba(16, 185, 129, 0.15), 0 4px 8px -2px rgba(0, 0, 0, 0.06); + } + + /* Library - Availability pulse */ + @keyframes availability-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } + } + .availability-pulse { + animation: availability-pulse 2s ease-in-out infinite; + } + + /* Library - Overdue gradient border */ + .overdue-gradient-border { + position: relative; + } + .overdue-gradient-border::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 0.5rem; + padding: 2px; + background: linear-gradient(135deg, #ef4444, #f97316, #ef4444); + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask-composite: exclude; + -webkit-mask-composite: xor; + pointer-events: none; + } + + /* Parent Portal - Circular progress gradient */ + @keyframes progress-fill-animate { + from { stroke-dashoffset: var(--circumference); } + to { stroke-dashoffset: var(--offset); } + } + + /* Parent Portal - Tab transition */ + .tab-content-enter { + animation: tab-enter 0.3s ease-out; + } + @keyframes tab-enter { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } + } + + /* Glassmorphism card style */ + .glass-card { + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.3); + } + .dark .glass-card { + background: rgba(24, 24, 27, 0.7); + border: 1px solid rgba(255, 255, 255, 0.06); + } } diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index 6875001..917efec 100644 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -66,6 +66,8 @@ import { Tablet, CalendarClock, Timer, + LayoutGrid as LayoutGridIcon, + BookOpen as BookOpenIconNav, } from 'lucide-react'; import { Sidebar, @@ -177,6 +179,9 @@ import TabletGradingView from './tablet-grading-view'; import ExamCalendarView from './exam-calendar-view'; import StudentAchievementsView from './student-achievements-view'; 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'; // Dynamic imports for heavy components with loading skeletons const AnalyticsView = dynamic(() => import('./analytics-view'), { @@ -243,6 +248,9 @@ const navSections: NavSection[] = [ { key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' }, { key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' }, { key: 'exam-calendar', icon: CalendarClock, labelKey: 'nav.exam_calendar' }, + { 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: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' }, { key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' }, ], @@ -281,9 +289,12 @@ const studentNavSections: NavSection[] = [ { key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' }, { key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' }, { key: 'exam-calendar', icon: CalendarClock, labelKey: 'nav.exam_calendar' }, + { key: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' }, + { 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: 'resources', icon: FolderOpen, labelKey: 'nav.resources' }, + { key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' }, { key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' }, { key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' }, { key: 'settings', icon: Settings, labelKey: 'nav.settings' }, @@ -308,6 +319,8 @@ const parentNavSections: NavSection[] = [ { key: 'communication', icon: MessageSquare, labelKey: 'nav.communication' }, { key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' }, { 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: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' }, { key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' }, ], @@ -455,6 +468,9 @@ function renderView(view: ViewName) { case 'exam-calendar': return ; case 'student-achievements': return ; case 'student-study-planner': return ; + case 'seating-chart': return ; + case 'school-library': return ; + case 'report-cards': return ; default: return ; } } diff --git a/src/components/parent-portal-view.tsx b/src/components/parent-portal-view.tsx index bc3e4d6..9cbaa98 100644 --- a/src/components/parent-portal-view.tsx +++ b/src/components/parent-portal-view.tsx @@ -1,12 +1,15 @@ 'use client'; -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Users, Heart, GraduationCap, Clock, CheckCircle2, XCircle, AlertCircle, Loader2, ChevronRight, Calendar, BookOpen, Shield, MessageSquare, Thermometer, TrendingUp, Timer, - Plus, FileText, BarChart3, + Plus, FileText, BarChart3, ArrowUpRight, ArrowDownRight, + Minus, Megaphone, CalendarDays, Download, Upload, + BookMarked, ClipboardCheck, Phone, Mail, Bell, + CalendarClock, StickyNote, Activity, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -24,6 +27,11 @@ import { apiPost } from '@/lib/api'; import { toast } from 'sonner'; import { format, parseISO, differenceInCalendarDays } from 'date-fns'; import { de as deLocale, enUS as enLocale } from 'date-fns/locale'; +import { + RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, + LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, + ResponsiveContainer, BarChart, Bar, +} from 'recharts'; /* ── Types ─────────────────────────────────────────────────────────── */ @@ -43,6 +51,7 @@ interface ChildData { parentApprovalStatus: string; status: string; reporterType: string; + documentUrl: string | null; createdAt: string; }>; upcomingExams: Array<{ @@ -86,6 +95,12 @@ interface ChildData { createdAt: string; resolution: string | null; }>; + homeworkDue: Array<{ + id: string; + title: string; + dueDate: string; + subject: { id: string; name: string } | null; + }>; } interface PendingApproval { @@ -100,30 +115,236 @@ interface PendingApproval { student: { id: string; firstName: string; lastName: string }; } +interface Announcement { + id: string; + title: string; + content: string; + priority: string; + announcementType: string; + isPinned: boolean; + createdAt: string; + author: { firstName: string; lastName: string } | null; +} + +interface CommRoom { + id: string; + status: string; + roomType: string; + student: { id: string; firstName: string; lastName: string }; + teacher: { id: string; firstName: string; lastName: string }; + lastMessage: { id: string; content: string; createdAt: string; senderId: string } | null; + updatedAt: string; +} + +interface SchoolEventShort { + id: string; + title: string; + startDate: string; + eventType: string; + location: string | null; +} + +interface SubjectGrade { + subject: { id: string; name: string; code: string | null }; + grades: Array<{ + id: string; + computedValue: number; + period: string; + computedAt: string; + classGroup: { id: string; name: string }; + schoolYear: { id: string; name: string }; + }>; + average: number; + trend: 'up' | 'down' | 'stable'; +} + +interface ChildProgress { + subjectGrades: SubjectGrade[]; + classAverages: Record; + competencyBySubject: Array<{ + subject: { id: string; name: string }; + competencies: Array<{ id: string; code: string; title: string; level: number; assessedAt: string }>; + averageLevel: number; + }>; + assessments: Array<{ + id: string; + score: number; + maxScore: number; + grade: string | null; + feedback: string | null; + assessedAt: string; + assessment: { id: string; title: string; type: string; date: string; subject: { id: string; name: string } }; + }>; +} + +interface ScheduleData { + timetableSlots: Array<{ + id: string; + dayOfWeek: number; + period: number; + startTime: string; + endTime: string; + isBreak: boolean; + subject: { id: string; name: string; code: string | null } | null; + teacher: { id: string; firstName: string; lastName: string } | null; + classGroup: { id: string; name: string }; + }>; + upcomingExams: Array<{ + id: string; + title: string; + date: string; + startTime: string | null; + endTime: string | null; + subject: { id: string; name: string } | null; + classGroup: { id: string; name: string } | null; + daysUntil: number; + }>; + schoolEvents: Array<{ + id: string; + title: string; + description: string | null; + startDate: string; + endDate: string | null; + eventType: string; + location: string | null; + organizer: { id: string; firstName: string; lastName: string }; + }>; + counselingAppointments: Array<{ + id: string; + requestType: string; + status: string; + scheduledAt: string | null; + description: string | null; + counselor: { firstName: string; lastName: string }; + }>; + homeworkDue: Array<{ + id: string; + title: string; + description: string | null; + dueDate: string; + homeworkType: string; + subject: { id: string; name: string } | null; + classGroup: { id: string; name: string }; + }>; +} + +/* ── Helpers ───────────────────────────────────────────────────────── */ + +const fmtDate = (d: string, loc: 'de' | 'en') => + format(parseISO(d), 'd. MMM yyyy', { locale: loc === 'de' ? deLocale : enLocale }); + +const fmtShort = (d: string, loc: 'de' | 'en') => + format(parseISO(d), 'd. MMM', { locale: loc === 'de' ? deLocale : enLocale }); + +const dayNames = (loc: 'de' | 'en') => [ + loc === 'de' ? 'Mo' : 'Mon', + loc === 'de' ? 'Di' : 'Tue', + loc === 'de' ? 'Mi' : 'Wed', + loc === 'de' ? 'Do' : 'Thu', + loc === 'de' ? 'Fr' : 'Fri', +]; + +const subjectColors = [ + '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', + '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', +]; + +/* ── Animated Counter ──────────────────────────────────────────────── */ + +function AnimatedCounter({ value, duration = 1200 }: { value: number; duration?: number }) { + const [display, setDisplay] = useState(0); + useEffect(() => { + let start = 0; + const step = value / (duration / 16); + const timer = setInterval(() => { + start += step; + if (start >= value) { start = value; clearInterval(timer); } + setDisplay(Math.round(start * 10) / 10); + }, 16); + return () => clearInterval(timer); + }, [value, duration]); + return <>{display}; +} + +/* ── Circular Progress ─────────────────────────────────────────────── */ + +function CircularProgress({ value, size = 120, strokeWidth = 10 }: { value: number; size?: number; strokeWidth?: number }) { + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (value / 100) * circumference; + const color = value >= 90 ? '#10b981' : value >= 75 ? '#f59e0b' : '#ef4444'; + + return ( +
+ + + + + + + + + + +
+ + {value}% + +
+
+ ); +} + /* ── Main Component ────────────────────────────────────────────────── */ export default function ParentPortalView() { const currentUser = useAppStore((s) => s.currentUser); const locale = useAppStore((s) => s.locale); - const localeCode = (locale === 'en' ? 'en' : 'de') as 'de' | 'en'; + const loc = (locale === 'en' ? 'en' : 'de') as 'de' | 'en'; const [children, setChildren] = useState([]); const [pendingApprovals, setPendingApprovals] = useState([]); + const [announcements, setAnnouncements] = useState([]); + const [commRooms, setCommRooms] = useState([]); + const [schoolEvents, setSchoolEvents] = useState([]); const [selectedChild, setSelectedChild] = useState(null); const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState('overview'); + + // Sub-data + const [progressData, setProgressData] = useState(null); + const [scheduleData, setScheduleData] = useState(null); + const [loadingProgress, setLoadingProgress] = useState(false); + const [loadingSchedule, setLoadingSchedule] = useState(false); + + // Dialogs const [showIllnessDialog, setShowIllnessDialog] = useState(false); const [showConversationDialog, setShowConversationDialog] = useState(false); + const [showMeetingDialog, setShowMeetingDialog] = useState(false); const [illnessForm, setIllnessForm] = useState({ reason: 'illness', description: '', startDate: format(new Date(), 'yyyy-MM-dd'), endDate: '', }); - const [conversationForm, setConversationForm] = useState({ - message: '', - }); + const [conversationForm, setConversationForm] = useState({ message: '' }); + const [meetingForm, setMeetingForm] = useState({ topic: '', preferredDate: '', notes: '' }); const [submitting, setSubmitting] = useState(false); + /* ── Load main data ────────────────────────────────────────────────── */ const loadData = useCallback(async () => { if (!currentUser?.schoolId) return; setLoading(true); @@ -133,6 +354,9 @@ export default function ParentPortalView() { const data = await res.json(); setChildren(data.children ?? []); setPendingApprovals(data.pendingApprovals ?? []); + setAnnouncements(data.announcements ?? []); + setCommRooms(data.communicationRooms ?? []); + setSchoolEvents(data.schoolEvents ?? []); if (data.children?.length > 0 && !selectedChild) { setSelectedChild(data.children[0]); } @@ -144,44 +368,48 @@ export default function ParentPortalView() { } }, [currentUser?.schoolId]); - useEffect(() => { - loadData(); - }, [loadData]); + useEffect(() => { loadData(); }, [loadData]); + /* ── Load sub-data when child changes ──────────────────────────────── */ + useEffect(() => { + if (!selectedChild || !currentUser?.schoolId) return; + setLoadingProgress(true); + setLoadingSchedule(true); + fetch(`/api/parent-portal/child-progress?studentId=${selectedChild.studentId}&schoolId=${currentUser.schoolId}`) + .then((r) => r.json()) + .then((d) => setProgressData(d)) + .catch(() => {}) + .finally(() => setLoadingProgress(false)); + fetch(`/api/parent-portal/child-schedule?studentId=${selectedChild.studentId}&schoolId=${currentUser.schoolId}`) + .then((r) => r.json()) + .then((d) => setScheduleData(d)) + .catch(() => {}) + .finally(() => setLoadingSchedule(false)); + }, [selectedChild?.studentId, currentUser?.schoolId]); + + /* ── Handlers ──────────────────────────────────────────────────────── */ const handleApproveIllness = async (reportId: string) => { try { - const res = await fetch(`/api/illness-reports/approve?XTransformPort=3000`, { + const res = await fetch('/api/illness-reports/approve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reportId, action: 'approve' }), }); - if (res.ok) { - toast.success(t('parent_portal.illness_approved')); - loadData(); - } else { - toast.error('Failed to approve illness report'); - } - } catch { - toast.error('Failed to approve illness report'); - } + if (res.ok) { toast.success(t('parent_portal.illness_approved')); loadData(); } + else toast.error('Failed to approve'); + } catch { toast.error('Failed to approve'); } }; const handleRejectIllness = async (reportId: string) => { try { - const res = await fetch(`/api/illness-reports/approve?XTransformPort=3000`, { + const res = await fetch('/api/illness-reports/approve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reportId, action: 'reject' }), }); - if (res.ok) { - toast.success(t('parent_portal.illness_rejected')); - loadData(); - } else { - toast.error('Failed to reject illness report'); - } - } catch { - toast.error('Failed to reject illness report'); - } + if (res.ok) { toast.success(t('parent_portal.illness_rejected')); loadData(); } + else toast.error('Failed to reject'); + } catch { toast.error('Failed to reject'); } }; const handleReportIllness = async () => { @@ -202,11 +430,8 @@ export default function ParentPortalView() { setShowIllnessDialog(false); setIllnessForm({ reason: 'illness', description: '', startDate: format(new Date(), 'yyyy-MM-dd'), endDate: '' }); loadData(); - } catch { - toast.error('Failed to report illness'); - } finally { - setSubmitting(false); - } + } catch { toast.error('Failed to report illness'); } + finally { setSubmitting(false); } }; const handleRequestConversation = async () => { @@ -223,11 +448,26 @@ export default function ParentPortalView() { setShowConversationDialog(false); setConversationForm({ message: '' }); loadData(); - } catch { - toast.error('Failed to request conversation'); - } finally { - setSubmitting(false); - } + } catch { toast.error('Failed to request conversation'); } + finally { setSubmitting(false); } + }; + + const handleRequestMeeting = async () => { + if (!currentUser?.schoolId || !selectedChild) return; + setSubmitting(true); + try { + await apiPost('/api/counseling', { + schoolId: currentUser.schoolId, + studentId: selectedChild.userId, + requestType: 'parent_teacher', + description: `${meetingForm.topic}${meetingForm.preferredDate ? ` | ${loc === 'de' ? 'Gewuenschter Termin' : 'Preferred date'}: ${meetingForm.preferredDate}` : ''}${meetingForm.notes ? ` | ${meetingForm.notes}` : ''}`, + }); + toast.success(t('parent_portal.request_meeting')); + setShowMeetingDialog(false); + setMeetingForm({ topic: '', preferredDate: '', notes: '' }); + loadData(); + } catch { toast.error('Failed to request meeting'); } + finally { setSubmitting(false); } }; const getAttendanceRate = (summary: { total: number; present: number; excused: number }) => { @@ -235,6 +475,43 @@ export default function ParentPortalView() { return Math.round(((summary.present + summary.excused) / summary.total) * 100); }; + /* ── Chart data ────────────────────────────────────────────────────── */ + const radarData = useMemo(() => { + if (!progressData?.competencyBySubject) return []; + return progressData.competencyBySubject.map((cs) => ({ + subject: cs.subject.name, + level: cs.averageLevel, + fullMark: 5, + })); + }, [progressData]); + + const gradeHistoryData = useMemo(() => { + if (!progressData?.subjectGrades) return []; + const periods = new Set(); + for (const sg of progressData.subjectGrades) { + for (const g of sg.grades) periods.add(g.period); + } + const sortedPeriods = Array.from(periods).sort(); + return sortedPeriods.map((period) => { + const entry: Record = { period }; + for (const sg of progressData.subjectGrades) { + const pg = sg.grades.find((g) => g.period === period); + if (pg) entry[sg.subject.name] = pg.computedValue; + } + return entry; + }); + }, [progressData]); + + const gradeComparisonData = useMemo(() => { + if (!progressData?.subjectGrades) return []; + return progressData.subjectGrades.map((sg) => ({ + subject: sg.subject.name, + student: sg.average, + classAvg: progressData.classAverages[sg.subject.id] ?? 0, + })); + }, [progressData]); + + /* ── Loading State ─────────────────────────────────────────────────── */ if (loading) { return (
@@ -243,97 +520,74 @@ export default function ParentPortalView() { ); } + /* ── Empty State ───────────────────────────────────────────────────── */ if (children.length === 0) { return (
- +
-

- {t('parent_portal.title')} -

-

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

+

{t('parent_portal.title')}

+

{t('parent_portal.subtitle')}

-

- {t('parent_portal.no_children')} -

-

- {t('parent_portal.no_children_desc')} -

+

{t('parent_portal.no_children')}

+

{t('parent_portal.no_children_desc')}

); } + /* ── Render ────────────────────────────────────────────────────────── */ return (
- {/* Header */} + {/* ── Gradient Header Banner ──────────────────────────────────── */} -
-
- +
+
+
+
+ +
+
+

{t('parent_portal.title')}

+

{t('parent_portal.subtitle')}

+
-
-

- {t('parent_portal.title')} -

-

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

+
+ {selectedChild && ( + <> + + + + + )}
-
- {selectedChild && ( - <> - - - - )} -
- {/* Pending Approvals */} + {/* ── Pending Approvals ───────────────────────────────────────── */} {pendingApprovals.length > 0 && ( - - + + @@ -342,24 +596,18 @@ export default function ParentPortalView() {
- {pendingApprovals.map((approval) => ( -
+ {pendingApprovals.map((pa) => ( +
-

- {approval.student.firstName} {approval.student.lastName} -

-

- {approval.reason} - {format(parseISO(approval.startDate), 'd. MMM', { locale: localeCode === 'de' ? deLocale : enLocale })} -

+

{pa.student.firstName} {pa.student.lastName}

+

{pa.reason} - {fmtShort(pa.startDate, loc)}

- -
@@ -370,432 +618,984 @@ export default function ParentPortalView() { )} - {/* Child Selector */} - - {children.map((child) => ( - - ))} + {/* ── Child Selector ──────────────────────────────────────────── */} + + {children.map((child) => { + const isActive = selectedChild?.studentId === child.studentId; + return ( + setSelectedChild(child)} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 transition-all whitespace-nowrap min-h-[44px] ${isActive ? 'bg-gradient-to-r from-emerald-500 to-teal-500 text-white border-emerald-500 shadow-lg shadow-emerald-300/40 dark:shadow-emerald-900/40' : 'bg-white dark:bg-gray-900/40 border-emerald-100 dark:border-emerald-900/30 text-gray-700 dark:text-gray-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 hover:border-emerald-300/60 dark:hover:border-emerald-700/60'}`} + > + + + {child.firstName[0]}{child.lastName[0]} + + + {child.firstName} + {child.classGroups.length > 0 && ( + + {child.classGroups[0].name} + + )} + + ); + })} - {/* Child Data */} + {/* ── Child Data ──────────────────────────────────────────────── */} {selectedChild && ( - - - - - {t('parent_portal.child_overview', { name: selectedChild.firstName })} + + + + + {t('parent_portal.child_dashboard')} - - {t('parent_portal.child_exams')} + + {t('parent_portal.academic_progress')} - - {t('parent_portal.child_grades')} + + {t('parent_portal.attendance_illness')} - - {t('parent_portal.child_attendance')} + + {t('parent_portal.communication')} - - {t('parent_portal.illness_reports')} + + {t('parent_portal.schedule')} - - {t('parent_portal.child_counseling')} - - - {t('parent_portal.child_disciplinary')} + + {t('parent_portal.reports_docs')} - {/* Overview Tab */} + {/* ═══════════════════════════════════════════════════════════ + OVERVIEW TAB + ═══════════════════════════════════════════════════════════ */} -
- - -
-
- -
-
-

{t('parent_portal.upcoming_exams')}

-

{selectedChild.upcomingExams.length}

-
-
-
-
- - -
-
- -
-
-

{t('parent_portal.attendance_rate')}

-

{getAttendanceRate(selectedChild.attendanceSummary)}%

-
-
-
-
- - -
-
- -
-
-

{t('parent_portal.recent_grades')}

-

{selectedChild.recentGrades.length}

-
-
-
-
- - -
-
- -
-
-

{t('parent_portal.counseling_appointments')}

-

{selectedChild.counselingAppointments.length}

-
-
-
-
-
+
+ {/* Quick Stats Cards */} +
+ + + +
+
+

{t('parent_portal.attendance_rate')}

+

%

+
+
+ +
+
+
+
+
+ + + +
+
+

{t('parent_portal.recent_grades')}

+

+
+
+ +
+
+
+
+
+ + + +
+
+

{t('parent_portal.upcoming_exams')}

+

+
+
+ +
+
+
+
+
+ + + +
+
+

{t('parent_portal.counseling_appointments')}

+

+
+
+ +
+
+
+
+
+
- {/* Quick actions */} -
- - - - + {/* Two Column: Upcoming Deadlines + Recent Activity */} +
+ {/* Upcoming Deadlines */} + + + + + {t('parent_portal.upcoming_deadlines')} + + + + {selectedChild.upcomingExams.length === 0 && selectedChild.homeworkDue.length === 0 ? ( +
+ +

{t('parent_portal.no_exams')}

+
+ ) : ( +
+ {selectedChild.upcomingExams.slice(0, 4).map((exam) => ( +
+
+
+ +
+
+

{exam.title}

+

{exam.subject?.name ?? ''}

+
+
+ + + {exam.daysUntil === 0 ? (loc === 'de' ? 'Heute' : 'Today') : `${exam.daysUntil}d`} + +
+ ))} + {selectedChild.homeworkDue.slice(0, 3).map((hw) => ( +
+
+
+ +
+
+

{hw.title}

+

{hw.subject?.name ?? ''}

+
+
+ + {t('parent_portal.due')}: {fmtShort(hw.dueDate, loc)} + +
+ ))} +
+ )} +
+
+ + {/* Recent Activity */} + + + + + {t('parent_portal.recent_activity')} + + + + {selectedChild.recentGrades.length === 0 && selectedChild.illnessReports.length === 0 ? ( +
+ +

{loc === 'de' ? 'Keine Aktivitaeten' : 'No activities'}

+
+ ) : ( +
+ {selectedChild.recentGrades.slice(0, 4).map((g) => ( +
+
+
+ +
+
+

{g.subject.name}

+

{g.period}

+
+
+ {g.computedValue.toFixed(1)} +
+ ))} + {selectedChild.illnessReports.slice(0, 2).map((ir) => ( +
+
+
+ +
+
+

{ir.reason}

+

{fmtShort(ir.startDate, loc)}

+
+
+ + {ir.parentApprovalStatus === 'approved' ? t('parent_portal.approved') : t('status.pending')} + +
+ ))} +
+ )} +
+
+
+ + {/* Quick Actions */} + + +

{t('parent_portal.quick_actions')}

+
+ + + + +
+
+
- {/* Exams Tab */} - - - - - - {t('parent_portal.upcoming_exams')} - - - - {selectedChild.upcomingExams.length === 0 ? ( -
- -

{t('parent_portal.no_exams')}

+ {/* ═══════════════════════════════════════════════════════════ + ACADEMIC PROGRESS TAB + ═══════════════════════════════════════════════════════════ */} + + {loadingProgress ? ( +
+ ) : ( +
+ {/* Subject Grades with Trends */} + + + + + {t('parent_portal.subject_grades')} + + + + {!progressData?.subjectGrades?.length ? ( +

{t('parent_portal.no_grades')}

+ ) : ( +
+ {progressData.subjectGrades.map((sg, i) => ( +
+
+
+ {sg.subject.name.substring(0, 2).toUpperCase()} +
+
+

{sg.subject.name}

+

{sg.grades.length} {loc === 'de' ? 'Bewertungen' : 'grades'}

+
+
+
+
+

{sg.average.toFixed(1)}

+ {progressData.classAverages[sg.subject.id] != null && ( +

{t('parent_portal.class')}: {progressData.classAverages[sg.subject.id].toFixed(1)}

+ )} +
+
+ {sg.trend === 'up' ? : sg.trend === 'down' ? : } +
+
+
+ ))} +
+ )} +
+
+ + {/* Charts Row */} +
+ {/* Competency Radar */} + + + + + {t('parent_portal.competency_radar')} + + + + {radarData.length < 3 ? ( +

{t('parent_portal.no_grades')}

+ ) : ( + + + + + + + + + + + + + + + )} +
+
+ + {/* Grade Comparison */} + + + + + {t('parent_portal.comparison')} + + + + {gradeComparisonData.length === 0 ? ( +

{t('parent_portal.no_grades')}

+ ) : ( + + + + + + + + + + + + )} +
+
+
+ + {/* Grade History Chart */} + {gradeHistoryData.length > 0 && ( + + + + + {t('parent_portal.grade_history')} + + + + + + + + + + + {progressData?.subjectGrades?.slice(0, 5).map((sg, i) => ( + + ))} + + + + + )} + + {/* Assessment Results */} + + + + + {t('parent_portal.assessment_results')} + + + + {!progressData?.assessments?.length ? ( +

{t('parent_portal.no_assessments')}

+ ) : ( +
+ {progressData.assessments.map((a) => ( +
+
+
+ +
+
+

{a.assessment.title}

+

{a.assessment.subject?.name ?? ''} - {fmtShort(a.assessedAt, loc)}

+
+
+
+

{a.score}/{a.maxScore}

+ {a.grade &&

{a.grade}

} +
+
+ ))} +
+ )} +
+
+
+ )} +
+ + {/* ═══════════════════════════════════════════════════════════ + ATTENDANCE & ILLNESS TAB + ═══════════════════════════════════════════════════════════ */} + +
+ {/* Attendance Rate Circular */} + + + + + {t('parent_portal.attendance_rate_circular')} + + + +
+ +
+
+

{selectedChild.attendanceSummary.present}

+

{t('parent_portal.days_present')}

+
+
+

{selectedChild.attendanceSummary.absent}

+

{t('parent_portal.days_absent')}

+
+
+

{selectedChild.attendanceSummary.excused}

+

{t('parent_portal.days_excused')}

+
+
+

{selectedChild.attendanceSummary.late}

+

{t('parent_portal.days_late')}

+
+
- ) : ( -
- {selectedChild.upcomingExams.map((exam) => { - const countdownText = exam.daysUntil === 0 - ? t('calendar.today') - : exam.daysUntil === 1 - ? t('calendar.tomorrow') - : exam.daysUntil < 7 - ? t('calendar.in_days', { days: exam.daysUntil }) - : t('calendar.in_weeks', { weeks: Math.floor(exam.daysUntil / 7) }); - return ( -
+ + + + {/* Illness Reports */} + + +
+ + + {t('parent_portal.illness_history')} + + +
+
+ + {selectedChild.illnessReports.length === 0 ? ( +

{t('parent_portal.no_illness_reports')}

+ ) : ( +
+ {selectedChild.illnessReports.map((report) => ( +
-
- +
+
-

{exam.title}

+

{report.reason}

- {exam.subject?.name ?? ''} {exam.classGroup?.name ?? ''} - {format(parseISO(exam.date), 'd. MMM yyyy', { locale: localeCode === 'de' ? deLocale : enLocale })} + {fmtShort(report.startDate, loc)} + {report.endDate ? ` - ${fmtShort(report.endDate, loc)}` : ''}

+ {report.description &&

{report.description}

}
- - - {countdownText} +
+ + {report.parentApprovalStatus === 'approved' ? t('parent_portal.approved') : report.parentApprovalStatus === 'rejected' ? t('parent_portal.rejected') : t('status.pending')} + + {report.documentUrl && ( + + {t('parent_portal.upload_certificate')} + + )} +
+
+ ))} +
+ )} + + + + {/* Upload Certificate */} + + +
+
+ +
+
+

{t('parent_portal.upload_certificate')}

+

{loc === 'de' ? 'Laden Sie ein aerztliches Attest hoch' : 'Upload a medical certificate'}

+
+ +
+
+
+
+ + + {/* ═══════════════════════════════════════════════════════════ + COMMUNICATION TAB + ═══════════════════════════════════════════════════════════ */} + +
+
+ {/* Messages from Teachers */} + + + + + {t('parent_portal.messages_teachers')} + + + + {commRooms.length === 0 ? ( +

{t('parent_portal.no_messages')}

+ ) : ( +
+ {commRooms.map((room) => ( + + + + {room.teacher.firstName[0]}{room.teacher.lastName[0]} + + +
+

{room.teacher.firstName} {room.teacher.lastName}

+

{room.lastMessage?.content ?? (loc === 'de' ? 'Kein Inhalt' : 'No content')}

+

{room.lastMessage ? fmtShort(room.lastMessage.createdAt, loc) : ''}

+
+ + {room.status} + +
+ ))} +
+ )} +
+
+ + {/* School Announcements */} + + + + + {t('parent_portal.school_announcements')} + + + + {announcements.length === 0 ? ( +

{t('parent_portal.no_announcements')}

+ ) : ( +
+ {announcements.map((ann) => ( +
+
+

{ann.title}

+ {ann.isPinned && {t('parent_portal.pinned')}} + {ann.priority === 'urgent' && {t('parent_portal.urgent')}} +
+

{ann.content}

+

{fmtShort(ann.createdAt, loc)}{ann.author ? ` - ${ann.author.firstName} ${ann.author.lastName}` : ''}

+
+ ))} +
+ )} +
+
+
+ + {/* Conversation Rooms */} + + +
+ + + {t('parent_portal.conversation_rooms')} + + +
+
+ + {commRooms.length === 0 ? ( +

{t('parent_portal.no_messages')}

+ ) : ( +
+ {commRooms.map((room) => ( +
+
+
+ +
+
+

{room.teacher.firstName} {room.teacher.lastName}

+

{loc === 'de' ? 'Fuer' : 'For'} {room.student.firstName} {room.student.lastName}

+
+
+ + {room.status}
- ); - })} -
- )} -
-
- - - {/* Grades Tab */} - - - - - - {t('parent_portal.recent_grades')} - - - - {selectedChild.recentGrades.length === 0 ? ( -
- -

{t('parent_portal.no_grades')}

-
- ) : ( -
- {selectedChild.recentGrades.map((grade) => ( -
-
-
- {grade.computedValue.toFixed(1)} -
-
-

{grade.subject.name}

-

{grade.period} - {grade.classGroup.name}

-
-
-
- ))} -
- )} -
-
-
- - {/* Attendance Tab */} - - - - - - {t('parent_portal.attendance_summary')} - - - -
-
-
- {t('parent_portal.attendance_rate')} - {getAttendanceRate(selectedChild.attendanceSummary)}% + ))}
- -
+ )} + + + + {/* School Events */} + + + + + {t('parent_portal.school_events')} + + + + {schoolEvents.length === 0 ? ( +

{t('parent_portal.no_events')}

+ ) : ( +
+ {schoolEvents.map((ev) => ( +
+
+
+ +
+
+

{ev.title}

+

{fmtShort(ev.startDate, loc)}{ev.location ? ` - ${ev.location}` : ''}

+
+
+ + {ev.eventType} + +
+ ))} +
+ )} +
+
+
+
+ + {/* ═══════════════════════════════════════════════════════════ + SCHEDULE TAB + ═══════════════════════════════════════════════════════════ */} + + {loadingSchedule ? ( +
+ ) : ( +
+ {/* Weekly Timetable */} + + + + + {t('parent_portal.weekly_timetable')} + + + + {!scheduleData?.timetableSlots?.length ? ( +

{t('parent_portal.no_schedule')}

+ ) : ( +
+ + + + + {dayNames(loc).map((d) => ( + + ))} + + + + {Array.from({ length: 8 }, (_, p) => p + 1).map((period) => { + const slotsForPeriod = scheduleData.timetableSlots.filter((s) => s.period === period && !s.isBreak); + const breakSlot = scheduleData.timetableSlots.find((s) => s.period === period && s.isBreak); + if (breakSlot) { + return ( + + + + ); + } + return ( + + + {Array.from({ length: 5 }, (_, d) => d).map((day) => { + const slot = slotsForPeriod.find((s) => s.dayOfWeek === day); + if (!slot) return + ); + })} + + ); + })} + +
{t('parent_portal.period')}{d}
+ {t('parent_portal.break')} ({breakSlot.startTime} - {breakSlot.endTime}) +
+ {period}. {t('parent_portal.period')} + ; + return ( + +
+

{slot.subject?.name ?? '-'}

+

{slot.startTime}-{slot.endTime}

+ {slot.teacher &&

{slot.teacher.firstName[0]}. {slot.teacher.lastName}

} +
+
+
+ )} +
+
+ +
+ {/* Exam Dates */} + + + + + {t('parent_portal.exam_dates')} + + + + {!scheduleData?.upcomingExams?.length ? ( +

{t('parent_portal.no_exams')}

+ ) : ( +
+ {scheduleData.upcomingExams.map((exam) => ( +
+
+

{exam.title}

+

{exam.subject?.name ?? ''} - {fmtShort(exam.date, loc)}

+
+ + + {exam.daysUntil === 0 ? (loc === 'de' ? 'Heute' : 'Today') : `${exam.daysUntil}d`} + +
+ ))} +
+ )} +
+
+ + {/* Counseling Appointments */} + + + + + {t('parent_portal.counseling_appointments_short')} + + + + {!scheduleData?.counselingAppointments?.length ? ( +

{t('parent_portal.no_counseling')}

+ ) : ( +
+ {scheduleData.counselingAppointments.map((apt) => ( +
+
+

{apt.requestType}

+

{apt.counselor.firstName} {apt.counselor.lastName}{apt.scheduledAt ? ` - ${fmtShort(apt.scheduledAt, loc)}` : ''}

+
+ + {apt.status} + +
+ ))} +
+ )} +
+
-
-
-

{selectedChild.attendanceSummary.present}

-

{t('parent_portal.days_present')}

-
-
-

{selectedChild.attendanceSummary.absent}

-

{t('parent_portal.days_absent')}

-
-
-

{selectedChild.attendanceSummary.excused}

-

{localeCode === 'de' ? 'Entschuldigt' : 'Excused'}

-
-
-

{selectedChild.attendanceSummary.late}

-

{localeCode === 'de' ? 'Verspaetet' : 'Late'}

-
-
- - + + {/* Homework Due */} + + + + + {t('parent_portal.homework_due')} + + + + {!scheduleData?.homeworkDue?.length ? ( +

{t('parent_portal.no_homework')}

+ ) : ( +
+ {scheduleData.homeworkDue.map((hw) => ( +
+
+
+ +
+
+

{hw.title}

+

{hw.subject?.name ?? ''} - {hw.classGroup.name}

+
+
+ + {t('parent_portal.due')}: {fmtShort(hw.dueDate, loc)} + +
+ ))} +
+ )} +
+
+
+ )}
- {/* Illness Reports Tab */} - - - - - - {t('parent_portal.illness_reports')} - - - - {selectedChild.illnessReports.length === 0 ? ( -
- -

{t('parent_portal.no_illness_reports')}

-
- ) : ( -
- {selectedChild.illnessReports.map((report) => ( -
-
-

{report.reason}

-

- {format(parseISO(report.startDate), 'd. MMM', { locale: localeCode === 'de' ? deLocale : enLocale })} - {report.endDate ? ` - ${format(parseISO(report.endDate), 'd. MMM', { locale: localeCode === 'de' ? deLocale : enLocale })}` : ''} -

+ {/* ═══════════════════════════════════════════════════════════ + REPORTS & DOCUMENTS TAB + ═══════════════════════════════════════════════════════════ */} + +
+ {/* Report Cards */} + + + + + {t('parent_portal.report_cards')} + + + + {selectedChild.recentGrades.length === 0 ? ( +

{t('parent_portal.no_grades')}

+ ) : ( +
+ {selectedChild.recentGrades.map((g) => ( +
+
+
+ {g.computedValue.toFixed(1)} +
+
+

{g.subject.name}

+

{g.period} - {g.classGroup.name}

+
+
+
- - {report.parentApprovalStatus === 'approved' ? t('parent_portal.approved') : report.parentApprovalStatus === 'rejected' ? t('parent_portal.rejected') : t('status.pending')} - -
- ))} -
- )} - - -
+ ))} +
+ )} + + - {/* Counseling Tab */} - - - - - - {t('parent_portal.counseling_appointments')} - - - - {selectedChild.counselingAppointments.length === 0 ? ( -
- -

{t('parent_portal.no_counseling')}

-
- ) : ( -
- {selectedChild.counselingAppointments.map((apt) => ( -
-
-

{apt.requestType}

-

- {apt.counselor.firstName} {apt.counselor.lastName} - {apt.scheduledAt ? ` - ${format(parseISO(apt.scheduledAt), 'd. MMM yyyy', { locale: localeCode === 'de' ? deLocale : enLocale })}` : ''} -

+ {/* Assessment Results */} + + + + + {t('parent_portal.assessment_results')} + + + + {!progressData?.assessments?.length ? ( +

{t('parent_portal.no_assessments')}

+ ) : ( +
+ {progressData.assessments.map((a) => ( +
+
+
+ +
+
+

{a.assessment.title}

+

{a.assessment.subject?.name ?? ''} - {fmtShort(a.assessedAt, loc)}

+ {a.feedback &&

{t('parent_portal.feedback')}: {a.feedback}

} +
+
+
+

{a.score}/{a.maxScore}

+ {a.grade &&

{a.grade}

} +
- - {apt.status} - -
- ))} -
- )} - - - + ))} +
+ )} + + - {/* Disciplinary Tab */} - - - - - - {t('parent_portal.disciplinary_cases')} - - - - {selectedChild.disciplinaryCases.length === 0 ? ( + {/* Portfolio Highlights */} + + + + + {t('parent_portal.portfolio_highlights')} + + +
- -

{t('parent_portal.no_disciplinary')}

+ +

{t('parent_portal.no_portfolio')}

+

{loc === 'de' ? 'Portfolio-Eintraege werden hier angezeigt, sobald sie verfuegbar sind.' : 'Portfolio entries will appear here when available.'}

- ) : ( -
- {selectedChild.disciplinaryCases.map((dCase) => ( -
-
-

{dCase.caseType}

-

- {format(parseISO(dCase.createdAt), 'd. MMM yyyy', { locale: localeCode === 'de' ? deLocale : enLocale })} -

-
- - {dCase.status} - -
- ))} -
- )} -
-
+
+
+
)} + {/* ═══════════════════════════════════════════════════════════════ + DIALOGS + ═══════════════════════════════════════════════════════════════ */} + {/* Illness Report Dialog */} - - - {t('parent_portal.report_illness')} - - - {selectedChild ? `${t('parent_portal.report_illness')} - ${selectedChild.firstName} ${selectedChild.lastName}` : ''} - + {t('parent_portal.report_illness')} + {selectedChild ? `${t('parent_portal.report_illness')} - ${selectedChild.firstName} ${selectedChild.lastName}` : ''}
- setIllnessForm((d) => ({ ...d, reason: e.target.value }))} className="w-full h-10 rounded-md border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 px-3 text-sm"> + + +
- + setIllnessForm((d) => ({ ...d, startDate: e.target.value }))} />
- + setIllnessForm((d) => ({ ...d, endDate: e.target.value }))} />
@@ -818,18 +1618,13 @@ export default function ParentPortalView() { - - - {t('parent_portal.request_conversation')} - - - {selectedChild ? `${t('parent_portal.request_conversation')} - ${selectedChild.firstName} ${selectedChild.lastName}` : ''} - + {t('parent_portal.request_conversation')} + {selectedChild ? `${t('parent_portal.request_conversation')} - ${selectedChild.firstName} ${selectedChild.lastName}` : ''}
-