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
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user