Round 17: QR Codes, Gamification Badges, Auto-Award, Badge Management

This commit is contained in:
Mustafa Cetin committed 2026-07-29 12:19:50 +00:00
1 parent 6174f127f9
commit cba8a8e724
23 files changed
+4028 -1

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { seedDefaultBadges } from '@/lib/badge-check';
function isTeacherOrAdmin(role: string | undefined): boolean {
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await request.json();
const { schoolId, studentId } = body;
if (!schoolId || !studentId) {
return NextResponse.json({ error: 'Missing schoolId or studentId' }, { status: 400 });
}
// Seed default badges if needed
await seedDefaultBadges(schoolId);
// Check and award
const { checkAndAwardBadges } = await import('@/lib/badge-check');
const awarded = await checkAndAwardBadges(schoolId, studentId);
return NextResponse.json({ awarded });
} catch (error) {
console.error('Badge-check POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { getBadgeProgress } from '@/lib/badge-check';
function isTeacherOrAdmin(role: string | undefined): boolean {
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
}
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId');
const studentId = searchParams.get('studentId');
if (!schoolId || !studentId) {
return NextResponse.json({ error: 'Missing schoolId or studentId' }, { status: 400 });
}
// Seed default badges if needed
const { seedDefaultBadges } = await import('@/lib/badge-check');
await seedDefaultBadges(schoolId);
const progress = await getBadgeProgress(schoolId, studentId);
return NextResponse.json(progress);
} catch (error) {
console.error('Badge-progress GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { getSession } from '@/lib/auth';
import { seedDefaultBadges } from '@/lib/badge-check';
function isTeacherOrAdmin(role: string | undefined): boolean {
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await request.json();
const { schoolId } = body;
if (!schoolId) {
return NextResponse.json({ error: 'Missing schoolId' }, { status: 400 });
}
await seedDefaultBadges(schoolId);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Badge-seed POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+92
View File
@@ -0,0 +1,92 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
function isTeacherOrAdmin(role: string | undefined): boolean {
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
}
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 });
if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { id } = await params;
const badge = await db.badge.findUnique({
where: { id, deletedAt: null },
include: {
_count: { select: { studentBadges: true } },
},
});
if (!badge) return NextResponse.json({ error: 'Badge not found' }, { status: 404 });
return NextResponse.json(badge);
} catch (error) {
console.error('Badge GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
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 (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { id } = await params;
const body = await request.json();
const existing = await db.badge.findUnique({ where: { id, deletedAt: null } });
if (!existing) return NextResponse.json({ error: 'Badge not found' }, { status: 404 });
const updated = await db.badge.update({
where: { id },
data: {
name: body.name ?? existing.name,
description: body.description ?? existing.description,
icon: body.icon ?? existing.icon,
color: body.color ?? existing.color,
category: body.category ?? existing.category,
requirementType: body.requirementType ?? existing.requirementType,
requirementValue: body.requirementValue ?? existing.requirementValue,
isAuto: body.isAuto ?? existing.isAuto,
},
});
return NextResponse.json(updated);
} catch (error) {
console.error('Badge 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 (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { id } = await params;
const existing = await db.badge.findUnique({ where: { id, deletedAt: null } });
if (!existing) return NextResponse.json({ error: 'Badge not found' }, { status: 404 });
// Soft delete
await db.badge.update({ where: { id }, data: { deletedAt: new Date() } });
return NextResponse.json({ success: true });
} catch (error) {
console.error('Badge DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+101
View File
@@ -0,0 +1,101 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const categoryEnum = z.enum(['competency', 'attendance', 'behavior', 'achievement', 'milestone']);
const reqTypeEnum = z.enum(['mastery_level', 'attendance_rate', 'behavior_count', 'progress_entries', 'custom']);
const createBadgeSchema = z.object({
schoolId: z.string().min(1),
name: z.string().min(1).max(100),
description: z.string().max(500).optional().nullable(),
icon: z.string().min(1).max(50),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default('#10b981'),
category: categoryEnum,
requirementType: reqTypeEnum,
requirementValue: z.number().int().min(0).optional().nullable(),
isAuto: z.boolean().default(true),
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
}
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { searchParams } = new URL(request.url);
const schoolIdParam = searchParams.get('schoolId');
const categoryParam = searchParams.get('category');
let schoolId: string | undefined;
if (session.user?.role === 'SCHOOL_ADMIN') {
schoolId = session.user.schoolId ?? undefined;
} else {
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
}
if (!schoolId) return NextResponse.json([]);
const where: Record<string, unknown> = { schoolId, deletedAt: null };
if (categoryParam) where.category = categoryParam;
const badges = await db.badge.findMany({
where,
orderBy: [{ category: 'asc' }, { name: 'asc' }],
include: {
_count: { select: { studentBadges: true } },
},
});
return NextResponse.json(badges);
} catch (error) {
console.error('Badges 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 (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await request.json();
const parsed = createBadgeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'Validation failed', details: parsed.error.issues }, { status: 400 });
}
const { schoolId } = parsed.data;
const school = await db.school.findUnique({ where: { id: schoolId } });
if (!school) return NextResponse.json({ error: 'School not found' }, { status: 404 });
if (session.user?.role === 'SCHOOL_ADMIN' && session.user.schoolId && school.id !== session.user.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const badge = await db.badge.create({
data: {
schoolId,
name: parsed.data.name,
description: parsed.data.description ?? null,
icon: parsed.data.icon,
color: parsed.data.color,
category: parsed.data.category,
requirementType: parsed.data.requirementType,
requirementValue: parsed.data.requirementValue ?? null,
isAuto: parsed.data.isAuto,
},
});
return NextResponse.json(badge, { status: 201 });
} catch (error) {
console.error('Badges POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+108
View File
@@ -0,0 +1,108 @@
import { NextResponse } from 'next/server';
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 classGroup = await db.classGroup.findFirst({
where: { id, deletedAt: null },
});
if (!classGroup) {
return NextResponse.json({ error: 'Class not found' }, { status: 404 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== classGroup.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Get enrolled students
const enrollments = await db.enrollment.findMany({
where: { classGroupId: id, endDate: null },
include: {
student: {
select: { id: true, firstName: true, lastName: true },
},
},
});
const students = enrollments.map((e) => e.student);
// Parse existing seating order
let seatingOrder: Array<{ studentId: string; row: number; col: number }> = [];
if (classGroup.seatingOrder) {
try {
const parsed = JSON.parse(classGroup.seatingOrder);
if (Array.isArray(parsed)) {
// Check if it's the new format [{ studentId, row, col }]
if (parsed.length > 0 && parsed[0].studentId !== undefined && parsed[0].row !== undefined) {
seatingOrder = parsed;
} else {
// Legacy format: just array of student IDs, convert to grid
seatingOrder = parsed.map((studentId: string, i: number) => ({
studentId,
row: Math.floor(i / 5),
col: i % 5,
}));
}
}
} catch { /* ignore parse errors */ }
}
return NextResponse.json({ students, seatingOrder, classId: id });
} catch (error) {
console.error('Seating GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
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 { id } = await params;
const classGroup = await db.classGroup.findFirst({
where: { id, deletedAt: null },
});
if (!classGroup) {
return NextResponse.json({ error: 'Class not found' }, { status: 404 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== classGroup.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const { seatingOrder } = body;
if (!Array.isArray(seatingOrder)) {
return NextResponse.json({ error: 'seatingOrder must be an array' }, { status: 400 });
}
await db.classGroup.update({
where: { id },
data: { seatingOrder: JSON.stringify(seatingOrder) },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Seating PUT error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+141
View File
@@ -0,0 +1,141 @@
import { NextResponse } from 'next/server';
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 record = await db.healthRecord.findFirst({
where: { id, deletedAt: null },
include: {
student: {
select: { id: true, firstName: true, lastName: true },
},
},
});
if (!record) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== record.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json(record);
} catch (error) {
console.error('HealthRecord GET [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
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 { id } = await params;
const existing = await db.healthRecord.findFirst({
where: { id, deletedAt: null },
});
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const {
bloodType,
allergies,
medications,
conditions,
doctorName,
doctorPhone,
insuranceNumber,
insuranceProvider,
lastCheckup,
notes,
isConfidential,
} = body;
const updated = await db.healthRecord.update({
where: { id },
data: {
...(bloodType !== undefined && { bloodType: bloodType || null }),
...(allergies !== undefined && { allergies: allergies ? JSON.stringify(allergies) : null }),
...(medications !== undefined && { medications: medications ? JSON.stringify(medications) : null }),
...(conditions !== undefined && { conditions: conditions ? JSON.stringify(conditions) : null }),
...(doctorName !== undefined && { doctorName: doctorName || null }),
...(doctorPhone !== undefined && { doctorPhone: doctorPhone || null }),
...(insuranceNumber !== undefined && { insuranceNumber: insuranceNumber || null }),
...(insuranceProvider !== undefined && { insuranceProvider: insuranceProvider || null }),
...(lastCheckup !== undefined && { lastCheckup: lastCheckup ? new Date(lastCheckup) : null }),
...(notes !== undefined && { notes }),
...(isConfidential !== undefined && { isConfidential }),
},
include: {
student: {
select: { id: true, firstName: true, lastName: true },
},
},
});
return NextResponse.json(updated);
} catch (error) {
console.error('HealthRecord PUT [id] 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 });
}
const { id } = await params;
const existing = await db.healthRecord.findFirst({
where: { id, deletedAt: null },
});
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Soft delete
await db.healthRecord.update({
where: { id },
data: { deletedAt: new Date() },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('HealthRecord DELETE [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+113
View File
@@ -0,0 +1,113 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
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');
const studentId = searchParams.get('studentId');
if (!schoolId) {
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const where: Record<string, unknown> = {
schoolId,
deletedAt: null,
};
if (studentId) where.studentId = studentId;
const records = await db.healthRecord.findMany({
where,
orderBy: [{ createdAt: 'desc' }],
include: {
student: {
select: { id: true, firstName: true, lastName: true },
},
},
});
return NextResponse.json(records);
} catch (error) {
console.error('HealthRecord 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 });
}
const body = await request.json();
const {
schoolId,
studentId,
bloodType,
allergies,
medications,
conditions,
doctorName,
doctorPhone,
insuranceNumber,
insuranceProvider,
lastCheckup,
notes,
isConfidential,
isDemo,
} = body;
if (!schoolId || !studentId) {
return NextResponse.json(
{ error: 'schoolId and studentId are required' },
{ status: 400 }
);
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const record = await db.healthRecord.create({
data: {
schoolId,
studentId,
bloodType: bloodType || null,
allergies: allergies ? JSON.stringify(allergies) : null,
medications: medications ? JSON.stringify(medications) : null,
conditions: conditions ? JSON.stringify(conditions) : null,
doctorName: doctorName || null,
doctorPhone: doctorPhone || null,
insuranceNumber: insuranceNumber || null,
insuranceProvider: insuranceProvider || null,
lastCheckup: lastCheckup ? new Date(lastCheckup) : null,
notes: notes || null,
isConfidential: isConfidential ?? true,
isDemo: isDemo ?? false,
},
include: {
student: {
select: { id: true, firstName: true, lastName: true },
},
},
});
return NextResponse.json(record, { status: 201 });
} catch (error) {
console.error('HealthRecord POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+57
View File
@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
function isTeacherOrAdmin(role: string | undefined): boolean {
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
}
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 });
if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { id } = await params;
const studentBadge = await db.studentBadge.findUnique({
where: { id },
include: {
badge: true,
student: { select: { id: true, firstName: true, lastName: true } },
awardedByUser: { select: { id: true, firstName: true, lastName: true } },
},
});
if (!studentBadge) return NextResponse.json({ error: 'Student badge not found' }, { status: 404 });
return NextResponse.json(studentBadge);
} catch (error) {
console.error('StudentBadge GET 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 (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { id } = await params;
const existing = await db.studentBadge.findUnique({ where: { id } });
if (!existing) return NextResponse.json({ error: 'Student badge not found' }, { status: 404 });
await db.studentBadge.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
console.error('StudentBadge DELETE error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+107
View File
@@ -0,0 +1,107 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const awardBadgeSchema = z.object({
schoolId: z.string().min(1),
studentId: z.string().min(1),
badgeId: z.string().min(1),
notes: z.string().max(500).optional().nullable(),
});
function isTeacherOrAdmin(role: string | undefined): boolean {
return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
}
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
if (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { searchParams } = new URL(request.url);
const schoolIdParam = searchParams.get('schoolId');
const studentIdParam = searchParams.get('studentId');
const badgeIdParam = searchParams.get('badgeId');
let schoolId: string | undefined;
if (session.user?.role === 'SCHOOL_ADMIN') {
schoolId = session.user.schoolId ?? undefined;
} else {
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
}
if (!schoolId) return NextResponse.json([]);
const where: Record<string, unknown> = { schoolId };
if (studentIdParam) where.studentId = studentIdParam;
if (badgeIdParam) where.badgeId = badgeIdParam;
const studentBadges = await db.studentBadge.findMany({
where,
orderBy: { awardedAt: 'desc' },
include: {
badge: true,
student: { select: { id: true, firstName: true, lastName: true } },
awardedByUser: { select: { id: true, firstName: true, lastName: true } },
},
});
return NextResponse.json(studentBadges);
} catch (error) {
console.error('StudentBadges 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 (!isTeacherOrAdmin(session.user?.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await request.json();
const parsed = awardBadgeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'Validation failed', details: parsed.error.issues }, { status: 400 });
}
const { schoolId, studentId, badgeId } = parsed.data;
// Verify badge exists
const badge = await db.badge.findUnique({ where: { id: badgeId, deletedAt: null } });
if (!badge) return NextResponse.json({ error: 'Badge not found' }, { status: 404 });
// Verify student exists
const student = await db.student.findUnique({ where: { id: studentId, deletedAt: null } });
if (!student) return NextResponse.json({ error: 'Student not found' }, { status: 404 });
// Check if already awarded
const existing = await db.studentBadge.findUnique({
where: { studentId_badgeId: { studentId, badgeId } },
});
if (existing) {
return NextResponse.json({ error: 'Badge already awarded to this student' }, { status: 409 });
}
const studentBadge = await db.studentBadge.create({
data: {
schoolId,
studentId,
badgeId,
awardedBy: session.userId,
notes: parsed.data.notes ?? null,
},
include: {
badge: true,
student: { select: { id: true, firstName: true, lastName: true } },
},
});
return NextResponse.json(studentBadge, { status: 201 });
} catch (error) {
console.error('StudentBadges POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+137
View File
@@ -0,0 +1,137 @@
import { NextResponse } from 'next/server';
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 transport = await db.studentTransport.findFirst({
where: { id, deletedAt: null },
include: {
student: {
select: { id: true, firstName: true, lastName: true },
},
},
});
if (!transport) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== transport.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json(transport);
} catch (error) {
console.error('StudentTransport GET [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
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 { id } = await params;
const existing = await db.studentTransport.findFirst({
where: { id, deletedAt: null },
});
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const {
transportType,
routeNumber,
stopName,
pickupTime,
dropoffTime,
driverName,
driverPhone,
distanceKm,
notes,
} = body;
const updated = await db.studentTransport.update({
where: { id },
data: {
...(transportType !== undefined && { transportType }),
...(routeNumber !== undefined && { routeNumber: routeNumber || null }),
...(stopName !== undefined && { stopName: stopName || null }),
...(pickupTime !== undefined && { pickupTime: pickupTime || null }),
...(dropoffTime !== undefined && { dropoffTime: dropoffTime || null }),
...(driverName !== undefined && { driverName: driverName || null }),
...(driverPhone !== undefined && { driverPhone: driverPhone || null }),
...(distanceKm !== undefined && { distanceKm: distanceKm != null ? distanceKm : null }),
...(notes !== undefined && { notes }),
},
include: {
student: {
select: { id: true, firstName: true, lastName: true },
},
},
});
return NextResponse.json(updated);
} catch (error) {
console.error('StudentTransport PUT [id] 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 });
}
const { id } = await params;
const existing = await db.studentTransport.findFirst({
where: { id, deletedAt: null },
});
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Soft delete
await db.studentTransport.update({
where: { id },
data: { deletedAt: new Date() },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('StudentTransport DELETE [id] error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+109
View File
@@ -0,0 +1,109 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
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');
const studentId = searchParams.get('studentId');
if (!schoolId) {
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const where: Record<string, unknown> = {
schoolId,
deletedAt: null,
};
if (studentId) where.studentId = studentId;
const transports = await db.studentTransport.findMany({
where,
orderBy: [{ createdAt: 'desc' }],
include: {
student: {
select: { id: true, firstName: true, lastName: true },
},
},
});
return NextResponse.json(transports);
} catch (error) {
console.error('StudentTransport 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 });
}
const body = await request.json();
const {
schoolId,
studentId,
transportType,
routeNumber,
stopName,
pickupTime,
dropoffTime,
driverName,
driverPhone,
distanceKm,
notes,
isDemo,
} = body;
if (!schoolId || !studentId || !transportType) {
return NextResponse.json(
{ error: 'schoolId, studentId, and transportType are required' },
{ status: 400 }
);
}
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const transport = await db.studentTransport.create({
data: {
schoolId,
studentId,
transportType,
routeNumber: routeNumber || null,
stopName: stopName || null,
pickupTime: pickupTime || null,
dropoffTime: dropoffTime || null,
driverName: driverName || null,
driverPhone: driverPhone || null,
distanceKm: distanceKm != null ? distanceKm : null,
notes: notes || null,
isDemo: isDemo ?? false,
},
include: {
student: {
select: { id: true, firstName: true, lastName: true },
},
},
});
return NextResponse.json(transport, { status: 201 });
} catch (error) {
console.error('StudentTransport POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+194
View File
@@ -2315,4 +2315,198 @@
min-height: 44px;
min-width: 44px;
}
/* ─── Badge Circle Styling ─────────────────────────────────────── */
.badge-circle {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
position: relative;
cursor: pointer;
}
.badge-circle:hover {
transform: scale(1.12);
}
.badge-earned {
background: linear-gradient(135deg, oklch(0.65 0.17 155), oklch(0.55 0.14 170));
box-shadow: 0 2px 12px oklch(0.55 0.17 155 / 0.3);
}
.badge-earned:hover {
box-shadow: 0 4px 20px oklch(0.55 0.17 155 / 0.5);
}
.badge-unearned {
background: oklch(0.92 0 0);
border: 2px dashed oklch(0.7 0 0);
opacity: 0.5;
}
.dark .badge-unearned {
background: oklch(0.25 0 0);
border-color: oklch(0.45 0 0);
}
.badge-progress {
position: absolute;
bottom: -2px;
left: 50%;
transform: translateX(-50%);
width: 36px;
height: 4px;
border-radius: 2px;
background: oklch(0.85 0 0);
overflow: hidden;
}
.dark .badge-progress {
background: oklch(0.35 0 0);
}
.badge-progress-fill {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, oklch(0.55 0.17 155), oklch(0.65 0.12 175));
transition: width 0.5s ease;
}
/* ─── Celebration Animation ────────────────────────────────────── */
@keyframes celebration-sparkle {
0% { opacity: 0; transform: scale(0.5) rotate(0deg); }
50% { opacity: 1; transform: scale(1.2) rotate(180deg); }
100% { opacity: 0; transform: scale(0.5) rotate(360deg); }
}
@keyframes celebration-bounce {
0%, 100% { transform: translateY(0); }
30% { transform: translateY(-12px); }
50% { transform: translateY(-6px); }
70% { transform: translateY(-8px); }
}
.celebration-sparkle {
animation: celebration-sparkle 0.8s ease-in-out;
}
.celebration-bounce {
animation: celebration-bounce 0.6s ease-in-out;
}
.celebration-ring {
position: absolute;
inset: -6px;
border-radius: 50%;
border: 2px solid oklch(0.65 0.17 155 / 0.6);
animation: celebration-ring-pulse 1s ease-out;
}
@keyframes celebration-ring-pulse {
0% { transform: scale(0.8); opacity: 1; }
100% { transform: scale(1.5); opacity: 0; }
}
/* ─── QR Code Card Styling ─────────────────────────────────────── */
.qr-card {
border-radius: 1rem;
padding: 1.5rem;
text-align: center;
background: white;
box-shadow: 0 4px 16px oklch(0 0 0 / 0.08);
position: relative;
overflow: hidden;
}
.dark .qr-card {
background: oklch(0.2 0 0);
box-shadow: 0 4px 16px oklch(0 0 0 / 0.3);
}
.qr-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, oklch(0.55 0.17 155), oklch(0.65 0.12 175), oklch(0.55 0.17 155));
}
.qr-card img {
border-radius: 0.5rem;
max-width: 100%;
}
/* ─── Attendance QR Display ────────────────────────────────────── */
.attendance-qr-display {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2rem;
background: linear-gradient(135deg, oklch(0.97 0.02 155), oklch(0.95 0.02 175));
border-radius: 1rem;
border: 2px solid oklch(0.55 0.17 155 / 0.2);
}
.dark .attendance-qr-display {
background: linear-gradient(135deg, oklch(0.2 0.01 155), oklch(0.18 0.01 175));
border-color: oklch(0.55 0.17 155 / 0.3);
}
.attendance-qr-display .qr-timer {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: oklch(0.5 0 0);
}
.dark .attendance-qr-display .qr-timer {
color: oklch(0.65 0 0);
}
.attendance-qr-display .qr-timer-bar {
width: 100%;
height: 4px;
border-radius: 2px;
background: oklch(0.9 0 0);
overflow: hidden;
}
.dark .attendance-qr-display .qr-timer-bar {
background: oklch(0.3 0 0);
}
.attendance-qr-display .qr-timer-bar-fill {
height: 100%;
background: linear-gradient(90deg, oklch(0.55 0.17 155), oklch(0.65 0.12 175));
transition: width 1s linear;
}
/* ─── Student Detail Quick Stats ───────────────────────────────── */
.quick-stats-bar {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.75rem;
border-radius: 0.75rem;
background: oklch(0.97 0.02 155);
border: 1px solid oklch(0.55 0.17 155 / 0.1);
}
.dark .quick-stats-bar {
background: oklch(0.2 0.01 155);
border-color: oklch(0.55 0.17 155 / 0.2);
}
.quick-stat-item {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
background: white;
border: 1px solid oklch(0.9 0 0);
font-size: 0.8125rem;
white-space: nowrap;
}
.dark .quick-stat-item {
background: oklch(0.25 0 0);
border-color: oklch(0.35 0 0);
}
/* ─── Section Header with Icon ─────────────────────────────────── */
.section-header-icon {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
cursor: pointer;
user-select: none;
}
.section-header-icon:hover {
opacity: 0.8;
}
}