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

- Report Card Generator: Professional report cards with PDF export
  - Subject grades table with competency levels
  - Competency radar chart, teacher comments
  - Template management, batch generation
  - Status workflow: draft → review → published → archived

- Seating Chart View: Visual classroom layout
  - Grid-based classroom with desk positions
  - Multiple layout templates (rows, groups, U-shape, circle)
  - Click-to-assign student placement
  - Randomize and smart arrange features
  - Student info popover

- School Library View: Book lending system
  - Book catalog with search and filter
  - Checkout system with due dates and renewals
  - Overdue tracking with notifications
  - Reservation queue system
  - Statistics dashboard with charts

- Enhanced Parent Portal: Comprehensive dashboard
  - Child overview with multiple children selector
  - Academic progress with competency radar chart
  - Attendance & illness with approval
  - Communication, schedule, and reports tabs

- Bug fixes: i18n breadcrumb keys for new views
- Styling: Gradient headers, animations, hover effects across all new views
- 200+ i18n keys added, 15+ API routes, 3+ Prisma models
This commit is contained in:
Mustafa Cetin committed 2026-07-29 23:42:26 +00:00
1 parent 490bd0650f
commit 6e5af13778
28 files changed
+10252 -494

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// ── GET: Get a single book ──────────────────────────────────────────
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const book = await db.libraryBook.findUnique({
where: { id },
include: {
checkouts: {
where: { status: { in: ['active', 'overdue'] } },
include: {
student: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { checkoutDate: 'desc' },
},
reservations: {
where: { status: 'waiting' },
include: {
student: { select: { id: true, firstName: true, lastName: true } },
user: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { createdAt: 'asc' },
},
},
});
if (!book) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
return NextResponse.json(book);
} catch (error) {
console.error('LibraryBook GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ── PUT: Update a book ──────────────────────────────────────────────
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const userRole = session.user?.role;
if (
userRole !== 'SCHOOL_ADMIN' &&
userRole !== 'VICE_PRINCIPAL' &&
userRole !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const existing = await db.libraryBook.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
const updateData: Record<string, unknown> = {};
const allowedFields = [
'title', 'author', 'isbn', 'publisher', 'publishYear',
'category', 'readingLevel', 'language', 'description',
'totalCopies', 'availableCopies', 'location',
];
for (const field of allowedFields) {
if (body[field] !== undefined) updateData[field] = body[field];
}
if (body.coverGradient) updateData.coverGradient = JSON.stringify(body.coverGradient);
if (body.tags) updateData.tags = JSON.stringify(body.tags);
const book = await db.libraryBook.update({
where: { id },
data: updateData,
});
return NextResponse.json(book);
} catch (error) {
console.error('LibraryBook PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ── DELETE: Remove a book ──────────────────────────────────────────
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const userRole = session.user?.role;
if (
userRole !== 'SCHOOL_ADMIN' &&
userRole !== 'VICE_PRINCIPAL' &&
userRole !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
// Check for active checkouts
const activeCheckouts = await db.bookCheckout.count({
where: { bookId: id, status: { in: ['active', 'overdue'] } },
});
if (activeCheckouts > 0) {
return NextResponse.json(
{ error: 'Cannot delete book with active checkouts' },
{ status: 400 }
);
}
await db.libraryBook.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
console.error('LibraryBook DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+163
View File
@@ -0,0 +1,163 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// ── GET: List/search books ──────────────────────────────────────────
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
const search = searchParams.get('search');
const category = searchParams.get('category');
const readingLevel = searchParams.get('readingLevel');
const availability = searchParams.get('availability'); // all, available, checked_out
const limit = parseInt(searchParams.get('limit') ?? '50');
const offset = parseInt(searchParams.get('offset') ?? '0');
if (!schoolId) {
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
}
const where: Record<string, unknown> = { schoolId };
if (search) {
where.OR = [
{ title: { contains: search } },
{ author: { contains: search } },
{ isbn: { contains: search } },
{ category: { contains: search } },
];
}
if (category) where.category = category;
if (readingLevel) where.readingLevel = readingLevel;
if (availability === 'available') {
where.availableCopies = { gt: 0 };
} else if (availability === 'checked_out') {
where.availableCopies = 0;
}
const [books, total] = await Promise.all([
db.libraryBook.findMany({
where,
include: {
_count: { select: { checkouts: true, reservations: true } },
},
orderBy: { title: 'asc' },
take: limit,
skip: offset,
}),
db.libraryBook.count({ where }),
]);
// Get active checkouts count per book
const activeCheckouts = await db.bookCheckout.groupBy({
by: ['bookId'],
where: { schoolId, status: 'active' },
_count: { id: true },
});
const activeCheckoutMap = Object.fromEntries(
activeCheckouts.map((c) => [c.bookId, c._count.id])
);
// Get waiting reservations count per book
const waitingReservations = await db.bookReservation.groupBy({
by: ['bookId'],
where: { schoolId, status: 'waiting' },
_count: { id: true },
});
const waitingReservationMap = Object.fromEntries(
waitingReservations.map((r) => [r.bookId, r._count.id])
);
const enriched = books.map((book) => ({
...book,
activeCheckouts: activeCheckoutMap[book.id] || 0,
waitingReservations: waitingReservationMap[book.id] || 0,
isAvailable: book.availableCopies > 0,
}));
return NextResponse.json({ books: enriched, total });
} catch (error) {
console.error('LibraryBooks GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ── POST: Add a new book ──────────────────────────────────────────
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const userRole = session.user?.role;
if (
userRole !== 'SCHOOL_ADMIN' &&
userRole !== 'VICE_PRINCIPAL' &&
userRole !== 'SUPER_ADMIN' &&
userRole !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const {
title,
author,
isbn,
publisher,
publishYear,
category,
readingLevel,
language,
description,
coverGradient,
totalCopies,
location,
tags,
schoolId,
} = body;
if (!title || !author || !schoolId || !category) {
return NextResponse.json(
{ error: 'title, author, schoolId, and category are required' },
{ status: 400 }
);
}
const book = await db.libraryBook.create({
data: {
schoolId,
title,
author,
isbn: isbn || null,
publisher: publisher || null,
publishYear: publishYear || null,
category,
readingLevel: readingLevel || null,
language: language || 'de',
description: description || null,
coverGradient: coverGradient ? JSON.stringify(coverGradient) : null,
totalCopies: totalCopies ?? 1,
availableCopies: totalCopies ?? 1,
location: location || null,
tags: tags ? JSON.stringify(tags) : null,
},
});
return NextResponse.json(book, { status: 201 });
} catch (error) {
console.error('LibraryBooks POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+163
View File
@@ -0,0 +1,163 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// ── PUT: Return a book (or renew) ──────────────────────────────────
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const userRole = session.user?.role;
if (
userRole !== 'TEACHER' &&
userRole !== 'SCHOOL_ADMIN' &&
userRole !== 'VICE_PRINCIPAL' &&
userRole !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const { action, returnCondition, notes, fineAmount } = body;
const checkout = await db.bookCheckout.findUnique({
where: { id },
include: { book: true },
});
if (!checkout) {
return NextResponse.json({ error: 'Checkout not found' }, { status: 404 });
}
if (action === 'return') {
// Return the book
const updated = await db.bookCheckout.update({
where: { id },
data: {
status: 'returned',
returnDate: new Date(),
returnCondition: returnCondition || checkout.condition,
notes: notes || checkout.notes,
fineAmount: fineAmount ?? checkout.fineAmount,
},
include: {
book: { select: { id: true, title: true, author: true } },
student: { select: { id: true, firstName: true, lastName: true } },
},
});
// Increase available copies
await db.libraryBook.update({
where: { id: checkout.bookId },
data: { availableCopies: { increment: 1 } },
});
// Check if there are waiting reservations to notify
const nextReservation = await db.bookReservation.findFirst({
where: { bookId: checkout.bookId, status: 'waiting' },
orderBy: { createdAt: 'asc' },
});
if (nextReservation) {
await db.bookReservation.update({
where: { id: nextReservation.id },
data: { status: 'notified', notifiedAt: new Date() },
});
}
return NextResponse.json(updated);
} else if (action === 'renew') {
// Renew the checkout (extend due date by 14 days)
if (checkout.status === 'returned' || checkout.status === 'lost') {
return NextResponse.json(
{ error: 'Cannot renew a returned or lost checkout' },
{ status: 400 }
);
}
if (checkout.renewalCount >= 3) {
return NextResponse.json(
{ error: 'Maximum renewals reached (3)' },
{ status: 400 }
);
}
const newDueDate = new Date(checkout.dueDate);
newDueDate.setDate(newDueDate.getDate() + 14);
const updated = await db.bookCheckout.update({
where: { id },
data: {
dueDate: newDueDate,
renewalCount: { increment: 1 },
status: 'active',
},
include: {
book: { select: { id: true, title: true, author: true } },
student: { select: { id: true, firstName: true, lastName: true } },
},
});
return NextResponse.json(updated);
} else if (action === 'mark_overdue') {
const updated = await db.bookCheckout.update({
where: { id },
data: { status: 'overdue' },
});
return NextResponse.json(updated);
} else if (action === 'mark_lost') {
const updated = await db.bookCheckout.update({
where: { id },
data: { status: 'lost' },
});
// Decrease total copies
await db.libraryBook.update({
where: { id: checkout.bookId },
data: { totalCopies: { decrement: 1 } },
});
return NextResponse.json(updated);
}
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
} catch (error) {
console.error('BookCheckout PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ── DELETE: Remove a checkout record ──────────────────────────────
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const userRole = session.user?.role;
if (
userRole !== 'SCHOOL_ADMIN' &&
userRole !== 'VICE_PRINCIPAL' &&
userRole !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
await db.bookCheckout.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
console.error('BookCheckout DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+192
View File
@@ -0,0 +1,192 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// ── GET: List checkouts ──────────────────────────────────────────────
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
const status = searchParams.get('status');
const studentId = searchParams.get('studentId');
const bookId = searchParams.get('bookId');
const overdue = searchParams.get('overdue') === 'true';
const limit = parseInt(searchParams.get('limit') ?? '50');
const offset = parseInt(searchParams.get('offset') ?? '0');
if (!schoolId) {
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
}
const where: Record<string, unknown> = { schoolId };
if (status) where.status = status;
if (studentId) where.studentId = studentId;
if (bookId) where.bookId = bookId;
if (overdue) {
where.status = 'active';
where.dueDate = { lt: new Date() };
}
// Role-based filtering
if (session.user?.role === 'STUDENT') {
const student = await db.student.findFirst({
where: { userId: session.userId },
select: { id: true },
});
if (student) {
where.studentId = student.id;
}
} else if (session.user?.role === 'PARENT') {
const parentLinks = await db.parentStudentLink.findMany({
where: { parentId: session.userId },
select: { studentId: true },
});
const studentIds = parentLinks.map((l) => l.studentId);
where.studentId = { in: studentIds };
}
const [checkouts, total] = await Promise.all([
db.bookCheckout.findMany({
where,
include: {
book: { select: { id: true, title: true, author: true, category: true } },
student: { select: { id: true, firstName: true, lastName: true } },
checkedOutByUser: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { checkoutDate: 'desc' },
take: limit,
skip: offset,
}),
db.bookCheckout.count({ where }),
]);
// Mark overdue checkouts
const now = new Date();
const enriched = checkouts.map((checkout) => {
const isOverdue = checkout.status === 'active' && new Date(checkout.dueDate) < now;
const daysOverdue = isOverdue
? Math.floor((now.getTime() - new Date(checkout.dueDate).getTime()) / (1000 * 60 * 60 * 24))
: 0;
return {
...checkout,
isOverdue,
daysOverdue,
};
});
// Auto-update overdue status in the background
if (status === 'active' || !status) {
const overdueIds = enriched
.filter((c) => c.isOverdue && c.status === 'active')
.map((c) => c.id);
if (overdueIds.length > 0) {
await db.bookCheckout.updateMany({
where: { id: { in: overdueIds } },
data: { status: 'overdue' },
});
}
}
return NextResponse.json({ checkouts: enriched, total });
} catch (error) {
console.error('BookCheckouts GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ── POST: Checkout a book ──────────────────────────────────────────
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const userRole = session.user?.role;
if (
userRole !== 'TEACHER' &&
userRole !== 'SCHOOL_ADMIN' &&
userRole !== 'VICE_PRINCIPAL' &&
userRole !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const { bookId, studentId, schoolId, dueDate, condition, notes } = body;
if (!bookId || !studentId || !schoolId) {
return NextResponse.json(
{ error: 'bookId, studentId, and schoolId are required' },
{ status: 400 }
);
}
// Check book availability
const book = await db.libraryBook.findUnique({ where: { id: bookId } });
if (!book) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
if (book.availableCopies <= 0) {
return NextResponse.json({ error: 'No copies available' }, { status: 400 });
}
// Check if student already has this book checked out
const existingCheckout = await db.bookCheckout.findFirst({
where: {
bookId,
studentId,
status: { in: ['active', 'overdue'] },
},
});
if (existingCheckout) {
return NextResponse.json(
{ error: 'Student already has this book checked out' },
{ status: 400 }
);
}
// Calculate due date (default 14 days from now)
const checkoutDueDate = dueDate
? new Date(dueDate)
: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000);
const checkout = await db.bookCheckout.create({
data: {
schoolId,
bookId,
studentId,
checkedOutBy: session.userId,
dueDate: checkoutDueDate,
condition: condition || 'good',
notes: notes || null,
},
include: {
book: { select: { id: true, title: true, author: true } },
student: { select: { id: true, firstName: true, lastName: true } },
checkedOutByUser: { select: { id: true, firstName: true, lastName: true } },
},
});
// Decrease available copies
await db.libraryBook.update({
where: { id: bookId },
data: { availableCopies: { decrement: 1 } },
});
return NextResponse.json(checkout, { status: 201 });
} catch (error) {
console.error('BookCheckout POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -0,0 +1,59 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// ── DELETE: Cancel a reservation ──────────────────────────────────
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const reservation = await db.bookReservation.findUnique({
where: { id },
});
if (!reservation) {
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
}
// Only the user who made the reservation, or admin, can cancel
const userRole = session.user?.role;
const isOwner = reservation.userId === session.userId;
const isAdmin = userRole === 'SCHOOL_ADMIN' || userRole === 'VICE_PRINCIPAL' || userRole === 'SUPER_ADMIN';
if (!isOwner && !isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
await db.bookReservation.update({
where: { id },
data: { status: 'cancelled' },
});
// Re-calculate queue positions for remaining reservations
const remaining = await db.bookReservation.findMany({
where: { bookId: reservation.bookId, status: 'waiting' },
orderBy: { createdAt: 'asc' },
});
await Promise.all(
remaining.map((r, index) =>
db.bookReservation.update({
where: { id: r.id },
data: { queuePosition: index + 1 },
})
)
);
return NextResponse.json({ success: true });
} catch (error) {
console.error('BookReservation DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+134
View File
@@ -0,0 +1,134 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// ── GET: List reservations ──────────────────────────────────────────
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
const bookId = searchParams.get('bookId');
const studentId = searchParams.get('studentId');
const status = searchParams.get('status');
if (!schoolId) {
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
}
const where: Record<string, unknown> = { schoolId };
if (bookId) where.bookId = bookId;
if (studentId) where.studentId = studentId;
if (status) where.status = status;
// Role-based filtering
if (session.user?.role === 'STUDENT') {
const student = await db.student.findFirst({
where: { userId: session.userId },
select: { id: true },
});
if (student) {
where.studentId = student.id;
}
} else if (session.user?.role === 'PARENT') {
const parentLinks = await db.parentStudentLink.findMany({
where: { parentId: session.userId },
select: { studentId: true },
});
const studentIds = parentLinks.map((l) => l.studentId);
where.studentId = { in: studentIds };
}
const reservations = await db.bookReservation.findMany({
where,
include: {
book: { select: { id: true, title: true, author: true, availableCopies: true, totalCopies: true } },
student: { select: { id: true, firstName: true, lastName: true } },
user: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { createdAt: 'asc' },
});
return NextResponse.json({ reservations });
} catch (error) {
console.error('BookReservations GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ── POST: Reserve a book ──────────────────────────────────────────
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const { bookId, studentId, schoolId } = body;
if (!bookId || !studentId || !schoolId) {
return NextResponse.json(
{ error: 'bookId, studentId, and schoolId are required' },
{ status: 400 }
);
}
// Check book exists
const book = await db.libraryBook.findUnique({ where: { id: bookId } });
if (!book) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
// Check if already reserved
const existingReservation = await db.bookReservation.findFirst({
where: {
bookId,
studentId,
status: { in: ['waiting', 'notified'] },
},
});
if (existingReservation) {
return NextResponse.json(
{ error: 'Already reserved this book' },
{ status: 400 }
);
}
// Calculate queue position
const maxPosition = await db.bookReservation.findFirst({
where: { bookId, status: 'waiting' },
orderBy: { queuePosition: 'desc' },
select: { queuePosition: true },
});
const queuePosition = (maxPosition?.queuePosition ?? 0) + 1;
const reservation = await db.bookReservation.create({
data: {
schoolId,
bookId,
studentId,
userId: session.userId,
queuePosition,
status: 'waiting',
},
include: {
book: { select: { id: true, title: true, author: true } },
student: { select: { id: true, firstName: true, lastName: true } },
user: { select: { id: true, firstName: true, lastName: true } },
},
});
return NextResponse.json(reservation, { status: 201 });
} catch (error) {
console.error('BookReservation POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+142
View File
@@ -0,0 +1,142 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// ── GET: Library statistics ──────────────────────────────────────────
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
if (!schoolId) {
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
}
const now = new Date();
// Basic counts
const [
totalBooks,
totalCopies,
availableCopies,
activeCheckouts,
overdueCheckouts,
totalCheckouts,
totalReservations,
waitingReservations,
] = await Promise.all([
db.libraryBook.count({ where: { schoolId } }),
db.libraryBook.aggregate({ where: { schoolId }, _sum: { totalCopies: true } }),
db.libraryBook.aggregate({ where: { schoolId }, _sum: { availableCopies: true } }),
db.bookCheckout.count({ where: { schoolId, status: 'active' } }),
db.bookCheckout.count({ where: { schoolId, status: 'overdue' } }),
db.bookCheckout.count({ where: { schoolId } }),
db.bookReservation.count({ where: { schoolId } }),
db.bookReservation.count({ where: { schoolId, status: 'waiting' } }),
]);
// Overdue rate
const overdueRate = totalCheckouts > 0
? Math.round((overdueCheckouts / totalCheckouts) * 100)
: 0;
// Most popular books (by checkout count)
const popularBooks = await db.bookCheckout.groupBy({
by: ['bookId'],
where: { schoolId },
_count: { id: true },
orderBy: { _count: { id: 'desc' } },
take: 10,
});
const popularBookIds = popularBooks.map((b) => b.bookId);
const popularBookDetails = await db.libraryBook.findMany({
where: { id: { in: popularBookIds } },
select: { id: true, title: true, author: true, category: true },
});
const popularBookMap = Object.fromEntries(
popularBookDetails.map((b) => [b.id, b])
);
const popularBooksWithCount = popularBooks.map((b) => ({
...popularBookMap[b.bookId],
checkoutCount: b._count.id,
}));
// Category distribution
const categoryDistribution = await db.libraryBook.groupBy({
by: ['category'],
where: { schoolId },
_count: { id: true },
});
// Checkout trends (last 30 days, by day)
const thirtyDaysAgo = new Date(now);
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const recentCheckouts = await db.bookCheckout.findMany({
where: {
schoolId,
checkoutDate: { gte: thirtyDaysAgo },
},
select: { checkoutDate: true },
});
// Group by day
const checkoutTrends: Record<string, number> = {};
for (const checkout of recentCheckouts) {
const dateKey = new Date(checkout.checkoutDate).toISOString().split('T')[0];
checkoutTrends[dateKey] = (checkoutTrends[dateKey] || 0) + 1;
}
const trendData = Object.entries(checkoutTrends)
.map(([date, count]) => ({ date, count }))
.sort((a, b) => a.date.localeCompare(b.date));
// Overdue checkouts list
const overdueList = await db.bookCheckout.findMany({
where: { schoolId, status: 'overdue' },
include: {
book: { select: { id: true, title: true, author: true } },
student: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { dueDate: 'asc' },
take: 20,
});
const overdueWithDays = overdueList.map((checkout) => {
const daysOverdue = Math.floor(
(now.getTime() - new Date(checkout.dueDate).getTime()) / (1000 * 60 * 60 * 24)
);
return { ...checkout, daysOverdue };
});
return NextResponse.json({
totalBooks,
totalCopies: totalCopies._sum.totalCopies || 0,
availableCopies: availableCopies._sum.availableCopies || 0,
checkedOut: activeCheckouts,
overdue: overdueCheckouts,
totalCheckouts,
totalReservations,
waitingReservations,
overdueRate,
popularBooks: popularBooksWithCount,
categoryDistribution: categoryDistribution.map((c) => ({
category: c.category,
count: c._count.id,
})),
checkoutTrends: trendData,
overdueList: overdueWithDays,
});
} catch (error) {
console.error('LibraryStats GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -0,0 +1,217 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// GET: Returns detailed academic progress for a child
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (session.user?.role !== 'PARENT') {
return NextResponse.json({ error: 'Forbidden - Parent role required' }, { status: 403 });
}
const parentId = session.userId;
const schoolId = session.user?.schoolId;
const { searchParams } = new URL(request.url);
const studentId = searchParams.get('studentId');
if (!studentId || !schoolId) {
return NextResponse.json({ error: 'studentId and schoolId are required' }, { status: 400 });
}
// Verify parent is linked to this student
const parentLink = await db.parentStudentLink.findFirst({
where: { parentId, studentId },
});
if (!parentLink) {
return NextResponse.json({ error: 'You can only view progress for your children' }, { status: 403 });
}
// Get all computed grades for the student
const grades = await db.computedGrade.findMany({
where: { studentId },
include: {
subject: { select: { id: true, name: true, code: true } },
classGroup: { select: { id: true, name: true } },
schoolYear: { select: { id: true, name: true } },
},
orderBy: { computedAt: 'desc' },
});
// Group grades by subject
const subjectGrades: Record<string, {
subject: { id: string; name: string; code: string | null };
grades: Array<{
id: string;
computedValue: number;
period: string;
computedAt: string;
classGroup: { id: string; name: string };
schoolYear: { id: string; name: string };
}>;
average: number;
trend: 'up' | 'down' | 'stable';
}> = {};
for (const grade of grades) {
const sid = grade.subjectId;
if (!subjectGrades[sid]) {
subjectGrades[sid] = {
subject: grade.subject,
grades: [],
average: 0,
trend: 'stable',
};
}
subjectGrades[sid].grades.push({
id: grade.id,
computedValue: grade.computedValue,
period: grade.period,
computedAt: grade.computedAt.toISOString(),
classGroup: grade.classGroup,
schoolYear: grade.schoolYear,
});
}
// Calculate averages and trends
for (const sid of Object.keys(subjectGrades)) {
const sg = subjectGrades[sid];
const values = sg.grades.map((g) => g.computedValue);
sg.average = values.length > 0
? Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 10) / 10
: 0;
// Trend: compare last two periods
if (values.length >= 2) {
const latest = values[0];
const previous = values[1];
if (latest > previous + 0.3) sg.trend = 'up';
else if (latest < previous - 0.3) sg.trend = 'down';
else sg.trend = 'stable';
}
}
// Class averages for comparison
const studentEnrollments = await db.enrollment.findMany({
where: { studentId, endDate: null },
select: { classGroupId: true },
});
const classIds = studentEnrollments.map((e) => e.classGroupId);
const classAverages: Record<string, number> = {};
if (classIds.length > 0) {
const classGrades = await db.computedGrade.findMany({
where: {
classGroupId: { in: classIds },
student: { enrollments: { some: { classGroupId: { in: classIds }, endDate: null } } },
},
include: { subject: { select: { id: true } } },
});
const bySubject: Record<string, number[]> = {};
for (const cg of classGrades) {
const sid = cg.subjectId;
if (!bySubject[sid]) bySubject[sid] = [];
bySubject[sid].push(cg.computedValue);
}
for (const [sid, vals] of Object.entries(bySubject)) {
classAverages[sid] = Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 10) / 10;
}
}
// Competency progress data for radar chart
const learningProgress = await db.learningProgress.findMany({
where: { studentId },
include: {
competency: { select: { id: true, code: true, title: true, subject: { select: { id: true, name: true } } } },
},
orderBy: { assessedAt: 'desc' },
});
// Group by subject for radar chart
const competencyBySubject: Record<string, {
subject: { id: string; name: string };
competencies: Array<{
id: string;
code: string;
title: string;
level: number;
assessedAt: string;
}>;
averageLevel: number;
}> = {};
for (const lp of learningProgress) {
const sid = lp.competency.subject?.id ?? 'unknown';
if (!competencyBySubject[sid]) {
competencyBySubject[sid] = {
subject: lp.competency.subject ?? { id: sid, name: 'Unknown' },
competencies: [],
averageLevel: 0,
};
}
// Only keep the latest for each competency
const existing = competencyBySubject[sid].competencies.find((c) => c.id === lp.competencyId);
if (!existing) {
competencyBySubject[sid].competencies.push({
id: lp.competencyId,
code: lp.competency.code,
title: lp.competency.title,
level: lp.level,
assessedAt: lp.assessedAt.toISOString(),
});
}
}
for (const sid of Object.keys(competencyBySubject)) {
const cs = competencyBySubject[sid];
const levels = cs.competencies.map((c) => c.level);
cs.averageLevel = levels.length > 0
? Math.round((levels.reduce((a, b) => a + b, 0) / levels.length) * 10) / 10
: 0;
}
// Assessment results
const assessments = await db.assessmentResult.findMany({
where: {
studentId,
assessment: { schoolId },
},
include: {
assessment: {
select: {
id: true,
title: true,
type: true,
date: true,
subject: { select: { id: true, name: true } },
},
},
},
orderBy: { assessedAt: 'desc' },
take: 20,
});
return NextResponse.json({
subjectGrades: Object.values(subjectGrades),
classAverages,
competencyBySubject: Object.values(competencyBySubject),
assessments: assessments.map((a) => ({
id: a.id,
score: a.score,
maxScore: a.maxScore,
grade: a.grade,
feedback: a.feedback,
assessedAt: a.assessedAt.toISOString(),
assessment: a.assessment,
})),
});
} catch (error) {
console.error('Child Progress GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -0,0 +1,184 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { differenceInCalendarDays } from 'date-fns';
// GET: Returns the weekly schedule for a child
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (session.user?.role !== 'PARENT') {
return NextResponse.json({ error: 'Forbidden - Parent role required' }, { status: 403 });
}
const parentId = session.userId;
const schoolId = session.user?.schoolId;
const { searchParams } = new URL(request.url);
const studentId = searchParams.get('studentId');
if (!studentId || !schoolId) {
return NextResponse.json({ error: 'studentId and schoolId are required' }, { status: 400 });
}
// Verify parent is linked to this student
const parentLink = await db.parentStudentLink.findFirst({
where: { parentId, studentId },
});
if (!parentLink) {
return NextResponse.json({ error: 'You can only view schedule for your children' }, { status: 403 });
}
// Get student's class groups
const enrollments = await db.enrollment.findMany({
where: { studentId, endDate: null },
select: { classGroupId: true, classGroup: { select: { id: true, name: true } } },
});
const classIds = enrollments.map((e) => e.classGroupId);
// Timetable slots
const timetableSlots = classIds.length > 0
? await db.timetableSlot.findMany({
where: {
schoolId,
classGroupId: { in: classIds },
deletedAt: null,
},
include: {
subject: { select: { id: true, name: true, code: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true } },
},
orderBy: [{ dayOfWeek: 'asc' }, { period: 'asc' }],
})
: [];
// Upcoming exams
const upcomingExams = classIds.length > 0
? await db.calendarEvent.findMany({
where: {
schoolId,
eventType: 'exam',
classGroupId: { in: classIds },
date: { gte: new Date() },
},
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true } },
},
orderBy: { date: 'asc' },
take: 15,
})
: [];
// School events
const schoolEvents = await db.schoolEvent.findMany({
where: {
schoolId,
deletedAt: null,
startDate: { gte: new Date() },
},
orderBy: { startDate: 'asc' },
take: 10,
select: {
id: true,
title: true,
description: true,
startDate: true,
endDate: true,
eventType: true,
location: true,
organizer: { select: { id: true, firstName: true, lastName: true } },
},
});
// Counseling appointments
const student = await db.student.findUnique({
where: { id: studentId },
select: { userId: true },
});
const counselingAppointments = student?.userId
? await db.counselingAppointment.findMany({
where: { studentId: student.userId, schoolId },
orderBy: { scheduledAt: 'desc' },
take: 10,
select: {
id: true,
requestType: true,
status: true,
scheduledAt: true,
description: true,
counselor: { select: { firstName: true, lastName: true } },
},
})
: [];
// Homework due
const homeworkDue = classIds.length > 0
? await db.homework.findMany({
where: {
schoolId,
classGroupId: { in: classIds },
dueDate: { gte: new Date() },
isPublished: true,
deletedAt: null,
},
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true } },
},
orderBy: { dueDate: 'asc' },
take: 10,
})
: [];
return NextResponse.json({
timetableSlots: timetableSlots.map((s) => ({
id: s.id,
dayOfWeek: s.dayOfWeek,
period: s.period,
startTime: s.startTime,
endTime: s.endTime,
isBreak: s.isBreak,
subject: s.subject ? { id: s.subject.id, name: s.subject.name, code: s.subject.code } : null,
teacher: s.teacher ? { id: s.teacher.id, firstName: s.teacher.firstName, lastName: s.teacher.lastName } : null,
classGroup: { id: s.classGroup.id, name: s.classGroup.name },
})),
upcomingExams: upcomingExams.map((exam) => ({
id: exam.id,
title: exam.title,
date: exam.date.toISOString(),
startTime: exam.startTime,
endTime: exam.endTime,
subject: exam.subject ? { id: exam.subject.id, name: exam.subject.name } : null,
classGroup: exam.classGroup ? { id: exam.classGroup.id, name: exam.classGroup.name } : null,
daysUntil: differenceInCalendarDays(new Date(exam.date), new Date()),
})),
schoolEvents: schoolEvents.map((e) => ({
...e,
startDate: e.startDate.toISOString(),
endDate: e.endDate?.toISOString() ?? null,
})),
counselingAppointments: counselingAppointments.map((a) => ({
...a,
scheduledAt: a.scheduledAt?.toISOString() ?? null,
})),
homeworkDue: homeworkDue.map((h) => ({
id: h.id,
title: h.title,
description: h.description,
dueDate: h.dueDate.toISOString(),
homeworkType: h.homeworkType,
subject: h.subject ? { id: h.subject.id, name: h.subject.name } : null,
classGroup: { id: h.classGroup.id, name: h.classGroup.name },
})),
});
} catch (error) {
console.error('Child Schedule GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+117 -1
View File
@@ -59,6 +59,7 @@ export async function GET(request: Request) {
for (const child of children) {
const studentId = child.studentId;
const classIds = child.classGroups.map((c) => c.id);
// Illness reports
const illnessReports = await db.illnessReport.findMany({
@@ -74,12 +75,12 @@ export async function GET(request: Request) {
parentApprovalStatus: true,
status: true,
reporterType: true,
documentUrl: true,
createdAt: true,
},
});
// Upcoming exams (calendar events with type 'exam')
const classIds = child.classGroups.map((c) => c.id);
const upcomingExams = classIds.length > 0
? await db.calendarEvent.findMany({
where: {
@@ -157,6 +158,24 @@ export async function GET(request: Request) {
},
});
// Homework due
const homeworkDue = classIds.length > 0
? await db.homework.findMany({
where: {
schoolId,
classGroupId: { in: classIds },
dueDate: { gte: new Date() },
isPublished: true,
deletedAt: null,
},
include: {
subject: { select: { id: true, name: true } },
},
orderBy: { dueDate: 'asc' },
take: 10,
})
: [];
childrenData.push({
...child,
illnessReports,
@@ -168,6 +187,12 @@ export async function GET(request: Request) {
attendanceSummary,
counselingAppointments,
disciplinaryCases,
homeworkDue: homeworkDue.map((h) => ({
id: h.id,
title: h.title,
dueDate: h.dueDate.toISOString(),
subject: h.subject ? { id: h.subject.id, name: h.subject.name } : null,
})),
});
}
@@ -184,9 +209,100 @@ export async function GET(request: Request) {
orderBy: { createdAt: 'desc' },
});
// School announcements for parents
const announcements = await db.announcement.findMany({
where: {
schoolId,
deletedAt: null,
OR: [
{ targetAudience: 'all' },
{ targetAudience: 'parents' },
],
OR: [
{ expiresAt: null },
{ expiresAt: { gt: new Date() } },
],
},
orderBy: { createdAt: 'desc' },
take: 10,
include: {
author: { select: { id: true, firstName: true, lastName: true } },
},
});
// Communication rooms for this parent's children
const childUserIds = children
.filter((c) => c.userId)
.map((c) => c.userId as string);
const communicationRooms = childUserIds.length > 0
? await db.communicationRoom.findMany({
where: {
studentId: { in: childUserIds },
status: { in: ['requested', 'active'] },
},
orderBy: { updatedAt: 'desc' },
include: {
student: { select: { id: true, firstName: true, lastName: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
messages: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { id: true, content: true, createdAt: true, senderId: true },
},
},
})
: [];
// School events
const schoolEvents = await db.schoolEvent.findMany({
where: {
schoolId,
deletedAt: null,
startDate: { gte: new Date() },
},
orderBy: { startDate: 'asc' },
take: 5,
select: {
id: true,
title: true,
startDate: true,
eventType: true,
location: true,
},
});
return NextResponse.json({
children: childrenData,
pendingApprovals,
announcements: announcements.map((a) => ({
id: a.id,
title: a.title,
content: a.content,
priority: a.priority,
announcementType: a.announcementType,
isPinned: a.isPinned,
createdAt: a.createdAt.toISOString(),
author: a.author ? { firstName: a.author.firstName, lastName: a.author.lastName } : null,
})),
communicationRooms: communicationRooms.map((r) => ({
id: r.id,
status: r.status,
roomType: r.roomType,
student: r.student,
teacher: r.teacher,
lastMessage: r.messages[0] ? {
id: r.messages[0].id,
content: r.messages[0].content,
createdAt: r.messages[0].createdAt.toISOString(),
senderId: r.messages[0].senderId,
} : null,
updatedAt: r.updatedAt.toISOString(),
})),
schoolEvents: schoolEvents.map((e) => ({
...e,
startDate: e.startDate.toISOString(),
})),
});
} catch (error) {
console.error('Parent Portal GET error:', error);
+219
View File
@@ -0,0 +1,219 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const report = await db.report.findUnique({
where: { id },
include: {
student: { select: { id: true, firstName: true, lastName: true, avatarUrl: true, dateOfBirth: true } },
classGroup: {
select: { id: true, name: true, gradeLevel: true, schoolType: true, responsibleTeacher: { select: { id: true, firstName: true, lastName: true } } },
},
schoolYear: { select: { id: true, label: true, startDate: true, endDate: true } },
generatedByUser: { select: { id: true, firstName: true, lastName: true } },
sections: {
orderBy: { order: 'asc' },
include: {
competencyCategory: { select: { id: true, name: true, color: true } },
},
},
template: { select: { id: true, name: true, layout: true, gradingScale: true, sections: true } },
},
});
if (!report) {
return NextResponse.json({ error: 'Report not found' }, { status: 404 });
}
// Role-based access check
if (session.user?.role === 'STUDENT') {
const student = await db.student.findFirst({ where: { userId: session.userId } });
if (!student || student.id !== report.studentId || report.status !== 'PUBLISHED') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
} else if (session.user?.role === 'PARENT') {
const link = await db.parentStudentLink.findFirst({
where: { parentId: session.userId, studentId: report.studentId },
});
if (!link || report.status !== 'PUBLISHED') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
// Fetch computed grades for this student/class/schoolYear
const computedGrades = await db.computedGrade.findMany({
where: {
studentId: report.studentId,
classGroupId: report.classGroupId,
schoolYearId: report.schoolYearId,
},
include: {
subject: { select: { id: true, name: true } },
},
orderBy: { subject: { name: 'asc' } },
});
return NextResponse.json({ ...report, computedGrades });
} catch (error) {
console.error('ReportCard GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
const updateReportCardSchema = z.object({
status: z.enum(['DRAFT', 'REVIEW', 'PUBLISHED', 'ARCHIVED', 'FINAL']).optional(),
includesGrades: z.boolean().optional(),
pdfFilePath: z.string().optional(),
teacherComments: z.string().nullable().optional(),
attendanceSummary: z.string().nullable().optional(),
overallAssessment: z.string().nullable().optional(),
templateId: z.string().nullable().optional(),
period: z.string().optional(),
sections: z
.array(
z.object({
id: z.string().optional(),
competencyCategoryId: z.string().nullable().optional(),
generatedText: z.string().min(1),
order: z.number().int().default(0),
})
)
.optional(),
});
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'VICE_PRINCIPAL' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const parsed = updateReportCardSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { sections, ...updateData } = parsed.data;
// Check if teacher owns this report
if (session.user?.role === 'TEACHER') {
const existing = await db.report.findUnique({ where: { id } });
if (!existing || existing.generatedByUserId !== session.userId) {
return NextResponse.json({ error: 'You can only edit your own reports' }, { status: 403 });
}
// Teachers can only submit for review, not publish
if (updateData.status === 'PUBLISHED') {
return NextResponse.json({ error: 'Teachers cannot publish reports directly' }, { status: 403 });
}
}
// Handle status transitions
const statusUpdate: Record<string, unknown> = {};
if (updateData.status === 'REVIEW') {
statusUpdate.reviewedByUserId = session.userId;
statusUpdate.reviewedAt = new Date();
}
if (updateData.status === 'PUBLISHED') {
statusUpdate.publishedAt = new Date();
}
// Update sections if provided
if (sections && sections.length > 0) {
// Delete existing sections and recreate
await db.reportSection.deleteMany({ where: { reportId: id } });
await db.reportSection.createMany({
data: sections.map((s) => ({
reportId: id,
competencyCategoryId: s.competencyCategoryId ?? null,
generatedText: s.generatedText,
order: s.order,
})),
});
}
const report = await db.report.update({
where: { id },
data: {
...updateData,
...statusUpdate,
},
include: {
student: { select: { id: true, firstName: true, lastName: true, avatarUrl: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
schoolYear: { select: { id: true, label: true } },
generatedByUser: { select: { id: true, firstName: true, lastName: true } },
sections: {
orderBy: { order: 'asc' },
include: {
competencyCategory: { select: { id: true, name: true, color: true } },
},
},
template: { select: { id: true, name: true, layout: true } },
},
});
return NextResponse.json(report);
} catch (error) {
console.error('ReportCard PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'VICE_PRINCIPAL'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
await db.reportSection.deleteMany({ where: { reportId: id } });
await db.report.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
console.error('ReportCard DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+101
View File
@@ -0,0 +1,101 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const exportSchema = z.object({
reportIds: z.array(z.string().min(1)).min(1),
});
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'VICE_PRINCIPAL' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = exportSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { reportIds } = parsed.data;
const reports = await db.report.findMany({
where: { id: { in: reportIds } },
include: {
student: { select: { id: true, firstName: true, lastName: true, avatarUrl: true, dateOfBirth: true } },
classGroup: {
select: {
id: true, name: true, gradeLevel: true, schoolType: true,
responsibleTeacher: { select: { id: true, firstName: true, lastName: true } },
},
},
schoolYear: { select: { id: true, label: true } },
generatedByUser: { select: { id: true, firstName: true, lastName: true } },
sections: {
orderBy: { order: 'asc' },
include: {
competencyCategory: { select: { id: true, name: true, color: true } },
},
},
template: { select: { id: true, name: true, layout: true, gradingScale: true } },
},
});
// Get school info for branding
const schoolId = session.user?.schoolId;
let school = null;
if (schoolId) {
school = await db.school.findUnique({
where: { id: schoolId },
select: {
id: true, name: true, motto: true, logoUrl: true,
primaryColor: true, secondaryColor: true, address: true, phone: true,
},
});
}
// Get computed grades for each report
const reportsWithGrades = await Promise.all(
reports.map(async (report) => {
const computedGrades = await db.computedGrade.findMany({
where: {
studentId: report.studentId,
classGroupId: report.classGroupId,
schoolYearId: report.schoolYearId,
},
include: {
subject: { select: { id: true, name: true } },
},
orderBy: { subject: { name: 'asc' } },
});
return { ...report, computedGrades };
})
);
// Return the data for client-side PDF generation
// The client will use window.print() or a print-specific CSS
return NextResponse.json({
reports: reportsWithGrades,
school,
exportDate: new Date().toISOString(),
});
} catch (error) {
console.error('ReportCards export error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+184
View File
@@ -0,0 +1,184 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const batchGenerateSchema = z.object({
classGroupId: z.string().min(1),
schoolYearId: z.string().min(1),
period: z.string().min(1),
templateId: z.string().optional(),
includesGrades: z.boolean().default(true),
});
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'VICE_PRINCIPAL'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = batchGenerateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { classGroupId, schoolYearId, period, templateId, includesGrades } = parsed.data;
// Get all enrolled students in the class
const enrollments = await db.enrollment.findMany({
where: {
classGroupId,
schoolYearId,
endDate: null,
},
include: {
student: { select: { id: true, firstName: true, lastName: true } },
},
});
if (enrollments.length === 0) {
return NextResponse.json({ error: 'No students found in this class' }, { status: 400 });
}
// Get competency categories for the class
const competencyAssignments = await db.classCompetencyAssignment.findMany({
where: { classGroupId, schoolYearId },
include: {
competencyTemplate: {
include: {
categories: {
include: { competencies: true },
orderBy: { order: 'asc' },
},
},
},
subject: { select: { id: true, name: true } },
},
});
// Get computed grades for all students
const computedGrades = await db.computedGrade.findMany({
where: { classGroupId, schoolYearId },
include: { subject: { select: { id: true, name: true } } },
});
const createdReports = [];
for (const enrollment of enrollments) {
// Check if report already exists for this student/period
const existing = await db.report.findFirst({
where: {
studentId: enrollment.studentId,
classGroupId,
schoolYearId,
period,
},
});
if (existing) continue; // Skip if report already exists
// Build sections from competency categories
const sections: { competencyCategoryId: string | null; generatedText: string; order: number }[] = [];
for (const assignment of competencyAssignments) {
const studentGrades = computedGrades.filter(
(g) => g.studentId === enrollment.studentId && g.subjectId === assignment.subjectId
);
for (const category of assignment.competencyTemplate.categories) {
// Get learning progress for this student in this category
const progressEntries = await db.learningProgressEntry.findMany({
where: {
studentId: enrollment.studentId,
classGroupId,
competency: { categoryId: category.id },
},
orderBy: { date: 'desc' },
take: 5,
});
const avgMastery =
progressEntries.length > 0
? progressEntries.reduce((sum, e) => sum + e.masteryLevelValue, 0) / progressEntries.length
: 0;
const gradeText = studentGrades.length > 0
? ` | Note: ${studentGrades[0].overriddenValue ?? studentGrades[0].computedValue}`
: '';
const masteryLabel = avgMastery >= 3.5 ? 'weit entwickelt' : avgMastery >= 2.5 ? 'kompetent' : avgMastery >= 1.5 ? 'entwickelt sich' : 'noch nicht erreicht';
sections.push({
competencyCategoryId: category.id,
generatedText: `${category.name}: ${masteryLabel}${gradeText}`,
order: sections.length,
});
}
}
// Get attendance summary
const attendanceRecords = await db.attendanceRecord.findMany({
where: {
studentId: enrollment.studentId,
session: {
classGroupId,
date: { gte: new Date(new Date().getFullYear(), 0, 1) },
},
},
});
const attendanceSummary = JSON.stringify({
present: attendanceRecords.filter((r) => r.status === 'PRESENT').length,
absent: attendanceRecords.filter((r) => r.status === 'ABSENT').length,
excused: attendanceRecords.filter((r) => r.status === 'EXCUSED').length,
late: attendanceRecords.filter((r) => r.status === 'LATE').length,
total: attendanceRecords.length,
});
const report = await db.report.create({
data: {
studentId: enrollment.studentId,
classGroupId,
schoolYearId,
period,
generatedByUserId: session.userId,
includesGrades,
status: 'DRAFT',
templateId: templateId ?? null,
attendanceSummary,
sections: sections.length > 0
? { create: sections }
: undefined,
},
include: {
student: { select: { id: true, firstName: true, lastName: true } },
classGroup: { select: { id: true, name: true } },
},
});
createdReports.push(report);
}
return NextResponse.json({
generated: createdReports.length,
skipped: enrollments.length - createdReports.length,
reports: createdReports,
});
} catch (error) {
console.error('ReportCards batch generate error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+199
View File
@@ -0,0 +1,199 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const createReportCardSchema = z.object({
studentId: z.string().min(1),
classGroupId: z.string().min(1),
schoolYearId: z.string().min(1),
period: z.string().min(1),
includesGrades: z.boolean().default(false),
teacherComments: z.string().optional(),
attendanceSummary: z.string().optional(),
overallAssessment: z.string().optional(),
templateId: z.string().optional(),
sections: z
.array(
z.object({
competencyCategoryId: z.string().optional(),
generatedText: z.string().min(1),
order: z.number().int().default(0),
})
)
.optional(),
});
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const studentId = searchParams.get('studentId');
const classGroupId = searchParams.get('classGroupId');
const schoolYearId = searchParams.get('schoolYearId');
const status = searchParams.get('status');
const period = searchParams.get('period');
const templateId = searchParams.get('templateId');
const where: Record<string, unknown> = {};
if (studentId) where.studentId = studentId;
if (classGroupId) where.classGroupId = classGroupId;
if (schoolYearId) where.schoolYearId = schoolYearId;
if (status) where.status = status;
if (period) where.period = period;
if (templateId) where.templateId = templateId;
// Role-based filtering
if (session.user?.role === 'STUDENT') {
// Students can only see their own published reports
const student = await db.student.findFirst({
where: { userId: session.userId },
});
if (student) {
where.studentId = student.id;
where.status = 'PUBLISHED';
} else {
return NextResponse.json([]);
}
} else if (session.user?.role === 'PARENT') {
// Parents can only see their children's published reports
const parentLinks = await db.parentStudentLink.findMany({
where: { parentId: session.userId },
});
const childIds = parentLinks.map((l) => l.studentId);
where.studentId = { in: childIds };
where.status = 'PUBLISHED';
} else if (session.user?.role === 'TEACHER') {
// Teachers see reports for their classes
if (!classGroupId) {
const teacherClasses = await db.classGroupTeacher.findMany({
where: { userId: session.userId },
});
const classIds = teacherClasses.map((c) => c.classGroupId);
where.classGroupId = { in: classIds };
}
}
const reports = await db.report.findMany({
where,
orderBy: { generatedAt: 'desc' },
include: {
student: { select: { id: true, firstName: true, lastName: true, avatarUrl: true } },
classGroup: { select: { id: true, name: true, gradeLevel: true } },
schoolYear: { select: { id: true, label: true } },
generatedByUser: { select: { id: true, firstName: true, lastName: true } },
sections: {
orderBy: { order: 'asc' },
include: {
competencyCategory: { select: { id: true, name: true, color: true } },
},
},
template: { select: { id: true, name: true, layout: true } },
},
});
return NextResponse.json(reports);
} catch (error) {
console.error('ReportCards GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'VICE_PRINCIPAL' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = createReportCardSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { sections, ...reportData } = parsed.data;
// If attendance summary not provided, try to compute it
let attendanceSummary = reportData.attendanceSummary;
if (!attendanceSummary) {
const attendanceRecords = await db.attendanceRecord.findMany({
where: {
studentId: reportData.studentId,
session: {
classGroupId: reportData.classGroupId,
date: { gte: new Date(new Date().getFullYear(), 0, 1) },
},
},
include: { session: true },
});
const summary = {
present: attendanceRecords.filter((r) => r.status === 'PRESENT').length,
absent: attendanceRecords.filter((r) => r.status === 'ABSENT').length,
excused: attendanceRecords.filter((r) => r.status === 'EXCUSED').length,
late: attendanceRecords.filter((r) => r.status === 'LATE').length,
total: attendanceRecords.length,
};
attendanceSummary = JSON.stringify(summary);
}
const report = await db.report.create({
data: {
studentId: reportData.studentId,
classGroupId: reportData.classGroupId,
schoolYearId: reportData.schoolYearId,
period: reportData.period,
generatedByUserId: session.userId,
includesGrades: reportData.includesGrades,
status: 'DRAFT',
teacherComments: reportData.teacherComments,
attendanceSummary,
overallAssessment: reportData.overallAssessment,
templateId: reportData.templateId,
sections: sections
? {
create: sections.map((s) => ({
competencyCategoryId: s.competencyCategoryId ?? null,
generatedText: s.generatedText,
order: s.order,
})),
}
: undefined,
},
include: {
student: { select: { id: true, firstName: true, lastName: true, avatarUrl: true } },
classGroup: { select: { id: true, name: true } },
schoolYear: { select: { id: true, label: true } },
generatedByUser: { select: { id: true, firstName: true, lastName: true } },
sections: {
orderBy: { order: 'asc' },
include: {
competencyCategory: { select: { id: true, name: true } },
},
},
template: { select: { id: true, name: true, layout: true } },
},
});
return NextResponse.json(report, { status: 201 });
} catch (error) {
console.error('ReportCards POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -0,0 +1,95 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const createTemplateSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
sections: z.string().default('[]'),
gradingScale: z.string().default('{}'),
layout: z.enum(['default', 'detailed', 'compact']).default('default'),
isDefault: z.boolean().default(false),
});
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId;
if (!schoolId) {
return NextResponse.json({ error: 'School ID required' }, { status: 400 });
}
const templates = await db.reportCardTemplate.findMany({
where: { schoolId },
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
});
return NextResponse.json(templates);
} catch (error) {
console.error('ReportCardTemplates GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'VICE_PRINCIPAL'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const schoolId = session.user?.schoolId;
if (!schoolId) {
return NextResponse.json({ error: 'School ID required' }, { status: 400 });
}
const body = await request.json();
const parsed = createTemplateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
// If this is set as default, unset other defaults
if (parsed.data.isDefault) {
await db.reportCardTemplate.updateMany({
where: { schoolId, isDefault: true },
data: { isDefault: false },
});
}
const template = await db.reportCardTemplate.create({
data: {
schoolId,
name: parsed.data.name,
description: parsed.data.description,
sections: parsed.data.sections,
gradingScale: parsed.data.gradingScale,
layout: parsed.data.layout,
isDefault: parsed.data.isDefault,
},
});
return NextResponse.json(template, { status: 201 });
} catch (error) {
console.error('ReportCardTemplates POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -0,0 +1,226 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// ── PUT: Update seating arrangement (drag-and-drop, randomize, smart arrange) ──
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const userRole = session.user?.role;
if (
userRole !== 'TEACHER' &&
userRole !== 'SCHOOL_ADMIN' &&
userRole !== 'VICE_PRINCIPAL' &&
userRole !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const chart = await db.seatingChart.findFirst({ where: { id } });
if (!chart) {
return NextResponse.json({ error: 'Seating chart not found' }, { status: 404 });
}
// Only the creator or admin can edit
if (userRole === 'TEACHER' && chart.teacherId !== session.userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const { action, arrangement, studentId, fromRow, fromCol, toRow, toCol } = body;
let currentArrangement: Array<{ studentId: string; row: number; col: number }> = [];
if (chart.arrangement) {
try {
currentArrangement = JSON.parse(chart.arrangement);
} catch { /* ignore */ }
}
if (action === 'move' && studentId) {
// Move a single student from one seat to another
const existingTarget = currentArrangement.find(
(a) => a.row === toRow && a.col === toCol
);
const existingSource = currentArrangement.find(
(a) => a.studentId === studentId
);
if (existingSource) {
if (existingTarget) {
// Swap: move target student to source position
existingTarget.row = existingSource.row;
existingTarget.col = existingSource.col;
}
existingSource.row = toRow;
existingSource.col = toCol;
} else {
// New placement
if (existingTarget) {
// Remove target from their seat (they become unassigned)
currentArrangement = currentArrangement.filter(
(a) => a.studentId !== existingTarget.studentId
);
}
currentArrangement.push({ studentId, row: toRow, col: toCol });
}
} else if (action === 'randomize') {
// Shuffle all assigned students randomly
const assignedStudents = currentArrangement.map((a) => a.studentId);
// Fisher-Yates shuffle
for (let i = assignedStudents.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[assignedStudents[i], assignedStudents[j]] = [assignedStudents[j], assignedStudents[i]];
}
// Reassign to seats
const positions = currentArrangement.map((a) => ({ row: a.row, col: a.col }));
currentArrangement = assignedStudents.map((studentId, i) => ({
studentId,
row: positions[i]?.row ?? Math.floor(i / chart.columns),
col: positions[i]?.col ?? i % chart.columns,
}));
} else if (action === 'smart-arrange') {
// Smart arrange: keep problem students apart based on behavior data
const enrolledStudents = await db.enrollment.findMany({
where: { classGroupId: chart.classGroupId, endDate: null },
include: {
student: { select: { id: true, firstName: true, lastName: true } },
},
});
const studentIds = enrolledStudents.map((e) => e.student.id);
// Get behavior data
const behaviorIncidents = await db.behaviorIncident.findMany({
where: {
studentId: { in: studentIds },
severity: { in: ['major', 'severe'] },
},
select: { studentId: true, severity: true },
});
const behaviorScore = new Map<string, number>();
for (const incident of behaviorIncidents) {
const weight = incident.severity === 'severe' ? 3 : 1;
behaviorScore.set(incident.studentId, (behaviorScore.get(incident.studentId) || 0) + weight);
}
// Sort: high behavior score students first, to place them first
const sortedStudents = [...studentIds].sort((a, b) => {
return (behaviorScore.get(b) || 0) - (behaviorScore.get(a) || 0);
});
// Generate all seat positions
const allPositions: Array<{ row: number; col: number }> = [];
for (let r = 0; r < chart.rows; r++) {
for (let c = 0; c < chart.columns; c++) {
allPositions.push({ row: r, col: c });
}
}
// Place high-behavior students with maximum distance
const newArrangement: Array<{ studentId: string; row: number; col: number }> = [];
const usedPositions = new Set<string>();
for (const sId of sortedStudents) {
let bestPos = allPositions[0];
let bestMinDist = -1;
for (const pos of allPositions) {
const key = `${pos.row}-${pos.col}`;
if (usedPositions.has(key)) continue;
// Calculate minimum distance to already-placed high-behavior students
let minDist = Infinity;
for (const placed of newArrangement) {
const placedScore = behaviorScore.get(placed.studentId) || 0;
if (placedScore >= 2) {
const dist = Math.abs(placed.row - pos.row) + Math.abs(placed.col - pos.col);
minDist = Math.min(minDist, dist);
}
}
if (minDist > bestMinDist) {
bestMinDist = minDist;
bestPos = pos;
}
}
const posKey = `${bestPos.row}-${bestPos.col}`;
usedPositions.add(posKey);
newArrangement.push({ studentId: sId, row: bestPos.row, col: bestPos.col });
}
currentArrangement = newArrangement;
} else if (action === 'assign' && arrangement) {
// Full arrangement replacement
currentArrangement = arrangement;
} else if (action === 'clear') {
// Clear all assignments
currentArrangement = [];
} else if (action === 'assign-all') {
// Assign all unassigned students to empty seats
const enrolledStudents = await db.enrollment.findMany({
where: { classGroupId: chart.classGroupId, endDate: null },
include: {
student: { select: { id: true } },
},
});
const assignedIds = new Set(currentArrangement.map((a) => a.studentId));
const unassigned = enrolledStudents
.map((e) => e.student.id)
.filter((sId) => !assignedIds.has(sId));
// Find empty positions
const usedPositions = new Set(
currentArrangement.map((a) => `${a.row}-${a.col}`)
);
const emptyPositions: Array<{ row: number; col: number }> = [];
for (let r = 0; r < chart.rows; r++) {
for (let c = 0; c < chart.columns; c++) {
if (!usedPositions.has(`${r}-${c}`)) {
emptyPositions.push({ row: r, col: c });
}
}
}
// Assign unassigned students to empty positions
for (let i = 0; i < Math.min(unassigned.length, emptyPositions.length); i++) {
currentArrangement.push({
studentId: unassigned[i],
row: emptyPositions[i].row,
col: emptyPositions[i].col,
});
}
}
// Save the updated arrangement
await db.seatingChart.update({
where: { id },
data: { arrangement: JSON.stringify(currentArrangement) },
});
// Also update ClassGroup's seatingOrder for backward compatibility
await db.classGroup.update({
where: { id: chart.classGroupId },
data: { seatingOrder: JSON.stringify(currentArrangement) },
});
return NextResponse.json({
success: true,
arrangement: currentArrangement,
});
} catch (error) {
console.error('SeatingChart Arrange PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+233
View File
@@ -0,0 +1,233 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// ── GET: Get a single seating chart ──────────────────────────────────────────
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const chart = await db.seatingChart.findFirst({
where: { id },
include: {
classGroup: {
select: {
id: true,
name: true,
gradeLevel: true,
schoolId: true,
},
},
teacher: { select: { id: true, firstName: true, lastName: true } },
},
});
if (!chart) {
return NextResponse.json({ error: 'Seating chart not found' }, { status: 404 });
}
// Access check
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== chart.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Get enrolled students for the class
const enrollments = await db.enrollment.findMany({
where: { classGroupId: chart.classGroupId, endDate: null },
include: {
student: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
avatarInitials: true,
},
},
},
});
const students = enrollments.map((e) => e.student);
// Get behavior data for smart arrange
const behaviorIncidents = await db.behaviorIncident.findMany({
where: {
studentId: { in: students.map((s) => s.id) },
severity: { in: ['major', 'severe'] },
},
select: { studentId: true, severity: true },
});
const behaviorMap = new Map<string, number>();
for (const incident of behaviorIncidents) {
const weight = incident.severity === 'severe' ? 3 : 1;
behaviorMap.set(incident.studentId, (behaviorMap.get(incident.studentId) || 0) + weight);
}
return NextResponse.json({
...chart,
arrangement: chart.arrangement ? JSON.parse(chart.arrangement) : [],
students: students.map((s) => ({
...s,
behaviorScore: behaviorMap.get(s.id) || 0,
})),
});
} catch (error) {
console.error('SeatingChart GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ── PUT: Update a seating chart ──────────────────────────────────────────
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const userRole = session.user?.role;
if (
userRole !== 'TEACHER' &&
userRole !== 'SCHOOL_ADMIN' &&
userRole !== 'VICE_PRINCIPAL' &&
userRole !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const chart = await db.seatingChart.findFirst({ where: { id } });
if (!chart) {
return NextResponse.json({ error: 'Seating chart not found' }, { status: 404 });
}
// Only the creator or admin can edit
if (
userRole === 'TEACHER' &&
chart.teacherId !== session.userId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const {
name,
layoutType,
rows,
columns,
gap,
arrangement,
showDoor,
showWindows,
doorPosition,
windowPosition,
isTemplate,
isDefault,
notes,
} = body;
// If isDefault is being set to true, unset any existing default for this class
if (isDefault) {
await db.seatingChart.updateMany({
where: { classGroupId: chart.classGroupId, isDefault: true },
data: { isDefault: false },
});
}
const updated = await db.seatingChart.update({
where: { id },
data: {
...(name !== undefined && { name }),
...(layoutType !== undefined && { layoutType }),
...(rows !== undefined && { rows }),
...(columns !== undefined && { columns }),
...(gap !== undefined && { gap }),
...(arrangement !== undefined && { arrangement: JSON.stringify(arrangement) }),
...(showDoor !== undefined && { showDoor }),
...(showWindows !== undefined && { showWindows }),
...(doorPosition !== undefined && { doorPosition }),
...(windowPosition !== undefined && { windowPosition }),
...(isTemplate !== undefined && { isTemplate }),
...(isDefault !== undefined && { isDefault }),
...(notes !== undefined && { notes }),
},
include: {
classGroup: { select: { id: true, name: true, gradeLevel: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
},
});
// Also update the ClassGroup's seatingOrder for backward compatibility
if (arrangement && Array.isArray(arrangement)) {
await db.classGroup.update({
where: { id: chart.classGroupId },
data: { seatingOrder: JSON.stringify(arrangement) },
});
}
return NextResponse.json({
...updated,
arrangement: updated.arrangement ? JSON.parse(updated.arrangement) : [],
});
} catch (error) {
console.error('SeatingChart PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ── DELETE: Delete a seating chart ──────────────────────────────────────────
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const userRole = session.user?.role;
if (
userRole !== 'TEACHER' &&
userRole !== 'SCHOOL_ADMIN' &&
userRole !== 'VICE_PRINCIPAL' &&
userRole !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const chart = await db.seatingChart.findFirst({ where: { id } });
if (!chart) {
return NextResponse.json({ error: 'Seating chart not found' }, { status: 404 });
}
// Only the creator or admin can delete
if (
userRole === 'TEACHER' &&
chart.teacherId !== session.userId
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
await db.seatingChart.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
console.error('SeatingChart DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+187
View File
@@ -0,0 +1,187 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// ── GET: List seating charts with filters ──────────────────────────────────
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
const classGroupId = searchParams.get('classGroupId');
const teacherId = searchParams.get('teacherId');
const isTemplate = searchParams.get('isTemplate');
if (!schoolId) {
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
}
const where: Record<string, unknown> = { schoolId };
if (classGroupId) where.classGroupId = classGroupId;
if (teacherId) where.teacherId = teacherId;
if (isTemplate !== null && isTemplate !== undefined) {
where.isTemplate = isTemplate === 'true';
}
// Role-based access
if (session.user?.role === 'STUDENT') {
const student = await db.student.findFirst({
where: { userId: session.userId },
select: { id: true },
});
if (student) {
const enrollments = await db.enrollment.findMany({
where: { studentId: student.id },
select: { classGroupId: true },
});
const classIds = enrollments.map((e) => e.classGroupId);
where.classGroupId = { in: classIds };
}
} else if (session.user?.role === 'PARENT') {
const parentLinks = await db.parentStudentLink.findMany({
where: { parentId: session.userId },
select: { studentId: true },
});
const studentIds = parentLinks.map((l) => l.studentId);
const enrollments = await db.enrollment.findMany({
where: { studentId: { in: studentIds } },
select: { classGroupId: true },
});
const classIds = enrollments.map((e) => e.classGroupId);
where.classGroupId = { in: classIds };
} else if (session.user?.role === 'TEACHER') {
// Teachers see their own charts by default, or all if classGroupId is specified
if (!teacherId && !classGroupId) {
where.teacherId = session.userId;
}
}
const charts = await db.seatingChart.findMany({
where,
include: {
classGroup: { select: { id: true, name: true, gradeLevel: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { updatedAt: 'desc' },
});
// Parse arrangement JSON for each chart
const result = charts.map((chart) => ({
...chart,
arrangement: chart.arrangement ? JSON.parse(chart.arrangement) : [],
}));
return NextResponse.json({ charts: result });
} catch (error) {
console.error('SeatingCharts GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ── POST: Create a seating chart ──────────────────────────────────────────
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const userRole = session.user?.role;
if (
userRole !== 'TEACHER' &&
userRole !== 'SCHOOL_ADMIN' &&
userRole !== 'VICE_PRINCIPAL' &&
userRole !== 'SUPER_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const {
name,
classGroupId,
schoolId,
layoutType,
rows,
columns,
gap,
arrangement,
showDoor,
showWindows,
doorPosition,
windowPosition,
isTemplate,
isDefault,
notes,
} = body;
if (!name || !schoolId || !classGroupId) {
return NextResponse.json(
{ error: 'name, schoolId, and classGroupId are required' },
{ status: 400 }
);
}
// Verify class belongs to school
const classGroup = await db.classGroup.findFirst({
where: { id: classGroupId, schoolId, deletedAt: null },
});
if (!classGroup) {
return NextResponse.json({ error: 'Class not found' }, { status: 404 });
}
// If isDefault is true, unset any existing default for this class
if (isDefault) {
await db.seatingChart.updateMany({
where: { classGroupId, isDefault: true },
data: { isDefault: false },
});
}
const chart = await db.seatingChart.create({
data: {
schoolId,
classGroupId,
teacherId: session.userId,
name,
layoutType: layoutType || 'rows',
rows: rows ?? 5,
columns: columns ?? 5,
gap: gap ?? 2,
arrangement: arrangement ? JSON.stringify(arrangement) : null,
showDoor: showDoor ?? true,
showWindows: showWindows ?? true,
doorPosition: doorPosition || 'left',
windowPosition: windowPosition || 'right',
isTemplate: isTemplate ?? false,
isDefault: isDefault ?? false,
notes: notes || null,
},
include: {
classGroup: { select: { id: true, name: true, gradeLevel: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
},
});
// Also update the ClassGroup's seatingOrder for backward compatibility
if (arrangement && Array.isArray(arrangement)) {
await db.classGroup.update({
where: { id: classGroupId },
data: { seatingOrder: JSON.stringify(arrangement) },
});
}
return NextResponse.json({
...chart,
arrangement: chart.arrangement ? JSON.parse(chart.arrangement) : [],
}, { status: 201 });
} catch (error) {
console.error('SeatingCharts POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}