Round 23: Report Card Generator, Seating Chart, School Library, Parent Portal Enhancement

- 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
This commit is contained in:
Mustafa Cetin committed 2026-07-29 23:42:26 +00:00
1 parent 490bd0650f
commit 6e5af13778
28 files changed
+10252 -494

No files matched your search

+162 -1
View File
@@ -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")
}
+144
View File
@@ -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<string, unknown> = {};
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 });
}
}
+163
View File
@@ -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<string, unknown> = { 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 });
}
}
+163
View File
@@ -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 });
}
}
+192
View File
@@ -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<string, unknown> = { 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 });
}
}
@@ -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 });
}
}
+134
View File
@@ -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<string, unknown> = { 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 });
}
}
+142
View File
@@ -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<string, number> = {};
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 });
}
}
@@ -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<string, {
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';
}> = {};
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<string, number> = {};
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<string, number[]> = {};
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<string, {
subject: { id: string; name: string };
competencies: Array<{
id: string;
code: string;
title: string;
level: number;
assessedAt: string;
}>;
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 });
}
}
@@ -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 });
}
}
+117 -1
View File
@@ -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);
+219
View File
@@ -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<string, unknown> = {};
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 });
}
}
+101
View File
@@ -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 });
}
}
+184
View File
@@ -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 });
}
}
+199
View File
@@ -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<string, unknown> = {};
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 });
}
}
@@ -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 });
}
}
@@ -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<string, number>();
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<string>();
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 });
}
}
+233
View File
@@ -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<string, number>();
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 });
}
}
+187
View File
@@ -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<string, unknown> = { 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 });
}
}
+95
View File
@@ -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);
}
}
+16
View File
@@ -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 <ExamCalendarView />;
case 'student-achievements': return <StudentAchievementsView />;
case 'student-study-planner': return <StudentStudyPlannerView />;
case 'seating-chart': return <SeatingChartView />;
case 'school-library': return <SchoolLibraryView />;
case 'report-cards': return <ReportCardView />;
default: return <DashboardView />;
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+694
View File
@@ -4614,6 +4614,72 @@ const de: TranslationDict = {
'parent_portal.days_present': 'Tage anwesend',
'parent_portal.attendance_rate': 'Anwesenheitsrate',
'parent_portal.all_children': 'Alle Kinder',
'parent_portal.academic_progress': 'Akademischer Fortschritt',
'parent_portal.attendance_illness': 'Anwesenheit & Krankheit',
'parent_portal.communication': 'Kommunikation',
'parent_portal.schedule': 'Stundenplan',
'parent_portal.reports_docs': 'Berichte & Dokumente',
'parent_portal.subject_grades': 'Faecher & Noten',
'parent_portal.class_average': 'Klassendurchschnitt',
'parent_portal.trend_up': 'Steigend',
'parent_portal.trend_down': 'Fallend',
'parent_portal.trend_stable': 'Stabil',
'parent_portal.competency_radar': 'Kompetenzradar',
'parent_portal.grade_history': 'Notenverlauf',
'parent_portal.assessment_results': 'Leistungskontrollergebnisse',
'parent_portal.attendance_rate_circular': 'Anwesenheitsrate',
'parent_portal.illness_history': 'Krankheitsverlauf',
'parent_portal.upload_certificate': 'Attest hochladen',
'parent_portal.messages_teachers': 'Nachrichten von Lehrern',
'parent_portal.conversation_rooms': 'Gespraechesraeume',
'parent_portal.school_announcements': 'Schulankuendigungen',
'parent_portal.weekly_timetable': 'Wochenstundenplan',
'parent_portal.exam_dates': 'Pruefungstermine',
'parent_portal.counseling_appointments_short': 'Beratungstermine',
'parent_portal.school_events': 'Schulveranstaltungen',
'parent_portal.homework_due': 'Hausaufgaben faellig',
'parent_portal.report_cards': 'Zeugnisse',
'parent_portal.portfolio_highlights': 'Portfolio-Highlights',
'parent_portal.request_meeting': 'Elterngespraech anfragen',
'parent_portal.reserve_book': 'Buch reservieren',
'parent_portal.view_announcements': 'Ankuendigungen anzeigen',
'parent_portal.child_dashboard': 'Dashboard',
'parent_portal.quick_stats': 'Schnellstatistiken',
'parent_portal.upcoming_deadlines': 'Anstehende Termine',
'parent_portal.recent_activity': 'Letzte Aktivitaet',
'parent_portal.no_announcements': 'Keine Ankuendigungen',
'parent_portal.no_messages': 'Keine Nachrichten',
'parent_portal.no_schedule': 'Kein Stundenplan verfuegbar',
'parent_portal.no_homework': 'Keine Hausaufgaben',
'parent_portal.no_events': 'Keine Veranstaltungen',
'parent_portal.no_portfolio': 'Keine Portfolio-Eintraege',
'parent_portal.no_assessments': 'Keine Leistungskontrollen',
'parent_portal.monday': 'Montag',
'parent_portal.tuesday': 'Dienstag',
'parent_portal.wednesday': 'Mittwoch',
'parent_portal.thursday': 'Donnerstag',
'parent_portal.friday': 'Freitag',
'parent_portal.period': 'Stunde',
'parent_portal.break': 'Pause',
'parent_portal.score': 'Punkte',
'parent_portal.feedback': 'Feedback',
'parent_portal.overall_average': 'Gesamtdurchschnitt',
'parent_portal.subjects_count': 'Faecher',
'parent_portal.latest_grade': 'Aktuellste Note',
'parent_portal.days_excused': 'Tage entschuldigt',
'parent_portal.days_late': 'Tage verspaetet',
'parent_portal.unread_messages': 'Ungelesene Nachrichten',
'parent_portal.active_conversations': 'Aktive Gespraeche',
'parent_portal.pinned': 'Angeheftet',
'parent_portal.urgent': 'Dringend',
'parent_portal.from': 'Von',
'parent_portal.due': 'Faellig',
'parent_portal.view': 'Ansehen',
'parent_portal.download': 'Herunterladen',
'parent_portal.grade_trend': 'Notentrend',
'parent_portal.comparison': 'Vergleich',
'parent_portal.student': 'Schueler/in',
'parent_portal.class': 'Klasse',
// ─── Content Crawl ──────────────────────────────────────────────────
'subjects.crawl': 'Inhalte crawlen',
@@ -5290,6 +5356,287 @@ const de: TranslationDict = {
'achievements.to_next_level': 'bis zur naechsten Stufe',
'achievements.time_remaining': 'Verbleibende Zeit',
'achievements.days': 'Tage',
// Seating Chart
'nav.seating_chart': 'Sitzplan',
'seating.title': 'Sitzplan',
'seating.subtitle': 'Sitzplaene erstellen und bearbeiten',
'seating.select_class': 'Klasse auswählen',
'seating.select_chart': 'Sitzplan auswählen',
'seating.new_chart': 'Neuer Sitzplan',
'seating.default': 'Standard',
'seating.no_chart_title': 'Kein Sitzplan vorhanden',
'seating.no_chart_subtitle': 'Erstellen Sie einen Sitzplan, um die Schülerplätze zu verwalten.',
'seating.create_chart_title': 'Neuen Sitzplan erstellen',
'seating.create_chart_desc': 'Erstellen Sie einen neuen Sitzplan für diese Klasse.',
'seating.chart_name': 'Name des Sitzplans',
'seating.chart_name_placeholder': 'z.B. Sitzplan Q1',
'seating.chart_created': 'Sitzplan erstellt',
'seating.chart_deleted': 'Sitzplan gelöscht',
'seating.delete_chart_title': 'Sitzplan löschen',
'seating.delete_chart_desc': 'Möchten Sie diesen Sitzplan wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
'seating.unassigned_students': 'Nicht zugewiesene Schüler',
'seating.all_students_assigned': 'Alle Schüler sind zugewiesen.',
'seating.assign_all': 'Alle zuweisen',
'seating.randomize': 'Zufällig mischen',
'seating.smart_arrange': 'Intelligent anordnen',
'seating.clear_all': 'Alle entfernen',
'seating.arrangement_saved': 'Sitzplan gespeichert',
'seating.arrangement_updated': 'Sitzplan aktualisiert',
'seating.teacher_desk': 'Lehrertisch',
'seating.door': 'Tür',
'seating.rows': 'Zeilen',
'seating.columns': 'Spalten',
'seating.layout_type': 'Layout-Typ',
'seating.layout_rows': 'Reihen',
'seating.layout_groups': 'Gruppen',
'seating.layout_ushape': 'U-Form',
'seating.layout_circle': 'Kreis',
'seating.layout_custom': 'Frei',
'seating.settings': 'Einstellungen',
'seating.show_door': 'Tür anzeigen',
'seating.show_windows': 'Fenster anzeigen',
'seating.door_position': 'Türposition',
'seating.window_position': 'Fensterposition',
'seating.pos_left': 'Links',
'seating.pos_right': 'Rechts',
'seating.pos_back': 'Hinten',
'seating.pos_both': 'Beide Seiten',
'seating.assigned_count': '{assigned} von {total} zugewiesen',
'seating.seat_position': 'Reihe {row}, Platz {col}',
'seating.view_profile': 'Profil anzeigen',
'seating.behavior_note': 'Verhaltenshinweis',
'seating.legend_assigned': 'Zugewiesen',
'seating.legend_empty': 'Leer',
'seating.legend_behavior': 'Verhaltenshinweis',
// School Library (DE)
'library.title': 'Schulbibliothek',
'library.subtitle': 'Buecherkatalog, Ausleihe und Reservierung',
'library.loading': 'Wird geladen...',
'library.add_book': 'Buch hinzufuegen',
'library.add_book_desc': 'Neues Buch zur Bibliothek hinzufuegen',
'library.book_added': 'Buch erfolgreich hinzugefuegt',
'library.error_adding_book': 'Fehler beim Hinzufuegen des Buches',
'library.catalog': 'Katalog',
'library.checkouts': 'Ausleihen',
'library.overdue_tab': 'Ueberfaellig',
'library.reservations': 'Reservierungen',
'library.statistics': 'Statistiken',
'library.search_placeholder': 'Titel, Autor, ISBN oder Kategorie suchen...',
'library.category': 'Kategorie',
'library.availability': 'Verfuegbarkeit',
'library.reading_level': 'Lesestufe',
'library.all_categories': 'Alle Kategorien',
'library.all': 'Alle',
'library.available': 'Verfuegbar',
'library.checked_out': 'Ausgeliehen',
'library.unavailable': 'Nicht verfuegbar',
'library.all_levels': 'Alle Stufen',
'library.level_elementary': 'Grundschule',
'library.level_middle': 'Mittelstufe',
'library.level_high': 'Oberstufe',
'library.level_advanced': 'Fortgeschritten',
'library.copies_available': 'Exemplare verfuegbar',
'library.no_books': 'Keine Buecher gefunden',
'library.add_first_book': 'Erstes Buch hinzufuegen',
'library.checkout': 'Ausleihen',
'library.reserve': 'Reservieren',
'library.active_checkouts': 'Aktive Ausleihen',
'library.new_checkout': 'Neue Ausleihe',
'library.no_active_checkouts': 'Keine aktiven Ausleihen',
'library.return_history': 'Rueckgabeverlauf',
'library.overdue_books': 'Ueberfaellige Buecher',
'library.no_overdue': 'Keine ueberfaelligen Buecher!',
'library.days_overdue': 'Tage ueberfaellig',
'library.fine_estimate': 'Geschaetzte Gebuehr',
'library.return': 'Zurueckgeben',
'library.renew': 'Verlaengern',
'library.renewed': 'verlaengert',
'library.renewal_success': 'Ausleihe erfolgreich verlaengert',
'library.error_renewal': 'Fehler bei der Verlaengerung',
'library.checkout_success': 'Buch erfolgreich ausgeliehen',
'library.error_checkout': 'Fehler bei der Ausleihe',
'library.return_success': 'Buch erfolgreich zurueckgegeben',
'library.error_return': 'Fehler bei der Rueckgabe',
'library.reservation_success': 'Buch erfolgreich reserviert',
'library.error_reservation': 'Fehler bei der Reservierung',
'library.reservation_cancelled': 'Reservierung storniert',
'library.error_cancel_reservation': 'Fehler beim Stornieren',
'library.no_student_selected': 'Kein Schueler ausgewaehlt',
'library.no_reservations': 'Keine Reservierungen',
'library.available_now': 'Jetzt verfuegbar',
'library.queue_position': 'Warteposition',
'library.reserved_on': 'Reserviert am',
'library.cancel': 'Stornieren',
'library.total_books': 'Buecher gesamt',
'library.total_copies': 'Exemplare gesamt',
'library.available_copies': 'Verfuegbar',
'library.checked_out_count': 'Ausgeliehen',
'library.overdue': 'Ueberfaellig',
'library.overdue_rate': 'Ueberfaellig-Quote',
'library.popular_books': 'Beliebte Buecher',
'library.category_distribution': 'Kategorieverteilung',
'library.checkout_trends': 'Ausleihtrends',
'library.no_data': 'Keine Daten verfuegbar',
'library.book_title': 'Titel',
'library.author': 'Autor',
'library.isbn': 'ISBN',
'library.publisher': 'Verlag',
'library.publish_year': 'Erscheinungsjahr',
'library.total_copies_field': 'Anzahl Exemplare',
'library.location': 'Standort',
'library.description': 'Beschreibung',
'library.student': 'Schueler',
'library.due_date': 'Faelligkeitsdatum',
'library.checkout_date': 'Ausleihdatum',
'library.condition': 'Zustand',
'library.condition_excellent': 'Sehr gut',
'library.condition_good': 'Gut',
'library.condition_fair': 'Befriedigend',
'library.condition_poor': 'Schlecht',
'library.notes': 'Notizen',
'library.notes_placeholder': 'Optionale Notizen...',
'library.checkout_book': 'Buch ausleihen',
'library.checkout_desc': 'Buch fuer einen Schueler ausleihen',
'library.select_book': 'Buch auswaehlen',
'library.select_student': 'Schueler auswaehlen',
'library.default_14_days': 'Standard: 14 Tage ab heute',
'library.return_book': 'Buch zurueckgeben',
'library.return_condition': 'Rueckgabezustand',
'library.fine_amount': 'Gebuehrenbetrag',
'library.overdue_fine_note': 'Dieses Buch ist ueberfaellig. Bitte Gebuehr pruefen.',
'library.checkout_receipt': 'Ausleihbeleg',
'library.confirm_delete_book': 'Buch wirklich loeschen?',
'library.book_deleted': 'Buch geloescht',
'library.error_delete_book': 'Fehler beim Loeschen',
'library.currently_checked_out': 'Aktuell ausgeliehen',
'library.reservation_queue': 'Reservierungswarteschlange',
'nav.school_library': 'Bibliothek',
'nav.school-library': 'Bibliothek',
'nav.seating_chart': 'Sitzplan',
'nav.seating-chart': 'Sitzplan',
// Report Card
'nav.report_cards': 'Zeugnisse',
'nav.report-cards': 'Zeugnisse',
'report_card.title': 'Zeugnisgenerator',
'report_card.subtitle': 'Zeugnisse erstellen, bearbeiten und verwalten',
'report_card.total_reports': 'Zeugnisse gesamt',
'report_card.drafts': 'Entwürfe',
'report_card.in_review': 'In Prüfung',
'report_card.published': 'Veröffentlicht',
'report_card.search_placeholder': 'Zeugnisse suchen...',
'report_card.filter_class': 'Klasse',
'report_card.filter_status': 'Status',
'report_card.filter_period': 'Zeitraum',
'report_card.all_classes': 'Alle Klassen',
'report_card.all_statuses': 'Alle Status',
'report_card.all_periods': 'Alle Zeiträume',
'report_card.create': 'Zeugnis erstellen',
'report_card.batch_generate': 'Klassensatz erstellen',
'report_card.manage_templates': 'Vorlagen verwalten',
'report_card.selected': 'ausgewählt',
'report_card.publish': 'Veröffentlichen',
'report_card.archive': 'Archivieren',
'report_card.no_reports': 'Keine Zeugnisse gefunden',
'report_card.create_first': 'Erstes Zeugnis erstellen',
'report_card.more': 'mehr',
'report_card.with_grades': 'Mit Noten',
'report_card.status_draft': 'Entwurf',
'report_card.status_review': 'In Prüfung',
'report_card.status_published': 'Veröffentlicht',
'report_card.status_archived': 'Archiviert',
'report_card.status_final': 'Final',
'report_card.submit_review': 'Zur Prüfung einreichen',
'report_card.finalize': 'Finalisieren',
'report_card.export_pdf': 'PDF exportieren',
'report_card.edit_title': 'Zeugnis bearbeiten',
'report_card.student_info': 'Schülerinformationen',
'report_card.student_name': 'Name',
'report_card.class_name': 'Klasse',
'report_card.school_year': 'Schuljahr',
'report_card.period': 'Zeitraum',
'report_card.date': 'Datum',
'report_card.subject_grades': 'Fachnoten',
'report_card.subject': 'Fach',
'report_card.grade': 'Note',
'report_card.label': 'Bewertung',
'report_card.status_col': 'Status',
'report_card.evaluation': 'Bewertung',
'report_card.competency_sections': 'Kompetenzbereiche',
'report_card.section': 'Bereich',
'report_card.no_sections': 'Keine Kompetenzbereiche vorhanden',
'report_card.teacher_comments': 'Lehrerbemerkungen',
'report_card.comments_placeholder': 'Bemerkungen zum Schüler eingeben...',
'report_card.overall_assessment': 'Gesamtbewertung',
'report_card.assessment_placeholder': 'Gesamtbewertung eingeben...',
'report_card.competency_radar': 'Kompetenzradar',
'report_card.competency_level': 'Kompetenzstufe',
'report_card.attendance_summary': 'Anwesenheit',
'report_card.present': 'Anwesend',
'report_card.absent': 'Fehlend',
'report_card.excused': 'Entschuldigt',
'report_card.late': 'Verspätet',
'report_card.actions': 'Aktionen',
'report_card.preview_title': 'Zeugnisvorschau',
'report_card.report_card_title': 'Zeugnis',
'report_card.competency_overview': 'Kompetenzübersicht',
'report_card.competency_details': 'Kompetenzdetails',
'report_card.teacher_signature': 'Unterschrift Lehrkraft',
'report_card.parent_signature': 'Unterschrift Eltern',
'report_card.grading_scale': 'Notenskala (1-6)',
'report_card.create_title': 'Neues Zeugnis erstellen',
'report_card.create_desc': 'Erstellen Sie ein neues Zeugnis für einen Schüler.',
'report_card.select_class': 'Klasse auswählen',
'report_card.select_student': 'Schüler auswählen',
'report_card.select_year': 'Schuljahr auswählen',
'report_card.template': 'Vorlage',
'report_card.select_template': 'Vorlage auswählen',
'report_card.no_template': 'Keine Vorlage',
'report_card.include_grades': 'Noten einbeziehen',
'report_card.batch_generate_title': 'Klassensatz erstellen',
'report_card.batch_generate_desc': 'Erstellen Sie Zeugnisse für alle Schüler einer Klasse.',
'report_card.generate': 'Erstellen',
'report_card.template_management': 'Vorlagenverwaltung',
'report_card.template_desc': 'Verwalten und erstellen Sie Zeugnisvorlagen.',
'report_card.existing_templates': 'Bestehende Vorlagen',
'report_card.default': 'Standard',
'report_card.template_name': 'Vorlagenname',
'report_card.template_name_placeholder': 'z.B. Standard-Grundschulzeugnis',
'report_card.template_description': 'Beschreibung',
'report_card.template_desc_placeholder': 'Kurze Beschreibung der Vorlage',
'report_card.layout': 'Layout',
'report_card.layout_default': 'Standard',
'report_card.layout_detailed': 'Detailliert',
'report_card.layout_compact': 'Kompakt',
'report_card.set_as_default': 'Als Standard festlegen',
'report_card.create_template': 'Vorlage erstellen',
'report_card.delete_confirm_title': 'Zeugnis löschen',
'report_card.delete_confirm_desc': 'Möchten Sie dieses Zeugnis wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
'report_card.created': 'Zeugnis erfolgreich erstellt',
'report_card.create_error': 'Fehler beim Erstellen des Zeugnisses',
'report_card.batch_generated': '{count} Zeugnisse erstellt',
'report_card.batch_error': 'Fehler beim Erstellen des Klassensatzes',
'report_card.saved': 'Zeugnis gespeichert',
'report_card.save_error': 'Fehler beim Speichern',
'report_card.status_updated': 'Status aktualisiert',
'report_card.status_error': 'Fehler beim Aktualisieren des Status',
'report_card.deleted': 'Zeugnis gelöscht',
'report_card.delete_error': 'Fehler beim Löschen',
'report_card.bulk_action_success': 'Massenaktion erfolgreich',
'report_card.bulk_action_error': 'Fehler bei der Massenaktion',
'report_card.export_success': 'Export erfolgreich',
'report_card.export_error': 'Fehler beim Export',
'report_card.load_error': 'Fehler beim Laden der Zeugnisse',
'report_card.template_created': 'Vorlage erstellt',
'report_card.template_error': 'Fehler beim Erstellen der Vorlage',
'report_card.section_header': 'Kopfzeile',
'report_card.section_grades': 'Noten',
'report_card.section_competencies': 'Kompetenzen',
'report_card.section_comments': 'Bemerkungen',
'report_card.section_attendance': 'Anwesenheit',
'report_card.section_signature': 'Unterschrift',
};
const en: TranslationDict = {
@@ -9902,6 +10249,72 @@ const en: TranslationDict = {
'parent_portal.days_present': 'Days Present',
'parent_portal.attendance_rate': 'Attendance Rate',
'parent_portal.all_children': 'All Children',
'parent_portal.academic_progress': 'Academic Progress',
'parent_portal.attendance_illness': 'Attendance & Illness',
'parent_portal.communication': 'Communication',
'parent_portal.schedule': 'Schedule',
'parent_portal.reports_docs': 'Reports & Documents',
'parent_portal.subject_grades': 'Subjects & Grades',
'parent_portal.class_average': 'Class Average',
'parent_portal.trend_up': 'Improving',
'parent_portal.trend_down': 'Declining',
'parent_portal.trend_stable': 'Stable',
'parent_portal.competency_radar': 'Competency Radar',
'parent_portal.grade_history': 'Grade History',
'parent_portal.assessment_results': 'Assessment Results',
'parent_portal.attendance_rate_circular': 'Attendance Rate',
'parent_portal.illness_history': 'Illness History',
'parent_portal.upload_certificate': 'Upload Certificate',
'parent_portal.messages_teachers': 'Messages from Teachers',
'parent_portal.conversation_rooms': 'Conversation Rooms',
'parent_portal.school_announcements': 'School Announcements',
'parent_portal.weekly_timetable': 'Weekly Timetable',
'parent_portal.exam_dates': 'Exam Dates',
'parent_portal.counseling_appointments_short': 'Counseling',
'parent_portal.school_events': 'School Events',
'parent_portal.homework_due': 'Homework Due',
'parent_portal.report_cards': 'Report Cards',
'parent_portal.portfolio_highlights': 'Portfolio Highlights',
'parent_portal.request_meeting': 'Request Meeting',
'parent_portal.reserve_book': 'Reserve Book',
'parent_portal.view_announcements': 'View Announcements',
'parent_portal.child_dashboard': 'Dashboard',
'parent_portal.quick_stats': 'Quick Stats',
'parent_portal.upcoming_deadlines': 'Upcoming Deadlines',
'parent_portal.recent_activity': 'Recent Activity',
'parent_portal.no_announcements': 'No announcements',
'parent_portal.no_messages': 'No messages',
'parent_portal.no_schedule': 'No schedule available',
'parent_portal.no_homework': 'No homework',
'parent_portal.no_events': 'No events',
'parent_portal.no_portfolio': 'No portfolio entries',
'parent_portal.no_assessments': 'No assessments',
'parent_portal.monday': 'Monday',
'parent_portal.tuesday': 'Tuesday',
'parent_portal.wednesday': 'Wednesday',
'parent_portal.thursday': 'Thursday',
'parent_portal.friday': 'Friday',
'parent_portal.period': 'Period',
'parent_portal.break': 'Break',
'parent_portal.score': 'Score',
'parent_portal.feedback': 'Feedback',
'parent_portal.overall_average': 'Overall Average',
'parent_portal.subjects_count': 'Subjects',
'parent_portal.latest_grade': 'Latest Grade',
'parent_portal.days_excused': 'Days Excused',
'parent_portal.days_late': 'Days Late',
'parent_portal.unread_messages': 'Unread Messages',
'parent_portal.active_conversations': 'Active Conversations',
'parent_portal.pinned': 'Pinned',
'parent_portal.urgent': 'Urgent',
'parent_portal.from': 'From',
'parent_portal.due': 'Due',
'parent_portal.view': 'View',
'parent_portal.download': 'Download',
'parent_portal.grade_trend': 'Grade Trend',
'parent_portal.comparison': 'Comparison',
'parent_portal.student': 'Student',
'parent_portal.class': 'Class',
// ─── Content Crawl ──────────────────────────────────────────────────
'subjects.crawl': 'Crawl Content',
@@ -10578,6 +10991,287 @@ const en: TranslationDict = {
'achievements.to_next_level': 'to next level',
'achievements.time_remaining': 'Time remaining',
'achievements.days': 'days',
// Seating Chart
'nav.seating_chart': 'Seating Chart',
'seating.title': 'Seating Chart',
'seating.subtitle': 'Create and manage seating arrangements',
'seating.select_class': 'Select class',
'seating.select_chart': 'Select chart',
'seating.new_chart': 'New Chart',
'seating.default': 'Default',
'seating.no_chart_title': 'No seating chart found',
'seating.no_chart_subtitle': 'Create a seating chart to manage student seats.',
'seating.create_chart_title': 'Create New Seating Chart',
'seating.create_chart_desc': 'Create a new seating chart for this class.',
'seating.chart_name': 'Chart Name',
'seating.chart_name_placeholder': 'e.g. Seating Q1',
'seating.chart_created': 'Chart created',
'seating.chart_deleted': 'Chart deleted',
'seating.delete_chart_title': 'Delete Seating Chart',
'seating.delete_chart_desc': 'Are you sure you want to delete this seating chart? This action cannot be undone.',
'seating.unassigned_students': 'Unassigned Students',
'seating.all_students_assigned': 'All students are assigned.',
'seating.assign_all': 'Assign All',
'seating.randomize': 'Randomize',
'seating.smart_arrange': 'Smart Arrange',
'seating.clear_all': 'Clear All',
'seating.arrangement_saved': 'Seating chart saved',
'seating.arrangement_updated': 'Seating arrangement updated',
'seating.teacher_desk': "Teacher's Desk",
'seating.door': 'Door',
'seating.rows': 'Rows',
'seating.columns': 'Columns',
'seating.layout_type': 'Layout Type',
'seating.layout_rows': 'Rows',
'seating.layout_groups': 'Groups',
'seating.layout_ushape': 'U-Shape',
'seating.layout_circle': 'Circle',
'seating.layout_custom': 'Custom',
'seating.settings': 'Settings',
'seating.show_door': 'Show Door',
'seating.show_windows': 'Show Windows',
'seating.door_position': 'Door Position',
'seating.window_position': 'Window Position',
'seating.pos_left': 'Left',
'seating.pos_right': 'Right',
'seating.pos_back': 'Back',
'seating.pos_both': 'Both Sides',
'seating.assigned_count': '{assigned} of {total} assigned',
'seating.seat_position': 'Row {row}, Seat {col}',
'seating.view_profile': 'View Profile',
'seating.behavior_note': 'Behavior note',
'seating.legend_assigned': 'Assigned',
'seating.legend_empty': 'Empty',
'seating.legend_behavior': 'Behavior note',
// School Library (EN)
'library.title': 'School Library',
'library.subtitle': 'Book catalog, checkouts, and reservations',
'library.loading': 'Loading...',
'library.add_book': 'Add Book',
'library.add_book_desc': 'Add a new book to the library',
'library.book_added': 'Book added successfully',
'library.error_adding_book': 'Error adding book',
'library.catalog': 'Catalog',
'library.checkouts': 'Checkouts',
'library.overdue_tab': 'Overdue',
'library.reservations': 'Reservations',
'library.statistics': 'Statistics',
'library.search_placeholder': 'Search by title, author, ISBN, or category...',
'library.category': 'Category',
'library.availability': 'Availability',
'library.reading_level': 'Reading Level',
'library.all_categories': 'All Categories',
'library.all': 'All',
'library.available': 'Available',
'library.checked_out': 'Checked Out',
'library.unavailable': 'Unavailable',
'library.all_levels': 'All Levels',
'library.level_elementary': 'Elementary',
'library.level_middle': 'Middle',
'library.level_high': 'High',
'library.level_advanced': 'Advanced',
'library.copies_available': 'copies available',
'library.no_books': 'No books found',
'library.add_first_book': 'Add your first book',
'library.checkout': 'Checkout',
'library.reserve': 'Reserve',
'library.active_checkouts': 'Active Checkouts',
'library.new_checkout': 'New Checkout',
'library.no_active_checkouts': 'No active checkouts',
'library.return_history': 'Return History',
'library.overdue_books': 'Overdue Books',
'library.no_overdue': 'No overdue books!',
'library.days_overdue': 'days overdue',
'library.fine_estimate': 'Estimated fine',
'library.return': 'Return',
'library.renew': 'Renew',
'library.renewed': 'renewed',
'library.renewal_success': 'Checkout renewed successfully',
'library.error_renewal': 'Error renewing checkout',
'library.checkout_success': 'Book checked out successfully',
'library.error_checkout': 'Error checking out book',
'library.return_success': 'Book returned successfully',
'library.error_return': 'Error returning book',
'library.reservation_success': 'Book reserved successfully',
'library.error_reservation': 'Error reserving book',
'library.reservation_cancelled': 'Reservation cancelled',
'library.error_cancel_reservation': 'Error cancelling reservation',
'library.no_student_selected': 'No student selected',
'library.no_reservations': 'No reservations',
'library.available_now': 'Available now',
'library.queue_position': 'Queue position',
'library.reserved_on': 'Reserved on',
'library.cancel': 'Cancel',
'library.total_books': 'Total Books',
'library.total_copies': 'Total Copies',
'library.available_copies': 'Available',
'library.checked_out_count': 'Checked Out',
'library.overdue': 'Overdue',
'library.overdue_rate': 'Overdue Rate',
'library.popular_books': 'Popular Books',
'library.category_distribution': 'Category Distribution',
'library.checkout_trends': 'Checkout Trends',
'library.no_data': 'No data available',
'library.book_title': 'Title',
'library.author': 'Author',
'library.isbn': 'ISBN',
'library.publisher': 'Publisher',
'library.publish_year': 'Publish Year',
'library.total_copies_field': 'Total Copies',
'library.location': 'Location',
'library.description': 'Description',
'library.student': 'Student',
'library.due_date': 'Due Date',
'library.checkout_date': 'Checkout Date',
'library.condition': 'Condition',
'library.condition_excellent': 'Excellent',
'library.condition_good': 'Good',
'library.condition_fair': 'Fair',
'library.condition_poor': 'Poor',
'library.notes': 'Notes',
'library.notes_placeholder': 'Optional notes...',
'library.checkout_book': 'Checkout Book',
'library.checkout_desc': 'Checkout a book for a student',
'library.select_book': 'Select Book',
'library.select_student': 'Select Student',
'library.default_14_days': 'Default: 14 days from today',
'library.return_book': 'Return Book',
'library.return_condition': 'Return Condition',
'library.fine_amount': 'Fine Amount',
'library.overdue_fine_note': 'This book is overdue. Please check the fine amount.',
'library.checkout_receipt': 'Checkout Receipt',
'library.confirm_delete_book': 'Are you sure you want to delete this book?',
'library.book_deleted': 'Book deleted',
'library.error_delete_book': 'Error deleting book',
'library.currently_checked_out': 'Currently Checked Out',
'library.reservation_queue': 'Reservation Queue',
'nav.school_library': 'Library',
'nav.school-library': 'Library',
'nav.seating_chart': 'Seating Chart',
'nav.seating-chart': 'Seating Chart',
// Report Card
'nav.report_cards': 'Report Cards',
'nav.report-cards': 'Report Cards',
'report_card.title': 'Report Card Generator',
'report_card.subtitle': 'Create, edit, and manage report cards',
'report_card.total_reports': 'Total Reports',
'report_card.drafts': 'Drafts',
'report_card.in_review': 'In Review',
'report_card.published': 'Published',
'report_card.search_placeholder': 'Search report cards...',
'report_card.filter_class': 'Class',
'report_card.filter_status': 'Status',
'report_card.filter_period': 'Period',
'report_card.all_classes': 'All Classes',
'report_card.all_statuses': 'All Statuses',
'report_card.all_periods': 'All Periods',
'report_card.create': 'Create Report Card',
'report_card.batch_generate': 'Batch Generate',
'report_card.manage_templates': 'Manage Templates',
'report_card.selected': 'selected',
'report_card.publish': 'Publish',
'report_card.archive': 'Archive',
'report_card.no_reports': 'No report cards found',
'report_card.create_first': 'Create first report card',
'report_card.more': 'more',
'report_card.with_grades': 'With Grades',
'report_card.status_draft': 'Draft',
'report_card.status_review': 'In Review',
'report_card.status_published': 'Published',
'report_card.status_archived': 'Archived',
'report_card.status_final': 'Final',
'report_card.submit_review': 'Submit for Review',
'report_card.finalize': 'Finalize',
'report_card.export_pdf': 'Export PDF',
'report_card.edit_title': 'Edit Report Card',
'report_card.student_info': 'Student Information',
'report_card.student_name': 'Name',
'report_card.class_name': 'Class',
'report_card.school_year': 'School Year',
'report_card.period': 'Period',
'report_card.date': 'Date',
'report_card.subject_grades': 'Subject Grades',
'report_card.subject': 'Subject',
'report_card.grade': 'Grade',
'report_card.label': 'Label',
'report_card.status_col': 'Status',
'report_card.evaluation': 'Evaluation',
'report_card.competency_sections': 'Competency Sections',
'report_card.section': 'Section',
'report_card.no_sections': 'No competency sections available',
'report_card.teacher_comments': 'Teacher Comments',
'report_card.comments_placeholder': 'Enter comments about the student...',
'report_card.overall_assessment': 'Overall Assessment',
'report_card.assessment_placeholder': 'Enter overall assessment...',
'report_card.competency_radar': 'Competency Radar',
'report_card.competency_level': 'Competency Level',
'report_card.attendance_summary': 'Attendance',
'report_card.present': 'Present',
'report_card.absent': 'Absent',
'report_card.excused': 'Excused',
'report_card.late': 'Late',
'report_card.actions': 'Actions',
'report_card.preview_title': 'Report Card Preview',
'report_card.report_card_title': 'Report Card',
'report_card.competency_overview': 'Competency Overview',
'report_card.competency_details': 'Competency Details',
'report_card.teacher_signature': 'Teacher Signature',
'report_card.parent_signature': 'Parent Signature',
'report_card.grading_scale': 'Grading Scale (1-6)',
'report_card.create_title': 'Create New Report Card',
'report_card.create_desc': 'Create a new report card for a student.',
'report_card.select_class': 'Select class',
'report_card.select_student': 'Select student',
'report_card.select_year': 'Select school year',
'report_card.template': 'Template',
'report_card.select_template': 'Select template',
'report_card.no_template': 'No template',
'report_card.include_grades': 'Include grades',
'report_card.batch_generate_title': 'Batch Generate Report Cards',
'report_card.batch_generate_desc': 'Generate report cards for all students in a class.',
'report_card.generate': 'Generate',
'report_card.template_management': 'Template Management',
'report_card.template_desc': 'Manage and create report card templates.',
'report_card.existing_templates': 'Existing Templates',
'report_card.default': 'Default',
'report_card.template_name': 'Template Name',
'report_card.template_name_placeholder': 'e.g. Standard Elementary Report',
'report_card.template_description': 'Description',
'report_card.template_desc_placeholder': 'Brief description of the template',
'report_card.layout': 'Layout',
'report_card.layout_default': 'Default',
'report_card.layout_detailed': 'Detailed',
'report_card.layout_compact': 'Compact',
'report_card.set_as_default': 'Set as default',
'report_card.create_template': 'Create Template',
'report_card.delete_confirm_title': 'Delete Report Card',
'report_card.delete_confirm_desc': 'Are you sure you want to delete this report card? This action cannot be undone.',
'report_card.created': 'Report card created successfully',
'report_card.create_error': 'Error creating report card',
'report_card.batch_generated': '{count} report cards generated',
'report_card.batch_error': 'Error generating batch report cards',
'report_card.saved': 'Report card saved',
'report_card.save_error': 'Error saving',
'report_card.status_updated': 'Status updated',
'report_card.status_error': 'Error updating status',
'report_card.deleted': 'Report card deleted',
'report_card.delete_error': 'Error deleting',
'report_card.bulk_action_success': 'Bulk action successful',
'report_card.bulk_action_error': 'Error performing bulk action',
'report_card.export_success': 'Export successful',
'report_card.export_error': 'Error exporting',
'report_card.load_error': 'Error loading report cards',
'report_card.template_created': 'Template created',
'report_card.template_error': 'Error creating template',
'report_card.section_header': 'Header',
'report_card.section_grades': 'Grades',
'report_card.section_competencies': 'Competencies',
'report_card.section_comments': 'Comments',
'report_card.section_attendance': 'Attendance',
'report_card.section_signature': 'Signature',
};
const translations: Record<string, TranslationDict> = { de, en };
+4 -1
View File
@@ -44,7 +44,10 @@ export type ViewName =
| 'tablet-grading'
| 'exam-calendar'
| 'student-achievements'
| 'student-study-planner';
| 'student-study-planner'
| 'seating-chart'
| 'school-library'
| 'report-cards';
export interface CurrentUser {
id: string;
+235
View File
@@ -1231,3 +1231,238 @@ Unresolved issues / Next phase priorities:
- Voice communication support in rooms
- Calendar reminders on tablet
- More styling improvements across all views
---
Task ID: 2-d
Agent: Parent Portal Enhancer
Task: Enhance parent portal with detailed dashboard, progress tracking, communication
Work Log:
- Read existing parent-portal-view.tsx (847 lines) and parent-portal API route
- Enhanced /api/parent-portal/route.ts with announcements, communication rooms, school events, homework due data
- Created /api/parent-portal/child-progress/route.ts with subject grades, class averages, competency radar data, assessment results
- Created /api/parent-portal/child-schedule/route.ts with timetable slots, exams, school events, counseling, homework
- Added 65+ new i18n keys in both German and English for all new tabs and features
- Completely rewrote parent-portal-view.tsx with 6 tabs:
- Dashboard: gradient stat cards with animated counters, upcoming deadlines, recent activity feed, quick actions
- Academic Progress: subject grades with trend arrows, Recharts radar chart, bar chart comparison with class average, line chart grade history, assessment results
- Attendance & Illness: circular progress indicator, attendance breakdown grid, illness history with certificate badges, medical certificate upload area
- Communication: messages from teachers, school announcements with pinned/urgent badges, conversation rooms, school events
- Schedule: weekly timetable grid view, exam dates, counseling appointments, homework due list
- Reports & Documents: report cards with download, assessment results, portfolio highlights
- Added new dialogs: Request Parent-Teacher Meeting, Report Illness, Request Conversation
- Added CircularProgress and AnimatedCounter components for visual polish
- All lint checks pass
Stage Summary:
- Enhanced parent portal from basic 7-tab view to comprehensive 6-tab dashboard with charts and rich data
- Created 2 new API routes (child-progress, child-schedule) for detailed sub-data
- Enhanced main API route with announcements, communication rooms, school events, homework
- Added Recharts radar, line, and bar charts for academic progress visualization
- Added circular progress indicator for attendance rate
- Added meeting request dialog for parent-teacher meetings
- All 65+ i18n keys added in both German and English
## Task ID: 2-c
Agent: School Library Builder
Task: Build School Library View with book lending system
Work Log:
- Added LibraryBook, BookCheckout, BookReservation models to Prisma schema with proper indexes and relations
- Added library relations to School, User, and Student models
- Created API route `/api/library/books/route.ts` with GET (list/search with filters) and POST (add book)
- Created API route `/api/library/books/[id]/route.ts` with GET, PUT, DELETE
- Created API route `/api/library/checkouts/route.ts` with GET (list with role-based filtering) and POST (checkout book with availability check)
- Created API route `/api/library/checkouts/[id]/route.ts` with PUT (return, renew, mark_overdue, mark_lost) and DELETE
- Created API route `/api/library/reservations/route.ts` with GET (list with role-based filtering) and POST (reserve with queue position)
- Created API route `/api/library/reservations/[id]/route.ts` with DELETE (cancel reservation with queue reordering)
- Created API route `/api/library/stats/route.ts` with GET (comprehensive statistics including popular books, category distribution, checkout trends, overdue list)
- Built comprehensive frontend component `school-library-view.tsx` with:
- Book Catalog: grid/list view toggle, search by title/author/ISBN/category, filter by category/availability/reading level, book cards with cover image placeholders, book detail dialog with full info
- Checkout System: select book and student, set due date (default 14 days), condition check, return with condition and fine, renewal option (max 3)
- Overdue Tracking: overdue list with red badges, days overdue calculation, fine estimation (0.50 EUR/day), warning styling
- Reservations: reserve unavailable books, queue position display, auto-notify when available, cancel reservation
- Statistics Dashboard: stat cards (total books, copies, available, checked out, overdue, overdue rate), popular books bar chart, category distribution pie chart, checkout trends line chart (Recharts)
- Role-based views: ADMIN/VICE_PRINCIPAL full CRUD, TEACHER checkout/return/renew, STUDENT browse/reserve, PARENT view child's checkouts/reserve
- Print support for checkout receipts, gradient category badges, availability status indicators
- Animated with framer-motion, dark mode support, mobile responsive
- Added 100+ i18n keys in both German and English
- Added 'school-library' to ViewName in store.ts
- Added navigation entry in app-layout.tsx for teacher, student, and parent nav sections
- Added SchoolLibraryView import and view rendering in app-layout.tsx
- All lint checks pass
- Database schema pushed successfully
Stage Summary:
- Full school library management system with book catalog, checkouts, overdue tracking, and reservations
- 3 new Prisma models (LibraryBook, BookCheckout, BookReservation) with proper indexes and relations
- 7 API routes with role-based access control and comprehensive business logic
- Frontend with 5 tabs (Catalog, Checkouts, Overdue, Reservations, Statistics) and Recharts charts
- Complete i18n support (DE + EN) with 100+ keys
- Print receipt support, animated transitions, dark mode, mobile responsive
---
Task ID: 2-b
Agent: Seating Chart Builder
Task: Build Seating Chart View with visual classroom layout
Work Log:
- Added SeatingChart model to Prisma schema with fields: schoolId, classGroupId, teacherId, name, layoutType, rows, columns, gap, arrangement (JSON), showDoor, showWindows, doorPosition, windowPosition, isTemplate, isDefault, notes
- Added SeatingChart relations to School, User, and ClassGroup models
- Created API route `/api/seating-charts/route.ts` with GET (list with filters: schoolId, classGroupId, teacherId, isTemplate) and POST (create chart with backward compat ClassGroup seatingOrder update)
- Created API route `/api/seating-charts/[id]/route.ts` with GET (single chart + enrolled students + behavior data), PUT (update chart), DELETE (delete chart)
- Created API route `/api/seating-charts/[id]/arrange/route.ts` with PUT supporting actions: move, randomize, smart-arrange, assign, clear, assign-all
- Smart Arrange algorithm: places high-behavior-score students with maximum distance between them based on behavior incident severity data
- Built comprehensive frontend component `seating-chart-view.tsx` with:
- Visual classroom layout grid with teacher's desk at the front
- Multiple layout templates (rows, groups, U-shape, circle, custom)
- Door/window markers with configurable positions
- HTML5 drag-and-drop from student list to seats
- Click-to-select seat swapping
- Student list sidebar with search/filter, drag from list to seat
- Student avatars with initials and gradient backgrounds
- Student info popover with name, seat position, behavior note, view profile link
- Layout controls (rows x columns, gap)
- Settings panel (show door, show windows, positions)
- Randomize, Smart Arrange, Assign All, Clear All actions
- Create/delete chart dialogs
- Color-coded student status (amber ring for behavior notes)
- Empty desk indicators (dashed border)
- Legend showing assigned/empty/behavior
- Print layout support
- Dark mode support
- Role-based views: TEACHER full CRUD, ADMIN/VICE_PRINCIPAL all, STUDENT/PARENT read-only
- Added 'seating-chart' to ViewName in store.ts
- Added navigation entries in app-layout.tsx for teacher and student nav sections
- Added 50+ i18n keys in both German and English
- All lint checks pass
Stage Summary:
- Full seating chart management system with visual classroom layout, drag-and-drop, and smart arrangement
- 1 new Prisma model (SeatingChart) with proper indexes and relations to School, User, ClassGroup
- 3 API routes with role-based access control, backward-compatible ClassGroup seatingOrder updates
- Smart Arrange algorithm that maximizes distance between high-behavior students
- Frontend with 5 layout templates, drag-and-drop, student popover, search, settings, print support
- Complete i18n support (DE + EN) with 50+ keys
- Dark mode, mobile responsive, print layout support
---
Task ID: 2-a
Agent: Report Card Builder
Task: Build Report Card Generator View
Work Log:
- Extended existing Report model in Prisma schema with new fields: teacherComments, attendanceSummary, overallAssessment, templateId, reviewedByUserId, reviewedAt, publishedAt; updated status enum to include DRAFT, REVIEW, PUBLISHED, ARCHIVED, FINAL
- Added ReportCardTemplate model to Prisma schema with fields: schoolId, name, description, sections (JSON), gradingScale (JSON), layout, isDefault
- Added reportCardTemplates relation to School model
- Ran db:push to sync schema changes
- Created API route `/api/report-cards/route.ts` with GET (list with filters: class, status, period, role-based access) and POST (create with auto-attendance computation)
- Created API route `/api/report-cards/[id]/route.ts` with GET (single with computed grades), PUT (update with status transitions, role-based permission checks), DELETE (admin only)
- Created API route `/api/report-cards/generate/route.ts` with POST (batch generate for entire class, auto-compute sections from competency categories, attendance, and grades)
- Created API route `/api/report-cards/export/route.ts` with POST (export data for PDF generation with school branding)
- Created API route `/api/report-cards/templates/route.ts` with GET (list templates) and POST (create template)
- Built comprehensive frontend component `report-card-view.tsx` with:
- Report Card List view with search, filter by class/status/period, bulk actions (publish, archive, delete)
- Report Card Editor with student info, subject grades table, competency sections, teacher comments, overall assessment, attendance summary, competency radar chart
- Report Card Preview with print-ready layout, school branding, grading scale legend, signature area
- Template Management dialog for creating/editing report card templates
- Batch Generate dialog for creating report cards for an entire class
- Role-based views: ADMIN/VICE_PRINCIPAL (full CRUD, batch, publish, templates), TEACHER (create/edit own, submit for review), STUDENT/PARENT (view published only)
- German grading system (1-6) with color-coded grade indicators and labels
- Animated stat counters, gradient header cards, framer-motion transitions
- PDF export via print window with A4-formatted HTML generation
- Dark mode support for editor (not preview)
- Added 120+ i18n keys in both German and English for report card feature
- Added 'report-cards' to ViewName in store.ts
- Added navigation entry in app-layout.tsx for teacher/admin, student, and parent nav sections
- All lint checks pass
Stage Summary:
- Extended Prisma schema with report card fields and ReportCardTemplate model
- Created 5 API routes for report card CRUD, batch generation, export, and template management
- Built comprehensive report-card-view.tsx with list, editor, preview modes
- German grading system (1-6) with visual color indicators
- Competency radar chart using Recharts
- Print-ready PDF export with school branding
- Role-based access control for all views
- 120+ i18n keys added in German and English
- Navigation integrated for all user roles
---
Task ID: 3
Agent: Styling Enhancer Round 23
Task: Enhance styling across all new Round 23 views
Work Log:
- Enhanced report-card-view.tsx with gradient header banner featuring animated count badge, shimmer effect on status badges, slide-in animations for list items, gradient borders on report card preview, animated grade indicators with hover scale, and improved hover effects on report card cards
- Enhanced seating-chart-view.tsx with gradient header with classroom illustration, animated desk placement transitions (hover scale, active scale), student avatar with gradient background circles and status indicator dots, hover glow effect on desks, drag indicator animation on empty cells, and color-coded student status indicators
- Enhanced school-library-view.tsx with gradient header banner with book illustrations, availability status indicators with pulse animation (ping dot), book card hover effects (scale + shadow via book-card-hover class), overdue warning styling with gradient borders (overdue-gradient-border class), statistics chart gradient fills (bar and line charts), and glass-card styling for statistics cards
- Enhanced parent-portal-view.tsx with warm gradient header banner, child selector with animated transitions (whileHover/whileTap), competency radar chart with gradient fills, circular progress with SVG gradient stroke, conversation card styling with hover effects and gradient avatars, and improved button styling on gradient header
- Added global CSS animations and utilities: shimmer keyframe, print styles for report cards, desk-glow keyframe, book-card-hover class, availability-pulse animation, overdue-gradient-border class, tab-content-enter animation, glass-card class with dark mode support
- Added missing i18n keys: report_card.subtitle (de/en), seating.subtitle (de/en)
Stage Summary:
- All 4 views now have consistent gradient header banners with emerald/teal theme
- Shimmer effects on report card status badges for visual feedback
- Animated grade indicators with hover interactions in report card view
- Student desk hover glow and status indicator dots in seating chart
- Book card hover lift effects and availability pulse indicators in library
- Overdue gradient border styling for library overdue items
- Gradient fills on charts (bar, line, radar) for visual polish
- Glassmorphism card styling for library statistics
- Parent portal header with warm gradient and backdrop-blur buttons
- Child selector with framer-motion whileHover/whileTap micro-interactions
- Circular progress with SVG gradient stroke in parent portal
- All changes pass ESLint validation
---
Task ID: Round-23
Agent: main
Task: Round 23 — Report Card Generator, Seating Chart, School Library, Parent Portal Enhancement, Styling
Work Log:
- Built Report Card Generator View with professional report card creation
- Report card editor with subject grades, competency radar chart, teacher comments
- Template management, batch generation, PDF export
- Status workflow: draft → review → published → archived
- Role-based: Admin full CRUD, teacher submit for review, student/parent view only
- Built Seating Chart View with visual classroom layout
- Visual grid representing classroom desks
- Multiple layout templates (rows, groups, U-shape, circle)
- Click-to-assign and drag-and-drop student placement
- Randomize and smart arrange features
- Student info popover on seated students
- Built School Library View with book lending system
- Book catalog with search, filter, grid/list view
- Checkout system with due dates, renewals
- Overdue tracking with notifications
- Reservation queue system
- Statistics dashboard with Recharts
- Enhanced Parent Portal View with comprehensive dashboard
- Child overview with multiple children selector
- Academic progress tab with competency radar chart
- Attendance & illness tab with approval
- Communication tab with messages and conversations
- Schedule & calendar tab
- Reports & documents tab
- Added styling enhancements across all new views
- Added i18n breadcrumb keys for new views (DE + EN)
- All API endpoints verified working
Stage Summary:
- 4 new views built: report-cards, seating-chart, school-library, enhanced parent-portal
- 15+ new API route files created
- 3+ new Prisma models (SeatingChart, LibraryBook, BookCheckout, BookReservation)
- 200+ i18n keys added
- All views render correctly in browser
- Dev server running without errors
- All lint checks pass
Unresolved issues / Next phase priorities:
- WebSocket connection timeout errors (Caddy proxy)
- Need more schlaukopf.de content cloning
- Voice communication support in rooms
- More styling improvements across all views
- More schlaukopf.de exercises and questions
- School event management enhancements
- Data import/export with CSV/Excel