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:
1 parent
490bd0650f
commit
6e5af13778
28 files changed
+10252
-494
No files matched your search
+162
-1
@@ -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")
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
}
|
||||
|
||||
+1317
-491
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
@@ -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
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user