Round 17: QR Codes, Gamification Badges, Auto-Award, Badge Management
This commit is contained in:
1 parent
6174f127f9
commit
cba8a8e724
23 files changed
+4028
-1
No files matched your search
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
Download,
|
||||
AlertTriangle,
|
||||
Shield,
|
||||
QrCode,
|
||||
FileDown,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
LineChart,
|
||||
@@ -97,6 +100,7 @@ import {
|
||||
type Subject,
|
||||
type AttendanceAnalyticsData,
|
||||
} from '@/lib/api';
|
||||
import { generateQRCodeSync, downloadQRCode, type QRCodeData } from '@/lib/qrcode';
|
||||
|
||||
/* ── Status helpers ────────────────────────────────────────────────── */
|
||||
|
||||
@@ -1072,6 +1076,43 @@ export default function AttendanceView() {
|
||||
const [newPeriod, setNewPeriod] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// QR Attendance mode
|
||||
const [qrAttendanceMode, setQrAttendanceMode] = useState(false);
|
||||
const [qrAttendanceDataUrl, setQrAttendanceDataUrl] = useState<string>('');
|
||||
const [qrRefreshTimer, setQrRefreshTimer] = useState(300); // 5 minutes = 300 seconds
|
||||
const [qrTimerInterval, setQrTimerInterval] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
// QR Attendance: generate and refresh QR code
|
||||
const generateAttendanceQR = useCallback(() => {
|
||||
if (!currentClassId) return;
|
||||
const sessionId = `attendance-${currentClassId}-${Date.now()}`;
|
||||
const qrData: QRCodeData = { type: 'attendance', id: currentClassId, sessionId, timestamp: Date.now() };
|
||||
const dataUrl = generateQRCodeSync(qrData, { size: 300 });
|
||||
setQrAttendanceDataUrl(dataUrl);
|
||||
setQrRefreshTimer(300);
|
||||
}, [currentClassId]);
|
||||
|
||||
// QR timer effect
|
||||
useEffect(() => {
|
||||
if (!qrAttendanceMode) {
|
||||
if (qrTimerInterval) clearInterval(qrTimerInterval);
|
||||
setQrTimerInterval(null);
|
||||
return;
|
||||
}
|
||||
generateAttendanceQR();
|
||||
const interval = setInterval(() => {
|
||||
setQrRefreshTimer((prev) => {
|
||||
if (prev <= 1) {
|
||||
generateAttendanceQR();
|
||||
return 300;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
setQrTimerInterval(interval);
|
||||
return () => clearInterval(interval);
|
||||
}, [qrAttendanceMode, generateAttendanceQR]);
|
||||
|
||||
// Load classes
|
||||
useEffect(() => {
|
||||
fetchClasses()
|
||||
@@ -1476,6 +1517,10 @@ export default function AttendanceView() {
|
||||
<TabsTrigger value="calendar" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
|
||||
{t('attendance.calendar')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="qr" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
|
||||
<QrCode className="h-4 w-4 mr-1" />
|
||||
{t('qr.attendance')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Sessions List Tab */}
|
||||
@@ -1650,6 +1695,109 @@ export default function AttendanceView() {
|
||||
<TabsContent value="calendar" className="space-y-4">
|
||||
<MonthlyHeatmap sessions={sessions} />
|
||||
</TabsContent>
|
||||
|
||||
{/* QR Attendance Tab */}
|
||||
<TabsContent value="qr" className="space-y-4">
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||
<QrCode className="h-4 w-4" />
|
||||
</div>
|
||||
{t('qr.attendance')}
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-300"
|
||||
onClick={() => setQrAttendanceMode(!qrAttendanceMode)}
|
||||
>
|
||||
{qrAttendanceMode ? t('action.close') : t('qr.generate')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{qrAttendanceMode ? (
|
||||
<div className="attendance-qr-display">
|
||||
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span className="font-semibold">{t('qr.session_active')}</span>
|
||||
</div>
|
||||
{qrAttendanceDataUrl ? (
|
||||
<img src={qrAttendanceDataUrl} alt={t('qr.attendance')} className="mx-auto rounded-lg" width={280} height={280} />
|
||||
) : (
|
||||
<div className="w-[280px] h-[280px] mx-auto bg-gray-100 dark:bg-gray-800 rounded-lg flex items-center justify-center">
|
||||
<QrCode className="h-16 w-16 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('qr.scan_to_checkin')}</p>
|
||||
<div className="qr-timer">
|
||||
<RefreshCw className="h-4 w-4 text-emerald-500" />
|
||||
<span>{t('qr.refreshes_in', { seconds: qrRefreshTimer })}</span>
|
||||
</div>
|
||||
<div className="qr-timer-bar w-full max-w-xs">
|
||||
<div className="qr-timer-bar-fill" style={{ width: `${(qrRefreshTimer / 300) * 100}%` }} />
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl min-h-[44px]"
|
||||
onClick={() => { if (qrAttendanceDataUrl) downloadQRCode(qrAttendanceDataUrl, 'attendance-qr.png'); }}
|
||||
>
|
||||
<FileDown className="h-4 w-4 mr-1.5" />
|
||||
{t('qr.download')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl min-h-[44px]"
|
||||
onClick={generateAttendanceQR}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||
{t('qr.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Real-time check-in status */}
|
||||
{sessions.length > 0 && (
|
||||
<div className="w-full mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">{t('attendance.status')}</h4>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto scrollbar-education">
|
||||
{sessions[0]?.records.map((record) => (
|
||||
<div key={record.id} className="flex items-center justify-between p-2 rounded-lg bg-white dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-xs font-medium">
|
||||
{record.student.firstName[0]}{record.student.lastName[0]}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{record.student.firstName} {record.student.lastName}</span>
|
||||
</div>
|
||||
<StatusBadge status={record.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-md shadow-emerald-200/40 dark:shadow-emerald-900/20">
|
||||
<QrCode className="h-10 w-10 text-emerald-500 dark:text-emerald-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">{t('qr.attendance')}</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto mb-4">{t('qr.scan_to_checkin')}</p>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl"
|
||||
onClick={() => setQrAttendanceMode(true)}
|
||||
>
|
||||
<QrCode className="h-4 w-4 mr-1.5" />
|
||||
{t('qr.generate')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
GripVertical, Armchair, ArrowRight,
|
||||
GraduationCap, Library, Backpack,
|
||||
Sprout, Leaf, TreePine, Trees,
|
||||
QrCode, FileDown,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
type LearningProgressEntry,
|
||||
type ClassGroup, type Student, type Subject, type CompetencyTemplate, type ClassCompetencyAssignment,
|
||||
} from '@/lib/api';
|
||||
import { generateQRCodeSync, downloadQRCode, type QRCodeData } from '@/lib/qrcode';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const schoolTypeAccent: Record<string, string> = {
|
||||
@@ -192,6 +194,10 @@ export default function ClassesView() {
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
const [reorderSaving, setReorderSaving] = useState(false);
|
||||
|
||||
// QR Code dialog
|
||||
const [classQrOpen, setClassQrOpen] = useState(false);
|
||||
const [classQrDataUrl, setClassQrDataUrl] = useState<string>('');
|
||||
|
||||
// Parse a CSV file (client-side). Supports quoted fields with escaped quotes.
|
||||
function parseCsv(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
@@ -787,6 +793,15 @@ export default function ClassesView() {
|
||||
<Grid3X3 className="h-4 w-4 mr-1" />
|
||||
{t('classes.assign_template')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="border-teal-300 dark:border-teal-700 rounded-xl" onClick={() => {
|
||||
const qrData: QRCodeData = { type: 'class', id: selectedClass.id, label: selectedClass.name };
|
||||
const dataUrl = generateQRCodeSync(qrData, { size: 256 });
|
||||
setClassQrDataUrl(dataUrl);
|
||||
setClassQrOpen(true);
|
||||
}}>
|
||||
<QrCode className="h-4 w-4 mr-1" />
|
||||
{t('qr.class')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -1423,6 +1438,40 @@ export default function ClassesView() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Class QR Code Dialog */}
|
||||
<Dialog open={classQrOpen} onOpenChange={setClassQrOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<QrCode className="h-5 w-5 text-emerald-600" />
|
||||
{t('qr.class')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{selectedClass?.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="qr-card mx-auto">
|
||||
{classQrDataUrl ? (
|
||||
<img src={classQrDataUrl} alt={t('qr.title')} className="mx-auto" width={200} height={200} />
|
||||
) : (
|
||||
<div className="w-[200px] h-[200px] mx-auto bg-gray-100 dark:bg-gray-800 rounded-lg flex items-center justify-center">
|
||||
<QrCode className="h-12 w-12 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">{selectedClass?.name}</p>
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500">{t('label.grade')} {selectedClass?.gradeLevel} · {selectedClass?.schoolYear?.label}</p>
|
||||
</div>
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-xl min-h-[44px]"
|
||||
onClick={() => { if (classQrDataUrl) downloadQRCode(classQrDataUrl, `${selectedClass?.name || 'class'}-qr.png`); }}
|
||||
>
|
||||
<FileDown className="h-4 w-4 mr-1.5" />
|
||||
{t('qr.download')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,11 @@ import {
|
||||
AlertCircle,
|
||||
Star,
|
||||
Printer,
|
||||
Trophy,
|
||||
Award,
|
||||
Target,
|
||||
CalendarCheck,
|
||||
Flame,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -145,6 +150,15 @@ import {
|
||||
fetchEmailLogs,
|
||||
type EmailTemplate,
|
||||
type EmailLog,
|
||||
fetchBadges,
|
||||
createBadge,
|
||||
updateBadge,
|
||||
deleteBadge,
|
||||
fetchStudentBadges,
|
||||
awardBadgeToStudent,
|
||||
type BadgeData,
|
||||
type StudentBadgeData,
|
||||
seedBadges,
|
||||
} from '@/lib/api';
|
||||
import { RateLimitStatus } from '@/components/offline-indicator';
|
||||
|
||||
@@ -409,6 +423,278 @@ function NotificationSoundSetting() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Badge Management Tab Component ─────────────────────────────────────
|
||||
function BadgeManagementTab() {
|
||||
const currentUser = useAppStore((s) => s.currentUser);
|
||||
const [badges, setBadges] = useState<BadgeData[]>([]);
|
||||
const [studentBadges, setStudentBadges] = useState<StudentBadgeData[]>([]);
|
||||
const [students, setStudentsList] = useState<Array<{ id: string; firstName: string; lastName: string }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createBadgeOpen, setCreateBadgeOpen] = useState(false);
|
||||
const [awardBadgeOpen, setAwardBadgeOpen] = useState(false);
|
||||
const [newBadgeForm, setNewBadgeForm] = useState({
|
||||
name: '', description: '', icon: 'Award', color: '#10b981',
|
||||
category: 'achievement', requirementType: 'custom', requirementValue: 5, isAuto: true,
|
||||
});
|
||||
const [awardForm, setAwardForm] = useState({ studentId: '', badgeId: '', notes: '' });
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: 'competency', label: t('badges.competency') },
|
||||
{ value: 'attendance', label: t('badges.attendance') },
|
||||
{ value: 'behavior', label: t('badges.behavior') },
|
||||
{ value: 'achievement', label: t('badges.achievement') },
|
||||
{ value: 'milestone', label: t('badges.milestone') },
|
||||
];
|
||||
const reqTypeOptions = [
|
||||
{ value: 'mastery_level', label: 'Mastery Level' },
|
||||
{ value: 'attendance_rate', label: 'Attendance Rate' },
|
||||
{ value: 'behavior_count', label: 'Behavior Count' },
|
||||
{ value: 'progress_entries', label: 'Progress Entries' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
];
|
||||
const iconOptions = ['Award', 'Star', 'Trophy', 'Target', 'CalendarCheck', 'TrendingUp', 'BookOpen', 'Pencil', 'ClipboardCheck', 'Leaf', 'Heart', 'Zap', 'Rocket', 'Flame'];
|
||||
|
||||
const loadBadges = useCallback(async () => {
|
||||
if (!currentUser?.schoolId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
// Seed default badges first
|
||||
await seedBadges(currentUser.schoolId);
|
||||
const data = await fetchBadges(currentUser.schoolId);
|
||||
setBadges(data);
|
||||
const sbData = await fetchStudentBadges(currentUser.schoolId);
|
||||
setStudentBadges(sbData);
|
||||
const stuData = await fetchStudents(currentUser.schoolId);
|
||||
setStudentsList(stuData.map(s => ({ id: s.id, firstName: s.firstName, lastName: s.lastName })));
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
}, [currentUser?.schoolId]);
|
||||
|
||||
useEffect(() => { loadBadges(); }, [loadBadges]);
|
||||
|
||||
const handleCreateBadge = async () => {
|
||||
if (!currentUser?.schoolId || !newBadgeForm.name) return;
|
||||
try {
|
||||
await createBadge({ schoolId: currentUser.schoolId, ...newBadgeForm });
|
||||
toast.success(t('badges.create'));
|
||||
setCreateBadgeOpen(false);
|
||||
setNewBadgeForm({ name: '', description: '', icon: 'Award', color: '#10b981', category: 'achievement', requirementType: 'custom', requirementValue: 5, isAuto: true });
|
||||
loadBadges();
|
||||
} catch { toast.error(t('error.generic')); }
|
||||
};
|
||||
|
||||
const handleAwardBadge = async () => {
|
||||
if (!currentUser?.schoolId || !awardForm.studentId || !awardForm.badgeId) return;
|
||||
try {
|
||||
await awardBadgeToStudent({ schoolId: currentUser.schoolId, studentId: awardForm.studentId, badgeId: awardForm.badgeId, notes: awardForm.notes });
|
||||
toast.success(t('badges.award'));
|
||||
setAwardBadgeOpen(false);
|
||||
setAwardForm({ studentId: '', badgeId: '', notes: '' });
|
||||
loadBadges();
|
||||
} catch { toast.error(t('error.generic')); }
|
||||
};
|
||||
|
||||
const handleDeleteBadge = async (id: string) => {
|
||||
try {
|
||||
await deleteBadge(id);
|
||||
toast.success(t('badges.delete'));
|
||||
loadBadges();
|
||||
} catch { toast.error(t('error.generic')); }
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-amber-500" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Badge Statistics */}
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
|
||||
<Trophy className="h-4 w-4" />
|
||||
</div>
|
||||
{t('badges.statistics')}
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" className="rounded-xl min-h-[44px]" onClick={() => setAwardBadgeOpen(true)}>
|
||||
<Award className="h-4 w-4 mr-1" />
|
||||
{t('badges.award_manually')}
|
||||
</Button>
|
||||
<Button size="sm" className="rounded-xl bg-gradient-to-r from-amber-500 to-amber-600 text-white min-h-[44px]" onClick={() => setCreateBadgeOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('badges.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6">
|
||||
<div className="p-4 rounded-xl bg-amber-50/60 dark:bg-amber-900/10 border border-amber-200/30 dark:border-amber-900/20 text-center">
|
||||
<Trophy className="h-5 w-5 text-amber-500 mx-auto mb-1" />
|
||||
<p className="text-2xl font-bold text-amber-700 dark:text-amber-300">{badges.length}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-amber-600/60 dark:text-amber-400/40">{t('badges.title')}</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-emerald-50/60 dark:bg-emerald-900/10 border border-emerald-200/30 dark:border-emerald-900/20 text-center">
|
||||
<Award className="h-5 w-5 text-emerald-500 mx-auto mb-1" />
|
||||
<p className="text-2xl font-bold text-emerald-700 dark:text-emerald-300">{studentBadges.length}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('badges.earned')}</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-teal-50/60 dark:bg-teal-900/10 border border-teal-200/30 dark:border-teal-900/20 text-center">
|
||||
<Star className="h-5 w-5 text-teal-500 mx-auto mb-1" />
|
||||
<p className="text-2xl font-bold text-teal-700 dark:text-teal-300">{badges.filter(b => b.isAuto).length}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-teal-600/60 dark:text-teal-400/40">{t('badges.auto')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badge List */}
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto scrollbar-education">
|
||||
{badges.map((badge) => {
|
||||
const awardedCount = badge._count?.studentBadges ?? 0;
|
||||
return (
|
||||
<div key={badge.id} className="flex items-center justify-between p-3 rounded-lg bg-white dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="badge-circle badge-earned" style={{ background: `linear-gradient(135deg, ${badge.color}cc, ${badge.color}88)`, width: 40, height: 40 }}>
|
||||
<Award className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{badge.name}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{badge.description}</p>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Badge className="text-[10px] bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">{t(`badges.${badge.category}`)}</Badge>
|
||||
{badge.isAuto && <Badge className="text-[10px] bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300">{t('badges.auto')}</Badge>}
|
||||
<Badge className="text-[10px] bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">{awardedCount} {t('badges.earned')}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="text-rose-500 hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-900/20 rounded-lg min-h-[44px]" onClick={() => handleDeleteBadge(badge.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Badge Dialog */}
|
||||
<Dialog open={createBadgeOpen} onOpenChange={setCreateBadgeOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-amber-600" />
|
||||
{t('badges.create')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t('badges.create')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">{t('label.name')}</Label>
|
||||
<Input value={newBadgeForm.name} onChange={(e) => setNewBadgeForm(f => ({ ...f, name: e.target.value }))} placeholder={t('badges.name' in {} ? 'badges.name' : 'label.name')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">{t('label.description')}</Label>
|
||||
<Input value={newBadgeForm.description} onChange={(e) => setNewBadgeForm(f => ({ ...f, description: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">{t('badges.icon')}</Label>
|
||||
<Select value={newBadgeForm.icon} onValueChange={(v) => setNewBadgeForm(f => ({ ...f, icon: v }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{iconOptions.map(i => <SelectItem key={i} value={i}>{i}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">{t('badges.color')}</Label>
|
||||
<Input type="color" value={newBadgeForm.color} onChange={(e) => setNewBadgeForm(f => ({ ...f, color: e.target.value }))} className="h-10" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">{t('badges.category')}</Label>
|
||||
<Select value={newBadgeForm.category} onValueChange={(v) => setNewBadgeForm(f => ({ ...f, category: v }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryOptions.map(c => <SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">{t('badges.type')}</Label>
|
||||
<Select value={newBadgeForm.requirementType} onValueChange={(v) => setNewBadgeForm(f => ({ ...f, requirementType: v }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{reqTypeOptions.map(r => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">{t('badges.threshold')}</Label>
|
||||
<Input type="number" value={newBadgeForm.requirementValue} onChange={(e) => setNewBadgeForm(f => ({ ...f, requirementValue: parseInt(e.target.value) || 0 }))} />
|
||||
</div>
|
||||
<div className="flex items-end gap-2 pb-2">
|
||||
<input type="checkbox" checked={newBadgeForm.isAuto} onChange={(e) => setNewBadgeForm(f => ({ ...f, isAuto: e.target.checked }))} className="rounded" />
|
||||
<Label className="text-xs font-medium">{t('badges.is_auto')}</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="rounded-xl" onClick={() => setCreateBadgeOpen(false)}>{t('action.cancel')}</Button>
|
||||
<Button className="rounded-xl bg-amber-500 hover:bg-amber-600 text-white" onClick={handleCreateBadge}>{t('badges.create')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Award Badge Dialog */}
|
||||
<Dialog open={awardBadgeOpen} onOpenChange={setAwardBadgeOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Award className="h-5 w-5 text-amber-600" />
|
||||
{t('badges.award_manually')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t('badges.award')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">{t('label.student_count')}</Label>
|
||||
<Select value={awardForm.studentId} onValueChange={(v) => setAwardForm(f => ({ ...f, studentId: v }))}>
|
||||
<SelectTrigger><SelectValue placeholder={t('action.select')} /></SelectTrigger>
|
||||
<SelectContent className="max-h-48">
|
||||
{studentsList.map(s => <SelectItem key={s.id} value={s.id}>{s.firstName} {s.lastName}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">{t('badges.title')}</Label>
|
||||
<Select value={awardForm.badgeId} onValueChange={(v) => setAwardForm(f => ({ ...f, badgeId: v }))}>
|
||||
<SelectTrigger><SelectValue placeholder={t('action.select')} /></SelectTrigger>
|
||||
<SelectContent className="max-h-48">
|
||||
{badges.map(b => <SelectItem key={b.id} value={b.id}>{b.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">{t('label.note')}</Label>
|
||||
<Input value={awardForm.notes} onChange={(e) => setAwardForm(f => ({ ...f, notes: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="rounded-xl" onClick={() => setAwardBadgeOpen(false)}>{t('action.cancel')}</Button>
|
||||
<Button className="rounded-xl bg-amber-500 hover:bg-amber-600 text-white" onClick={handleAwardBadge} disabled={!awardForm.studentId || !awardForm.badgeId}>{t('badges.award')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsView() {
|
||||
const currentUser = useAppStore((s) => s.currentUser);
|
||||
const [activeTab, setActiveTab] = useState('school');
|
||||
@@ -1569,6 +1855,10 @@ export default function SettingsView() {
|
||||
<Shield className="h-4 w-4 mr-1.5" />
|
||||
<span className="hidden sm:inline">{t('rate_limit.status_title')}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="badges" className="rounded-lg min-h-[44px] data-[state=active]:bg-amber-500 data-[state=active]:text-white">
|
||||
<Trophy className="h-4 w-4 mr-1.5" />
|
||||
<span className="hidden sm:inline">{t('badges.manage')}</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── School Info Tab ─────────────────────────────────── */}
|
||||
@@ -4273,6 +4563,11 @@ export default function SettingsView() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* ── Badge Management Tab ─────────────────────────────────── */}
|
||||
<TabsContent value="badges">
|
||||
<BadgeManagementTab />
|
||||
</TabsContent>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
LucideIcon, Download, FileSpreadsheet, FileDown, Heart, Users as UsersIcon,
|
||||
Eye, Plus, Trash2, Clock, CheckCircle2, XCircle, SlidersHorizontal, Lightbulb,
|
||||
Phone, PhoneCall, MapPin, ShieldAlert, UserCheck, AlertCircle, Globe,
|
||||
Bus, Car, Bike, Footprints, Train, Truck, Droplets, Pill, Stethoscope,
|
||||
EyeOff, Shield, Activity, HeartPulse, Syringe,
|
||||
QrCode, CalendarCheck, Leaf,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -36,7 +39,8 @@ import {
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useAppStore, type CurrentUser } from '@/lib/store';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { fetchStudentDetail, getReportPdfUrl, type StudentDetailData, fetchParentLinks, type ParentStudentLinkData, apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
|
||||
import { fetchStudentDetail, getReportPdfUrl, type StudentDetailData, fetchParentLinks, type ParentStudentLinkData, apiGet, apiPost, apiPut, apiDelete, fetchBadgeProgress, type BadgeProgressData, fetchStudentBadges, type StudentBadgeData, type BadgeData } from '@/lib/api';
|
||||
import { generateQRCodeSync, downloadQRCode, type QRCodeData } from '@/lib/qrcode';
|
||||
import { toast } from 'sonner';
|
||||
import TeacherNotesSection from './teacher-notes-section';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
||||
@@ -332,6 +336,13 @@ const masteryBadge = (level: number) => {
|
||||
return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300';
|
||||
};
|
||||
|
||||
// Icon map for badge icons
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
CalendarCheck, Award, TrendingUp, Star, BookOpen, Pencil, ClipboardCheck,
|
||||
UsersIcon, Target, Leaf, Trophy, Zap, Rocket, Heart, GraduationCap,
|
||||
CheckCircle2, Shield, Activity, Flower2, Flag, Lightbulb,
|
||||
};
|
||||
|
||||
const gradeColor = (value: number) => {
|
||||
if (value <= 2) return 'text-emerald-600 dark:text-emerald-400';
|
||||
if (value <= 4) return 'text-amber-600 dark:text-amber-400';
|
||||
@@ -463,6 +474,51 @@ export default function StudentDetailView() {
|
||||
const [ecEditId, setEcEditId] = useState<string | null>(null);
|
||||
const [ecForm, setEcForm] = useState({ name: '', relationship: 'mother', phone: '', phoneAlt: '', email: '', address: '', isPrimary: false, priority: 1, notes: '' });
|
||||
|
||||
// Transportation state
|
||||
interface TransportData {
|
||||
id: string; schoolId: string; studentId: string; transportType: string;
|
||||
routeNumber: string | null; stopName: string | null; pickupTime: string | null;
|
||||
dropoffTime: string | null; driverName: string | null; driverPhone: string | null;
|
||||
distanceKm: number | null; notes: string | null; isDemo: boolean;
|
||||
createdAt: string; updatedAt: string; deletedAt: string | null;
|
||||
student: { id: string; firstName: string; lastName: string };
|
||||
}
|
||||
const [transports, setTransports] = useState<TransportData[]>([]);
|
||||
const [transportDialogOpen, setTransportDialogOpen] = useState(false);
|
||||
const [transportEditId, setTransportEditId] = useState<string | null>(null);
|
||||
const [transportForm, setTransportForm] = useState({
|
||||
transportType: 'bus', routeNumber: '', stopName: '', pickupTime: '',
|
||||
dropoffTime: '', driverName: '', driverPhone: '', distanceKm: '', notes: '',
|
||||
});
|
||||
|
||||
// Health Records state
|
||||
interface HealthRecordData {
|
||||
id: string; schoolId: string; studentId: string; bloodType: string | null;
|
||||
allergies: string | null; medications: string | null; conditions: string | null;
|
||||
doctorName: string | null; doctorPhone: string | null; insuranceNumber: string | null;
|
||||
insuranceProvider: string | null; lastCheckup: string | null; notes: string | null;
|
||||
isConfidential: boolean; isDemo: boolean;
|
||||
createdAt: string; updatedAt: string; deletedAt: string | null;
|
||||
student: { id: string; firstName: string; lastName: string };
|
||||
}
|
||||
const [healthRecord, setHealthRecord] = useState<HealthRecordData | null>(null);
|
||||
const [healthDialogOpen, setHealthDialogOpen] = useState(false);
|
||||
const [healthEditId, setHealthEditId] = useState<string | null>(null);
|
||||
const [healthForm, setHealthForm] = useState({
|
||||
bloodType: '', allergies: '' as string, medications: '' as string,
|
||||
conditions: '' as string, doctorName: '', doctorPhone: '',
|
||||
insuranceNumber: '', insuranceProvider: '', lastCheckup: '',
|
||||
notes: '', isConfidential: true,
|
||||
});
|
||||
|
||||
// QR Code & Badge state
|
||||
const [qrDialogOpen, setQrDialogOpen] = useState(false);
|
||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>('');
|
||||
const [badgeProgress, setBadgeProgress] = useState<BadgeProgressData[]>([]);
|
||||
const [earnedBadges, setEarnedBadges] = useState<StudentBadgeData[]>([]);
|
||||
const [badgeDetailDialogOpen, setBadgeDetailDialogOpen] = useState(false);
|
||||
const [selectedBadgeDetail, setSelectedBadgeDetail] = useState<BadgeProgressData | null>(null);
|
||||
|
||||
// Load parent links for parent users
|
||||
useEffect(() => {
|
||||
if (isParent && currentUser?.id) {
|
||||
@@ -540,6 +596,46 @@ export default function StudentDetailView() {
|
||||
loadEC();
|
||||
}, [currentStudentId, currentUser?.schoolId]);
|
||||
|
||||
// Load transportation and health records
|
||||
useEffect(() => {
|
||||
if (!currentStudentId || !currentUser?.schoolId) return;
|
||||
const schoolId = currentUser.schoolId;
|
||||
async function loadTransport() {
|
||||
try {
|
||||
const data = await apiGet<TransportData[]>(`/api/student-transport?schoolId=${schoolId}&studentId=${currentStudentId}`);
|
||||
setTransports(data);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
async function loadHealth() {
|
||||
try {
|
||||
const data = await apiGet<HealthRecordData[]>(`/api/health-records?schoolId=${schoolId}&studentId=${currentStudentId}`);
|
||||
setHealthRecord(data.length > 0 ? data[0] : null);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
loadTransport();
|
||||
loadHealth();
|
||||
}, [currentStudentId, currentUser?.schoolId]);
|
||||
|
||||
// Load badge progress and earned badges
|
||||
useEffect(() => {
|
||||
if (!currentStudentId || !currentUser?.schoolId) return;
|
||||
const schoolId = currentUser.schoolId;
|
||||
async function loadBadges() {
|
||||
try {
|
||||
const progress = await fetchBadgeProgress(schoolId, currentStudentId);
|
||||
setBadgeProgress(progress);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
async function loadEarnedBadges() {
|
||||
try {
|
||||
const data = await fetchStudentBadges(schoolId, currentStudentId);
|
||||
setEarnedBadges(data);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
loadBadges();
|
||||
loadEarnedBadges();
|
||||
}, [currentStudentId, currentUser?.schoolId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -631,6 +727,21 @@ export default function StudentDetailView() {
|
||||
<FileDown className="h-4 w-4 mr-1.5" />
|
||||
{t('student.export_pdf')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20"
|
||||
onClick={() => {
|
||||
if (!currentStudentId) return;
|
||||
const qrData: QRCodeData = { type: 'student', id: currentStudentId, label: studentName };
|
||||
const dataUrl = generateQRCodeSync(qrData, { size: 256 });
|
||||
setQrCodeDataUrl(dataUrl);
|
||||
setQrDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<QrCode className="h-4 w-4 mr-1.5" />
|
||||
{t('qr.show_qr')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -736,6 +847,175 @@ export default function StudentDetailView() {
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Quick Stats Bar + Badge Collection */}
|
||||
<div className="quick-stats-bar">
|
||||
<div className="quick-stat-item">
|
||||
<TrendingUp className="h-3.5 w-3.5 text-emerald-500" />
|
||||
<span className="text-gray-700 dark:text-gray-300">{stats.totalProgressEntries} {t('student_detail.total_entries')}</span>
|
||||
</div>
|
||||
<div className="quick-stat-item">
|
||||
<Award className="h-3.5 w-3.5 text-teal-500" />
|
||||
<span className="text-gray-700 dark:text-gray-300">{stats.averageMastery.toFixed(1)} {t('student_detail.avg_mastery')}</span>
|
||||
</div>
|
||||
<div className="quick-stat-item">
|
||||
<QrCode className="h-3.5 w-3.5 text-violet-500" />
|
||||
<span className="text-gray-700 dark:text-gray-300">{student.externalId || student.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
<div className="quick-stat-item">
|
||||
<Trophy className="h-3.5 w-3.5 text-amber-500" />
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{badgeProgress.filter(b => b.earned).length}/{badgeProgress.length} {t('badges.title')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badge Collection Section */}
|
||||
{badgeProgress.length > 0 && (
|
||||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
|
||||
<Trophy className="h-4 w-4" />
|
||||
</div>
|
||||
{t('badges.collection')}
|
||||
</CardTitle>
|
||||
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 text-xs font-medium">
|
||||
{t('badges.earned_count', { earned: badgeProgress.filter(b => b.earned).length, total: badgeProgress.length })}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{badgeProgress.map((bp) => {
|
||||
const BadgeIcon = (iconMap as Record<string, LucideIcon>)[bp.icon] || Award;
|
||||
return (
|
||||
<TooltipProvider key={bp.badgeId}>
|
||||
<ShadTooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`badge-circle ${bp.earned ? 'badge-earned' : 'badge-unearned'}`}
|
||||
style={bp.earned ? { background: `linear-gradient(135deg, ${bp.color}cc, ${bp.color}88)` } : undefined}
|
||||
onClick={() => { setSelectedBadgeDetail(bp); setBadgeDetailDialogOpen(true); }}
|
||||
>
|
||||
<BadgeIcon className="h-6 w-6 text-white" />
|
||||
{bp.earned && <div className="celebration-ring" />}
|
||||
{!bp.earned && bp.progress > 0 && (
|
||||
<div className="badge-progress">
|
||||
<div className="badge-progress-fill" style={{ width: `${bp.progress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<ShadTooltipContent side="top" className="text-xs">
|
||||
<strong>{bp.name}</strong>
|
||||
{bp.earned ? ` — ${t('badges.earned')}` : ` — ${bp.progress}% ${t('badges.progress')}`}
|
||||
</ShadTooltipContent>
|
||||
</ShadTooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Recent badge awards */}
|
||||
{earnedBadges.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">{t('badges.recent')}</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{earnedBadges.slice(0, 5).map((eb) => {
|
||||
const BadgeIcon = (iconMap as Record<string, LucideIcon>)[eb.badge.icon] || Award;
|
||||
return (
|
||||
<div key={eb.id} className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-amber-50/80 dark:bg-amber-900/10 border border-amber-200/30 dark:border-amber-900/20">
|
||||
<BadgeIcon className="h-4 w-4" style={{ color: eb.badge.color }} />
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">{eb.badge.name}</span>
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500">{new Date(eb.awardedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Badge Detail Dialog */}
|
||||
<Dialog open={badgeDetailDialogOpen} onOpenChange={setBadgeDetailDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{selectedBadgeDetail && (() => {
|
||||
const BadgeIcon = (iconMap as Record<string, LucideIcon>)[selectedBadgeDetail.icon] || Award;
|
||||
return <BadgeIcon className="h-5 w-5" style={{ color: selectedBadgeDetail.color }} />;
|
||||
})()}
|
||||
{selectedBadgeDetail?.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedBadgeDetail?.earned ? t('badges.earned') : t('badges.unearned')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedBadgeDetail && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<div
|
||||
className={`badge-circle ${selectedBadgeDetail.earned ? 'badge-earned' : 'badge-unearned'}`}
|
||||
style={selectedBadgeDetail.earned ? { background: `linear-gradient(135deg, ${selectedBadgeDetail.color}cc, ${selectedBadgeDetail.color}88)` } : undefined}
|
||||
>
|
||||
{(() => { const BadgeIcon = (iconMap as Record<string, LucideIcon>)[selectedBadgeDetail.icon] || Award; return <BadgeIcon className="h-8 w-8 text-white" />; })()}
|
||||
</div>
|
||||
</div>
|
||||
{!selectedBadgeDetail.earned && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">{t('badges.progress')}</span>
|
||||
<span className="font-medium">{selectedBadgeDetail.current}/{selectedBadgeDetail.target}</span>
|
||||
</div>
|
||||
<Progress value={selectedBadgeDetail.progress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium">{t('badges.category')}:</span> {t(`badges.${selectedBadgeDetail.category}`)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* QR Code Dialog */}
|
||||
<Dialog open={qrDialogOpen} onOpenChange={setQrDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<QrCode className="h-5 w-5 text-emerald-600" />
|
||||
{t('qr.student')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{studentName}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="qr-card mx-auto">
|
||||
{qrCodeDataUrl ? (
|
||||
<img src={qrCodeDataUrl} alt={t('qr.title')} className="mx-auto" width={200} height={200} />
|
||||
) : (
|
||||
<div className="w-[200px] h-[200px] mx-auto bg-gray-100 dark:bg-gray-800 rounded-lg flex items-center justify-center">
|
||||
<QrCode className="h-12 w-12 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">{studentName}</p>
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500">{student.externalId || student.id.slice(0, 8)}</p>
|
||||
</div>
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-xl min-h-[44px]"
|
||||
onClick={() => { if (qrCodeDataUrl) downloadQRCode(qrCodeDataUrl, `${studentName}-qr.png`); }}
|
||||
>
|
||||
<FileDown className="h-4 w-4 mr-1.5" />
|
||||
{t('qr.download')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Competence Flower (mini radar) */}
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
|
||||
@@ -2024,6 +2304,561 @@ export default function StudentDetailView() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ─── Transportation Section ─────────────────────────────────── */}
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
|
||||
<Bus className="h-4 w-4" />
|
||||
</div>
|
||||
{t('transport.title')}
|
||||
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 text-xs font-medium">
|
||||
{transports.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 min-h-[44px]"
|
||||
onClick={() => {
|
||||
setTransportEditId(null);
|
||||
setTransportForm({ transportType: 'bus', routeNumber: '', stopName: '', pickupTime: '', dropoffTime: '', driverName: '', driverPhone: '', distanceKm: '', notes: '' });
|
||||
setTransportDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('transport.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{transports.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Bus className="h-8 w-8 text-amber-400 dark:text-amber-500 mx-auto mb-2" />
|
||||
<p className="text-gray-500 dark:text-gray-400">{t('transport.no_transport')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto scrollbar-education">
|
||||
{transports.map((tr, i) => {
|
||||
const typeIcon: Record<string, React.ReactNode> = {
|
||||
bus: <Bus className="h-4 w-4" />,
|
||||
tram: <Train className="h-4 w-4" />,
|
||||
walk: <Footprints className="h-4 w-4" />,
|
||||
car: <Car className="h-4 w-4" />,
|
||||
bike: <Bike className="h-4 w-4" />,
|
||||
other: <Truck className="h-4 w-4" />,
|
||||
};
|
||||
const typeColor: Record<string, string> = {
|
||||
bus: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
tram: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300',
|
||||
walk: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
car: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300',
|
||||
bike: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300',
|
||||
other: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300',
|
||||
};
|
||||
return (
|
||||
<motion.div
|
||||
key={tr.id}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.04 }}
|
||||
className="p-4 rounded-xl bg-gradient-to-r from-amber-50/40 to-transparent dark:from-amber-900/10 dark:to-transparent border border-amber-200/30 dark:border-amber-900/20"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge className={`${typeColor[tr.transportType] || typeColor.other} text-xs`}>{typeIcon[tr.transportType] || typeIcon.other}{t(`transport.${tr.transportType}`)}</Badge>
|
||||
{tr.routeNumber && <Badge variant="outline" className="text-xs">{t('transport.route')}: {tr.routeNumber}</Badge>}
|
||||
{tr.distanceKm != null && <Badge variant="outline" className="text-xs">{t('transport.distance_km', { count: tr.distanceKm })}</Badge>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2 flex-wrap text-xs text-gray-600 dark:text-gray-400">
|
||||
{tr.stopName && <span className="flex items-center gap-1.5"><MapPin className="h-3 w-3 text-amber-500" />{tr.stopName}</span>}
|
||||
{tr.pickupTime && <span className="flex items-center gap-1.5"><Clock className="h-3 w-3 text-emerald-500" />{t('transport.pickup')}: {tr.pickupTime}</span>}
|
||||
{tr.dropoffTime && <span className="flex items-center gap-1.5"><Clock className="h-3 w-3 text-rose-500" />{t('transport.dropoff')}: {tr.dropoffTime}</span>}
|
||||
</div>
|
||||
{(tr.driverName || tr.driverPhone) && (
|
||||
<div className="flex items-center gap-3 mt-2 flex-wrap text-xs text-gray-600 dark:text-gray-400">
|
||||
{tr.driverName && <span className="flex items-center gap-1.5"><User className="h-3 w-3 text-violet-500" />{tr.driverName}</span>}
|
||||
{tr.driverPhone && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Phone className="h-3 w-3 text-amber-500" />{tr.driverPhone}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-lg text-amber-600 hover:text-amber-700 min-h-[44px] min-w-[44px] p-0 h-6 w-6"
|
||||
title={t('transport.quick_call')}
|
||||
onClick={() => toast.success(`${t('transport.driver_phone')}: ${tr.driverPhone}`)}
|
||||
>
|
||||
<PhoneCall className="h-3 w-3" />
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tr.notes && <p className="text-xs text-gray-500 dark:text-gray-400 mt-1 italic">{tr.notes}</p>}
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-lg min-h-[44px] min-w-[44px]"
|
||||
onClick={() => {
|
||||
setTransportEditId(tr.id);
|
||||
setTransportForm({
|
||||
transportType: tr.transportType,
|
||||
routeNumber: tr.routeNumber ?? '',
|
||||
stopName: tr.stopName ?? '',
|
||||
pickupTime: tr.pickupTime ?? '',
|
||||
dropoffTime: tr.dropoffTime ?? '',
|
||||
driverName: tr.driverName ?? '',
|
||||
driverPhone: tr.driverPhone ?? '',
|
||||
distanceKm: tr.distanceKm != null ? String(tr.distanceKm) : '',
|
||||
notes: tr.notes ?? '',
|
||||
});
|
||||
setTransportDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-lg text-red-500 hover:text-red-700 min-h-[44px] min-w-[44px]"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiDelete(`/api/student-transport/${tr.id}`);
|
||||
setTransports((prev) => prev.filter((t) => t.id !== tr.id));
|
||||
toast.success(t('action.delete'));
|
||||
} catch { toast.error(t('error.generic')); }
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ─── Health Records Section ─────────────────────────────────── */}
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-rose-500 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-rose-50/50 to-transparent dark:from-rose-900/10 dark:to-transparent">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
|
||||
<HeartPulse className="h-4 w-4" />
|
||||
</div>
|
||||
{t('health.title')}
|
||||
{healthRecord?.isConfidential && (
|
||||
<Badge className="bg-gray-100 text-gray-600 dark:bg-gray-900/30 dark:text-gray-400 text-xs flex items-center gap-1">
|
||||
<EyeOff className="h-3 w-3" />{t('health.confidential')}
|
||||
</Badge>
|
||||
)}
|
||||
{!healthRecord && <Badge className="bg-gray-100 text-gray-500 dark:bg-gray-900/30 dark:text-gray-400 text-xs">0</Badge>}
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl border-rose-300 dark:border-rose-700 text-rose-700 dark:text-rose-300 min-h-[44px]"
|
||||
onClick={() => {
|
||||
if (healthRecord) {
|
||||
setHealthEditId(healthRecord.id);
|
||||
const parsedAllergies = healthRecord.allergies ? JSON.parse(healthRecord.allergies) : [];
|
||||
const parsedMedications = healthRecord.medications ? JSON.parse(healthRecord.medications) : [];
|
||||
const parsedConditions = healthRecord.conditions ? JSON.parse(healthRecord.conditions) : [];
|
||||
setHealthForm({
|
||||
bloodType: healthRecord.bloodType ?? '',
|
||||
allergies: parsedAllergies.join(', '),
|
||||
medications: parsedMedications.map((m: { name: string; dosage: string; frequency: string }) => `${m.name} (${m.dosage}, ${m.frequency})`).join('; '),
|
||||
conditions: parsedConditions.join(', '),
|
||||
doctorName: healthRecord.doctorName ?? '',
|
||||
doctorPhone: healthRecord.doctorPhone ?? '',
|
||||
insuranceNumber: healthRecord.insuranceNumber ?? '',
|
||||
insuranceProvider: healthRecord.insuranceProvider ?? '',
|
||||
lastCheckup: healthRecord.lastCheckup ? new Date(healthRecord.lastCheckup).toISOString().split('T')[0] : '',
|
||||
notes: healthRecord.notes ?? '',
|
||||
isConfidential: healthRecord.isConfidential,
|
||||
});
|
||||
} else {
|
||||
setHealthEditId(null);
|
||||
setHealthForm({ bloodType: '', allergies: '', medications: '', conditions: '', doctorName: '', doctorPhone: '', insuranceNumber: '', insuranceProvider: '', lastCheckup: '', notes: '', isConfidential: true });
|
||||
}
|
||||
setHealthDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{healthRecord ? t('health.edit') : t('health.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!healthRecord ? (
|
||||
<div className="text-center py-8">
|
||||
<HeartPulse className="h-8 w-8 text-rose-400 dark:text-rose-500 mx-auto mb-2" />
|
||||
<p className="text-gray-500 dark:text-gray-400">{t('health.no_record')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* Blood Type */}
|
||||
{healthRecord.bloodType && (
|
||||
<div className="p-3 rounded-xl bg-gradient-to-r from-rose-50/40 to-transparent dark:from-rose-900/10 dark:to-transparent border border-rose-200/30 dark:border-rose-900/20">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Droplets className="h-3.5 w-3.5 text-rose-500" />
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">{t('health.blood_type')}</span>
|
||||
</div>
|
||||
<Badge className="bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 text-sm font-bold">{healthRecord.bloodType}</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Last Checkup */}
|
||||
{healthRecord.lastCheckup && (
|
||||
<div className="p-3 rounded-xl bg-gradient-to-r from-teal-50/40 to-transparent dark:from-teal-900/10 dark:to-transparent border border-teal-200/30 dark:border-teal-900/20">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Stethoscope className="h-3.5 w-3.5 text-teal-500" />
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">{t('health.last_checkup')}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{new Date(healthRecord.lastCheckup).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Allergies */}
|
||||
{healthRecord.allergies && (() => {
|
||||
const allergies: string[] = JSON.parse(healthRecord.allergies);
|
||||
if (allergies.length === 0) return null;
|
||||
return (
|
||||
<div className="p-3 rounded-xl bg-gradient-to-r from-amber-50/40 to-transparent dark:from-amber-900/10 dark:to-transparent border border-amber-200/30 dark:border-amber-900/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-amber-500" />
|
||||
<span className="text-xs font-semibold text-amber-700 dark:text-amber-300">{t('health.allergies')}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{allergies.map((a, i) => (
|
||||
<Badge key={i} className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 text-xs">{a}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Medications */}
|
||||
{healthRecord.medications && (() => {
|
||||
const medications: Array<{ name: string; dosage: string; frequency: string }> = JSON.parse(healthRecord.medications);
|
||||
if (medications.length === 0) return null;
|
||||
return (
|
||||
<div className="p-3 rounded-xl bg-gradient-to-r from-violet-50/40 to-transparent dark:from-violet-900/10 dark:to-transparent border border-violet-200/30 dark:border-violet-900/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Pill className="h-3.5 w-3.5 text-violet-500" />
|
||||
<span className="text-xs font-semibold text-violet-700 dark:text-violet-300">{t('health.medications')}</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{medications.map((m, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
<Badge className="bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 text-xs">{m.name}</Badge>
|
||||
{m.dosage && <span className="text-gray-500 dark:text-gray-400">{t('health.dosage')}: {m.dosage}</span>}
|
||||
{m.frequency && <span className="text-gray-500 dark:text-gray-400">{t('health.frequency')}: {m.frequency}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Conditions */}
|
||||
{healthRecord.conditions && (() => {
|
||||
const conditions: string[] = JSON.parse(healthRecord.conditions);
|
||||
if (conditions.length === 0) return null;
|
||||
return (
|
||||
<div className="p-3 rounded-xl bg-gradient-to-r from-rose-50/40 to-transparent dark:from-rose-900/10 dark:to-transparent border border-rose-200/30 dark:border-rose-900/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Activity className="h-3.5 w-3.5 text-rose-500" />
|
||||
<span className="text-xs font-semibold text-rose-700 dark:text-rose-300">{t('health.conditions')}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{conditions.map((c, i) => (
|
||||
<Badge key={i} className="bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 text-xs">{c}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Doctor & Insurance */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{(healthRecord.doctorName || healthRecord.doctorPhone) && (
|
||||
<div className="p-3 rounded-xl bg-gradient-to-r from-emerald-50/40 to-transparent dark:from-emerald-900/10 dark:to-transparent border border-emerald-200/30 dark:border-emerald-900/20">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Stethoscope className="h-3.5 w-3.5 text-emerald-500" />
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">{t('health.doctor')}</span>
|
||||
</div>
|
||||
{healthRecord.doctorName && <p className="text-sm font-medium text-gray-900 dark:text-gray-100">{healthRecord.doctorName}</p>}
|
||||
{healthRecord.doctorPhone && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
<Phone className="h-3 w-3 text-emerald-500" />{healthRecord.doctorPhone}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-lg text-emerald-600 hover:text-emerald-700 min-h-[44px] min-w-[44px] p-0 h-6 w-6"
|
||||
onClick={() => toast.success(`${t('health.doctor_phone')}: ${healthRecord.doctorPhone}`)}
|
||||
>
|
||||
<PhoneCall className="h-3 w-3" />
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(healthRecord.insuranceNumber || healthRecord.insuranceProvider) && (
|
||||
<div className="p-3 rounded-xl bg-gradient-to-r from-teal-50/40 to-transparent dark:from-teal-900/10 dark:to-transparent border border-teal-200/30 dark:border-teal-900/20">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Shield className="h-3.5 w-3.5 text-teal-500" />
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">{t('health.insurance')}</span>
|
||||
</div>
|
||||
{healthRecord.insuranceProvider && <p className="text-sm font-medium text-gray-900 dark:text-gray-100">{healthRecord.insuranceProvider}</p>}
|
||||
{healthRecord.insuranceNumber && <p className="text-xs text-gray-500 dark:text-gray-400">{healthRecord.insuranceNumber}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{healthRecord.notes && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 italic p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/20">{healthRecord.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ─── Transportation Dialog ──────────────────────────────────── */}
|
||||
<Dialog open={transportDialogOpen} onOpenChange={setTransportDialogOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{transportEditId ? t('transport.edit') : t('transport.add')}</DialogTitle>
|
||||
<DialogDescription>{t('transport.title')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('transport.type')}</Label>
|
||||
<Select value={transportForm.transportType} onValueChange={(v) => setTransportForm((f) => ({ ...f, transportType: v }))}>
|
||||
<SelectTrigger className="h-10 rounded-lg mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bus">{t('transport.bus')}</SelectItem>
|
||||
<SelectItem value="tram">{t('transport.tram')}</SelectItem>
|
||||
<SelectItem value="walk">{t('transport.walk')}</SelectItem>
|
||||
<SelectItem value="car">{t('transport.car')}</SelectItem>
|
||||
<SelectItem value="bike">{t('transport.bike')}</SelectItem>
|
||||
<SelectItem value="other">{t('transport.other')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('transport.route_number')}</Label>
|
||||
<Input value={transportForm.routeNumber} onChange={(e) => setTransportForm((f) => ({ ...f, routeNumber: e.target.value }))} className="mt-1 rounded-lg" placeholder="42" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('transport.stop_name')}</Label>
|
||||
<Input value={transportForm.stopName} onChange={(e) => setTransportForm((f) => ({ ...f, stopName: e.target.value }))} className="mt-1 rounded-lg" placeholder={t('transport.stop_name')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('transport.pickup_time')}</Label>
|
||||
<Input type="time" value={transportForm.pickupTime} onChange={(e) => setTransportForm((f) => ({ ...f, pickupTime: e.target.value }))} className="mt-1 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('transport.dropoff_time')}</Label>
|
||||
<Input type="time" value={transportForm.dropoffTime} onChange={(e) => setTransportForm((f) => ({ ...f, dropoffTime: e.target.value }))} className="mt-1 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('transport.driver_name')}</Label>
|
||||
<Input value={transportForm.driverName} onChange={(e) => setTransportForm((f) => ({ ...f, driverName: e.target.value }))} className="mt-1 rounded-lg" placeholder={t('transport.driver_name')} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('transport.driver_phone')}</Label>
|
||||
<Input value={transportForm.driverPhone} onChange={(e) => setTransportForm((f) => ({ ...f, driverPhone: e.target.value }))} className="mt-1 rounded-lg" placeholder="+49 123 456789" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('transport.distance_km_field')}</Label>
|
||||
<Input type="number" step="0.1" value={transportForm.distanceKm} onChange={(e) => setTransportForm((f) => ({ ...f, distanceKm: e.target.value }))} className="mt-1 rounded-lg" placeholder="2.5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('transport.notes')}</Label>
|
||||
<Textarea value={transportForm.notes} onChange={(e) => setTransportForm((f) => ({ ...f, notes: e.target.value }))} className="mt-1 rounded-lg" rows={2} placeholder={t('transport.notes')} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="rounded-xl min-h-[44px]" onClick={() => setTransportDialogOpen(false)}>{t('action.cancel')}</Button>
|
||||
<Button
|
||||
className="rounded-xl bg-amber-600 hover:bg-amber-700 text-white min-h-[44px]"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const payload = {
|
||||
schoolId: currentUser?.schoolId,
|
||||
studentId: currentStudentId,
|
||||
transportType: transportForm.transportType,
|
||||
routeNumber: transportForm.routeNumber || null,
|
||||
stopName: transportForm.stopName || null,
|
||||
pickupTime: transportForm.pickupTime || null,
|
||||
dropoffTime: transportForm.dropoffTime || null,
|
||||
driverName: transportForm.driverName || null,
|
||||
driverPhone: transportForm.driverPhone || null,
|
||||
distanceKm: transportForm.distanceKm ? parseFloat(transportForm.distanceKm) : null,
|
||||
notes: transportForm.notes || null,
|
||||
};
|
||||
if (transportEditId) {
|
||||
const updated = await apiPut<TransportData>(`/api/student-transport/${transportEditId}`, payload);
|
||||
setTransports((prev) => prev.map((t) => t.id === transportEditId ? updated : t));
|
||||
} else {
|
||||
const created = await apiPost<TransportData>('/api/student-transport', payload);
|
||||
setTransports((prev) => [created, ...prev]);
|
||||
}
|
||||
setTransportDialogOpen(false);
|
||||
toast.success(transportEditId ? t('action.save') : t('action.create'));
|
||||
} catch { toast.error(t('error.generic')); }
|
||||
}}
|
||||
>
|
||||
{transportEditId ? t('action.save') : t('action.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ─── Health Record Dialog ───────────────────────────────────── */}
|
||||
<Dialog open={healthDialogOpen} onOpenChange={setHealthDialogOpen}>
|
||||
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{healthEditId ? t('health.edit') : t('health.add')}</DialogTitle>
|
||||
<DialogDescription>{t('health.title')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('health.blood_type')}</Label>
|
||||
<Select value={healthForm.bloodType || '_none'} onValueChange={(v) => setHealthForm((f) => ({ ...f, bloodType: v === '_none' ? '' : v }))}>
|
||||
<SelectTrigger className="h-10 rounded-lg mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">—</SelectItem>
|
||||
<SelectItem value="A+">A+</SelectItem>
|
||||
<SelectItem value="A-">A-</SelectItem>
|
||||
<SelectItem value="B+">B+</SelectItem>
|
||||
<SelectItem value="B-">B-</SelectItem>
|
||||
<SelectItem value="O+">O+</SelectItem>
|
||||
<SelectItem value="O-">O-</SelectItem>
|
||||
<SelectItem value="AB+">AB+</SelectItem>
|
||||
<SelectItem value="AB-">AB-</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('health.last_checkup_date')}</Label>
|
||||
<Input type="date" value={healthForm.lastCheckup} onChange={(e) => setHealthForm((f) => ({ ...f, lastCheckup: e.target.value }))} className="mt-1 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('health.allergies')} <span className="text-gray-400 font-normal">({t('health.add_allergy')}: kommagetrennt)</span></Label>
|
||||
<Input value={healthForm.allergies} onChange={(e) => setHealthForm((f) => ({ ...f, allergies: e.target.value }))} className="mt-1 rounded-lg" placeholder="Peanuts, Penicillin, ..." />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('health.conditions')} <span className="text-gray-400 font-normal">({t('health.add_condition')}: kommagetrennt)</span></Label>
|
||||
<Input value={healthForm.conditions} onChange={(e) => setHealthForm((f) => ({ ...f, conditions: e.target.value }))} className="mt-1 rounded-lg" placeholder="Asthma, Diabetes, ..." />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('health.medications')} <span className="text-gray-400 font-normal">(Name, Dosierung, Häufigkeit; pro Zeile)</span></Label>
|
||||
<Textarea value={healthForm.medications} onChange={(e) => setHealthForm((f) => ({ ...f, medications: e.target.value }))} className="mt-1 rounded-lg" rows={3} placeholder="Aspirin, 500mg, 1x täglich Insulin, 10IE, 2x täglich" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('health.doctor_name')}</Label>
|
||||
<Input value={healthForm.doctorName} onChange={(e) => setHealthForm((f) => ({ ...f, doctorName: e.target.value }))} className="mt-1 rounded-lg" placeholder="Dr. Schmidt" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('health.doctor_phone')}</Label>
|
||||
<Input value={healthForm.doctorPhone} onChange={(e) => setHealthForm((f) => ({ ...f, doctorPhone: e.target.value }))} className="mt-1 rounded-lg" placeholder="+49 123 456789" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('health.insurance_provider')}</Label>
|
||||
<Input value={healthForm.insuranceProvider} onChange={(e) => setHealthForm((f) => ({ ...f, insuranceProvider: e.target.value }))} className="mt-1 rounded-lg" placeholder="AOK" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('health.insurance_number')}</Label>
|
||||
<Input value={healthForm.insuranceNumber} onChange={(e) => setHealthForm((f) => ({ ...f, insuranceNumber: e.target.value }))} className="mt-1 rounded-lg" placeholder="X123456789" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium">{t('health.notes')}</Label>
|
||||
<Textarea value={healthForm.notes} onChange={(e) => setHealthForm((f) => ({ ...f, notes: e.target.value }))} className="mt-1 rounded-lg" rows={2} placeholder={t('health.notes')} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={healthForm.isConfidential}
|
||||
onChange={(e) => setHealthForm((f) => ({ ...f, isConfidential: e.target.checked }))}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<Label className="text-xs font-medium flex items-center gap-1">{t('health.confidential_toggle')} <EyeOff className="h-3 w-3" /></Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="rounded-xl min-h-[44px]" onClick={() => setHealthDialogOpen(false)}>{t('action.cancel')}</Button>
|
||||
<Button
|
||||
className="rounded-xl bg-rose-600 hover:bg-rose-700 text-white min-h-[44px]"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const allergiesArr = healthForm.allergies ? healthForm.allergies.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
const conditionsArr = healthForm.conditions ? healthForm.conditions.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
const medicationsArr = healthForm.medications
|
||||
? healthForm.medications.split('\n').map((line) => {
|
||||
const parts = line.split(',').map((s) => s.trim());
|
||||
return { name: parts[0] || '', dosage: parts[1] || '', frequency: parts[2] || '' };
|
||||
}).filter((m) => m.name)
|
||||
: [];
|
||||
const payload = {
|
||||
schoolId: currentUser?.schoolId,
|
||||
studentId: currentStudentId,
|
||||
bloodType: healthForm.bloodType || null,
|
||||
allergies: allergiesArr.length > 0 ? allergiesArr : null,
|
||||
medications: medicationsArr.length > 0 ? medicationsArr : null,
|
||||
conditions: conditionsArr.length > 0 ? conditionsArr : null,
|
||||
doctorName: healthForm.doctorName || null,
|
||||
doctorPhone: healthForm.doctorPhone || null,
|
||||
insuranceNumber: healthForm.insuranceNumber || null,
|
||||
insuranceProvider: healthForm.insuranceProvider || null,
|
||||
lastCheckup: healthForm.lastCheckup || null,
|
||||
notes: healthForm.notes || null,
|
||||
isConfidential: healthForm.isConfidential,
|
||||
};
|
||||
if (healthEditId) {
|
||||
const updated = await apiPut<HealthRecordData>(`/api/health-records/${healthEditId}`, payload);
|
||||
setHealthRecord(updated);
|
||||
} else {
|
||||
const created = await apiPost<HealthRecordData>('/api/health-records', payload);
|
||||
setHealthRecord(created);
|
||||
}
|
||||
setHealthDialogOpen(false);
|
||||
toast.success(healthEditId ? t('action.save') : t('action.create'));
|
||||
} catch { toast.error(t('error.generic')); }
|
||||
}}
|
||||
>
|
||||
{healthEditId ? t('action.save') : t('action.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ─── Self-Assessment Dialog ───────────────────────────────── */}
|
||||
<Dialog open={saDialogOpen} onOpenChange={setSaDialogOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
|
||||
+105
@@ -2889,3 +2889,108 @@ export function fetchDistrictSchools(districtId: string): Promise<Array<{
|
||||
export function assignSchoolToDistrict(districtId: string, schoolId: string): Promise<{ success: boolean }> {
|
||||
return apiPost<{ success: boolean }>(`/api/districts/${districtId}/schools`, { schoolId });
|
||||
}
|
||||
|
||||
/* ── Badges ──────────────────────────────────────────────────────── */
|
||||
|
||||
export interface BadgeData {
|
||||
id: string;
|
||||
schoolId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string;
|
||||
color: string;
|
||||
category: string;
|
||||
requirementType: string;
|
||||
requirementValue: number | null;
|
||||
isAuto: boolean;
|
||||
isDemo: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
_count?: { studentBadges: number };
|
||||
}
|
||||
|
||||
export interface StudentBadgeData {
|
||||
id: string;
|
||||
schoolId: string;
|
||||
studentId: string;
|
||||
badgeId: string;
|
||||
awardedAt: string;
|
||||
awardedBy: string | null;
|
||||
notes: string | null;
|
||||
badge: BadgeData;
|
||||
student: { id: string; firstName: string; lastName: string };
|
||||
awardedByUser?: { id: string; firstName: string; lastName: string } | null;
|
||||
}
|
||||
|
||||
export interface BadgeProgressData {
|
||||
badgeId: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
category: string;
|
||||
earned: boolean;
|
||||
progress: number;
|
||||
current: number;
|
||||
target: number;
|
||||
}
|
||||
|
||||
export function fetchBadges(schoolId: string, category?: string): Promise<BadgeData[]> {
|
||||
const params = new URLSearchParams({ schoolId });
|
||||
if (category) params.set('category', category);
|
||||
return apiGet<BadgeData[]>(`/api/badges?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function createBadge(data: {
|
||||
schoolId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon: string;
|
||||
color?: string;
|
||||
category: string;
|
||||
requirementType: string;
|
||||
requirementValue?: number;
|
||||
isAuto?: boolean;
|
||||
}): Promise<BadgeData> {
|
||||
return apiPost<BadgeData>('/api/badges', data);
|
||||
}
|
||||
|
||||
export function updateBadge(id: string, data: Partial<BadgeData>): Promise<BadgeData> {
|
||||
return apiPut<BadgeData>(`/api/badges/${id}`, data);
|
||||
}
|
||||
|
||||
export function deleteBadge(id: string): Promise<{ success: boolean }> {
|
||||
return apiDelete<{ success: boolean }>(`/api/badges/${id}`);
|
||||
}
|
||||
|
||||
export function fetchStudentBadges(schoolId: string, studentId?: string, badgeId?: string): Promise<StudentBadgeData[]> {
|
||||
const params = new URLSearchParams({ schoolId });
|
||||
if (studentId) params.set('studentId', studentId);
|
||||
if (badgeId) params.set('badgeId', badgeId);
|
||||
return apiGet<StudentBadgeData[]>(`/api/student-badges?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function awardBadgeToStudent(data: {
|
||||
schoolId: string;
|
||||
studentId: string;
|
||||
badgeId: string;
|
||||
notes?: string;
|
||||
}): Promise<StudentBadgeData> {
|
||||
return apiPost<StudentBadgeData>('/api/student-badges', data);
|
||||
}
|
||||
|
||||
export function removeStudentBadge(id: string): Promise<{ success: boolean }> {
|
||||
return apiDelete<{ success: boolean }>(`/api/student-badges/${id}`);
|
||||
}
|
||||
|
||||
export function fetchBadgeProgress(schoolId: string, studentId: string): Promise<BadgeProgressData[]> {
|
||||
const params = new URLSearchParams({ schoolId, studentId });
|
||||
return apiGet<BadgeProgressData[]>(`/api/badge-progress?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function checkAndAwardBadges(schoolId: string, studentId: string): Promise<string[]> {
|
||||
return apiPost<string[]>('/api/badge-check', { schoolId, studentId });
|
||||
}
|
||||
|
||||
export function seedBadges(schoolId: string): Promise<{ success: boolean }> {
|
||||
return apiPost<{ success: boolean }>('/api/badge-seed', { schoolId });
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
// CompetenceTrack — Badge Auto-Award Check Utility
|
||||
// Checks if a student meets requirements for auto-awarded badges
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// Default badge definitions that get seeded when a school is created
|
||||
export const DEFAULT_BADGES = [
|
||||
{
|
||||
name: 'Perfect Attendance',
|
||||
description: '100% attendance for 30 consecutive days',
|
||||
icon: 'CalendarCheck',
|
||||
color: '#10b981',
|
||||
category: 'attendance',
|
||||
requirementType: 'attendance_rate',
|
||||
requirementValue: 30,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Competency Master',
|
||||
description: 'Achieved mastery level 5+ in a competency',
|
||||
icon: 'Award',
|
||||
color: '#f59e0b',
|
||||
category: 'competency',
|
||||
requirementType: 'mastery_level',
|
||||
requirementValue: 5,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Progress Pioneer',
|
||||
description: '10+ progress entries in a month',
|
||||
icon: 'TrendingUp',
|
||||
color: '#3b82f6',
|
||||
category: 'achievement',
|
||||
requirementType: 'progress_entries',
|
||||
requirementValue: 10,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Behavior Star',
|
||||
description: '5+ positive behavior entries',
|
||||
icon: 'Star',
|
||||
color: '#eab308',
|
||||
category: 'behavior',
|
||||
requirementType: 'behavior_count',
|
||||
requirementValue: 5,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Notebook Champion',
|
||||
description: '10+ notebook pages created',
|
||||
icon: 'BookOpen',
|
||||
color: '#8b5cf6',
|
||||
category: 'achievement',
|
||||
requirementType: 'custom',
|
||||
requirementValue: 10,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Drawing Artist',
|
||||
description: '5+ drawings created',
|
||||
icon: 'Pencil',
|
||||
color: '#ec4899',
|
||||
category: 'achievement',
|
||||
requirementType: 'custom',
|
||||
requirementValue: 5,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Homework Hero',
|
||||
description: '10+ homework submissions on time',
|
||||
icon: 'ClipboardCheck',
|
||||
color: '#06b6d4',
|
||||
category: 'achievement',
|
||||
requirementType: 'custom',
|
||||
requirementValue: 10,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Team Player',
|
||||
description: '3+ peer assessments given',
|
||||
icon: 'Users',
|
||||
color: '#14b8a6',
|
||||
category: 'competency',
|
||||
requirementType: 'custom',
|
||||
requirementValue: 3,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Goal Achiever',
|
||||
description: '3+ learning goals completed',
|
||||
icon: 'Target',
|
||||
color: '#f97316',
|
||||
category: 'milestone',
|
||||
requirementType: 'custom',
|
||||
requirementValue: 3,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Eco Warrior',
|
||||
description: 'Environmental badge for using digital notebooks',
|
||||
icon: 'Leaf',
|
||||
color: '#22c55e',
|
||||
category: 'milestone',
|
||||
requirementType: 'custom',
|
||||
requirementValue: 1,
|
||||
isAuto: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Seed default badges for a school if they don't exist yet
|
||||
*/
|
||||
export async function seedDefaultBadges(schoolId: string): Promise<void> {
|
||||
const existing = await db.badge.findMany({ where: { schoolId, deletedAt: null } });
|
||||
if (existing.length > 0) return; // Already seeded
|
||||
|
||||
for (const badge of DEFAULT_BADGES) {
|
||||
await db.badge.create({
|
||||
data: {
|
||||
schoolId,
|
||||
name: badge.name,
|
||||
description: badge.description,
|
||||
icon: badge.icon,
|
||||
color: badge.color,
|
||||
category: badge.category,
|
||||
requirementType: badge.requirementType,
|
||||
requirementValue: badge.requirementValue,
|
||||
isAuto: badge.isAuto,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and auto-award badges for a student.
|
||||
* Returns the list of newly awarded badge IDs.
|
||||
*/
|
||||
export async function checkAndAwardBadges(
|
||||
schoolId: string,
|
||||
studentId: string
|
||||
): Promise<string[]> {
|
||||
const awardedBadgeIds: string[] = [];
|
||||
|
||||
// Get all auto-award badges for the school
|
||||
const autoBadges = await db.badge.findMany({
|
||||
where: { schoolId, isAuto: true, deletedAt: null },
|
||||
});
|
||||
|
||||
// Get already earned badges for this student
|
||||
const earnedBadges = await db.studentBadge.findMany({
|
||||
where: { studentId, schoolId },
|
||||
select: { badgeId: true },
|
||||
});
|
||||
const earnedBadgeIdSet = new Set(earnedBadges.map(eb => eb.badgeId));
|
||||
|
||||
for (const badge of autoBadges) {
|
||||
// Skip if already earned
|
||||
if (earnedBadgeIdSet.has(badge.id)) continue;
|
||||
|
||||
const met = await checkBadgeRequirement(badge, schoolId, studentId);
|
||||
if (met) {
|
||||
await db.studentBadge.create({
|
||||
data: {
|
||||
schoolId,
|
||||
studentId,
|
||||
badgeId: badge.id,
|
||||
awardedBy: null, // Auto-awarded
|
||||
},
|
||||
});
|
||||
awardedBadgeIds.push(badge.id);
|
||||
}
|
||||
}
|
||||
|
||||
return awardedBadgeIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific badge requirement is met for a student
|
||||
*/
|
||||
async function checkBadgeRequirement(
|
||||
badge: {
|
||||
id: string;
|
||||
requirementType: string;
|
||||
requirementValue: number | null;
|
||||
},
|
||||
schoolId: string,
|
||||
studentId: string
|
||||
): Promise<boolean> {
|
||||
const threshold = badge.requirementValue ?? 0;
|
||||
if (threshold === 0) return false;
|
||||
|
||||
switch (badge.requirementType) {
|
||||
case 'attendance_rate': {
|
||||
// Check if student has 30 consecutive days of attendance
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - threshold);
|
||||
const attendanceRecords = await db.attendanceRecord.findMany({
|
||||
where: {
|
||||
studentId,
|
||||
schoolId,
|
||||
status: 'PRESENT',
|
||||
session: { date: { gte: thirtyDaysAgo } },
|
||||
},
|
||||
include: { session: { select: { date: true } } },
|
||||
});
|
||||
return attendanceRecords.length >= threshold;
|
||||
}
|
||||
|
||||
case 'mastery_level': {
|
||||
// Check if student has any competency at mastery level >= threshold
|
||||
const progressEntries = await db.learningProgressEntry.findMany({
|
||||
where: {
|
||||
studentId,
|
||||
schoolId,
|
||||
masteryLevelValue: { gte: threshold },
|
||||
},
|
||||
});
|
||||
return progressEntries.length > 0;
|
||||
}
|
||||
|
||||
case 'progress_entries': {
|
||||
// Check if student has threshold+ progress entries in the last month
|
||||
const oneMonthAgo = new Date();
|
||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||
const count = await db.learningProgressEntry.count({
|
||||
where: {
|
||||
studentId,
|
||||
schoolId,
|
||||
date: { gte: oneMonthAgo },
|
||||
},
|
||||
});
|
||||
return count >= threshold;
|
||||
}
|
||||
|
||||
case 'behavior_count': {
|
||||
// Check if student has threshold+ positive behavior entries
|
||||
const count = await db.behaviorIncident.count({
|
||||
where: {
|
||||
studentId,
|
||||
schoolId,
|
||||
category: { valence: 'positive' },
|
||||
},
|
||||
});
|
||||
return count >= threshold;
|
||||
}
|
||||
|
||||
case 'custom': {
|
||||
// For custom badges, check based on badge name
|
||||
const badgeRecord = await db.badge.findUnique({ where: { id: badge.id } });
|
||||
if (!badgeRecord) return false;
|
||||
|
||||
switch (badgeRecord.name) {
|
||||
case 'Notebook Champion': {
|
||||
const count = await db.notebookPage.count({
|
||||
where: {
|
||||
notebook: { schoolId },
|
||||
notebook: { createdById: studentId },
|
||||
},
|
||||
});
|
||||
return count >= threshold;
|
||||
}
|
||||
case 'Drawing Artist': {
|
||||
const count = await db.drawing.count({
|
||||
where: { schoolId, createdById: studentId },
|
||||
});
|
||||
return count >= threshold;
|
||||
}
|
||||
case 'Homework Hero': {
|
||||
const count = await db.homeworkSubmission.count({
|
||||
where: {
|
||||
studentId,
|
||||
homework: { schoolId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
});
|
||||
return count >= threshold;
|
||||
}
|
||||
case 'Team Player': {
|
||||
const count = await db.peerAssessment.count({
|
||||
where: {
|
||||
assessorId: studentId,
|
||||
schoolId,
|
||||
},
|
||||
});
|
||||
return count >= threshold;
|
||||
}
|
||||
case 'Goal Achiever': {
|
||||
const count = await db.learningGoal.count({
|
||||
where: {
|
||||
studentId,
|
||||
schoolId,
|
||||
status: 'COMPLETED',
|
||||
},
|
||||
});
|
||||
return count >= threshold;
|
||||
}
|
||||
case 'Eco Warrior': {
|
||||
// Awarded if student has at least 1 notebook
|
||||
const count = await db.notebook.count({
|
||||
where: { schoolId, createdById: studentId },
|
||||
});
|
||||
return count >= threshold;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge progress for a student (how close they are to earning each badge)
|
||||
*/
|
||||
export async function getBadgeProgress(
|
||||
schoolId: string,
|
||||
studentId: string
|
||||
): Promise<Array<{
|
||||
badgeId: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
category: string;
|
||||
earned: boolean;
|
||||
progress: number; // 0-100
|
||||
current: number;
|
||||
target: number;
|
||||
}>> {
|
||||
const badges = await db.badge.findMany({
|
||||
where: { schoolId, deletedAt: null },
|
||||
orderBy: [{ category: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
|
||||
const earnedBadges = await db.studentBadge.findMany({
|
||||
where: { studentId, schoolId },
|
||||
select: { badgeId: true, awardedAt: true },
|
||||
});
|
||||
const earnedBadgeMap = new Map(earnedBadges.map(eb => [eb.badgeId, eb.awardedAt]));
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const badge of badges) {
|
||||
const earned = earnedBadgeMap.has(badge.id);
|
||||
const target = badge.requirementValue ?? 1;
|
||||
let current = 0;
|
||||
|
||||
if (!earned) {
|
||||
current = await getCurrentProgress(badge, schoolId, studentId);
|
||||
} else {
|
||||
current = target;
|
||||
}
|
||||
|
||||
results.push({
|
||||
badgeId: badge.id,
|
||||
name: badge.name,
|
||||
icon: badge.icon,
|
||||
color: badge.color,
|
||||
category: badge.category,
|
||||
earned,
|
||||
progress: Math.min(100, Math.round((current / target) * 100)),
|
||||
current,
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function getCurrentProgress(
|
||||
badge: { id: string; requirementType: string; requirementValue: number | null; name: string },
|
||||
schoolId: string,
|
||||
studentId: string
|
||||
): Promise<number> {
|
||||
const threshold = badge.requirementValue ?? 1;
|
||||
|
||||
switch (badge.requirementType) {
|
||||
case 'attendance_rate': {
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - threshold);
|
||||
return db.attendanceRecord.count({
|
||||
where: {
|
||||
studentId,
|
||||
schoolId,
|
||||
status: 'PRESENT',
|
||||
session: { date: { gte: thirtyDaysAgo } },
|
||||
},
|
||||
});
|
||||
}
|
||||
case 'mastery_level': {
|
||||
const entries = await db.learningProgressEntry.findMany({
|
||||
where: { studentId, schoolId },
|
||||
orderBy: { masteryLevelValue: 'desc' },
|
||||
take: 1,
|
||||
});
|
||||
return entries.length > 0 ? entries[0].masteryLevelValue : 0;
|
||||
}
|
||||
case 'progress_entries': {
|
||||
const oneMonthAgo = new Date();
|
||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||
return db.learningProgressEntry.count({
|
||||
where: { studentId, schoolId, date: { gte: oneMonthAgo } },
|
||||
});
|
||||
}
|
||||
case 'behavior_count': {
|
||||
return db.behaviorIncident.count({
|
||||
where: { studentId, schoolId, category: { valence: 'positive' } },
|
||||
});
|
||||
}
|
||||
case 'custom': {
|
||||
switch (badge.name) {
|
||||
case 'Notebook Champion':
|
||||
return db.notebookPage.count({ where: { notebook: { schoolId, createdById: studentId } } });
|
||||
case 'Drawing Artist':
|
||||
return db.drawing.count({ where: { schoolId, createdById: studentId } });
|
||||
case 'Homework Hero':
|
||||
return db.homeworkSubmission.count({ where: { studentId, homework: { schoolId }, status: 'SUBMITTED' } });
|
||||
case 'Team Player':
|
||||
return db.peerAssessment.count({ where: { assessorId: studentId, schoolId } });
|
||||
case 'Goal Achiever':
|
||||
return db.learningGoal.count({ where: { studentId, schoolId, status: 'COMPLETED' } });
|
||||
case 'Eco Warrior':
|
||||
return db.notebook.count({ where: { schoolId, createdById: studentId } });
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
+290
@@ -3084,6 +3084,151 @@ const de: TranslationDict = {
|
||||
'onboarding.previous': 'Zurück',
|
||||
'onboarding.dont_show_again': 'Nicht mehr anzeigen',
|
||||
'onboarding.restart_tour': 'Tour erneut starten',
|
||||
|
||||
// Student Transportation
|
||||
'transport.title': 'Verkehrsmittel',
|
||||
'transport.type': 'Verkehrsmitteltyp',
|
||||
'transport.bus': 'Bus',
|
||||
'transport.tram': 'Straßenbahn',
|
||||
'transport.walk': 'Zu Fuß',
|
||||
'transport.car': 'Auto',
|
||||
'transport.bike': 'Fahrrad',
|
||||
'transport.other': 'Sonstiges',
|
||||
'transport.route': 'Linie',
|
||||
'transport.stop': 'Haltestelle',
|
||||
'transport.pickup': 'Abholung',
|
||||
'transport.dropoff': 'Rückfahrt',
|
||||
'transport.driver': 'Fahrer',
|
||||
'transport.distance': 'Entfernung',
|
||||
'transport.distance_km': '{count} km',
|
||||
'transport.notes': 'Hinweise',
|
||||
'transport.add': 'Verkehrsmittel hinzufügen',
|
||||
'transport.edit': 'Verkehrsmittel bearbeiten',
|
||||
'transport.no_transport': 'Keine Verkehrsmittel gespeichert',
|
||||
'transport.route_number': 'Liniennummer',
|
||||
'transport.stop_name': 'Haltestellenname',
|
||||
'transport.pickup_time': 'Abholzeit',
|
||||
'transport.dropoff_time': 'Rückfahrtzeit',
|
||||
'transport.driver_name': 'Fahrername',
|
||||
'transport.driver_phone': 'Fahrertelefon',
|
||||
'transport.distance_km_field': 'Entfernung (km)',
|
||||
'transport.class_overview': 'Klassenübersicht Verkehrsmittel',
|
||||
'transport.quick_call': 'Anrufen',
|
||||
|
||||
// Health Records
|
||||
'health.title': 'Gesundheitsdaten',
|
||||
'health.blood_type': 'Blutgruppe',
|
||||
'health.allergies': 'Allergien',
|
||||
'health.medications': 'Medikamente',
|
||||
'health.conditions': 'Erkrankungen',
|
||||
'health.doctor': 'Arzt',
|
||||
'health.insurance': 'Versicherung',
|
||||
'health.confidential': 'Vertraulich',
|
||||
'health.last_checkup': 'Letzte Untersuchung',
|
||||
'health.warning': 'Gesundheitshinweis',
|
||||
'health.add': 'Gesundheitsdaten hinzufügen',
|
||||
'health.edit': 'Gesundheitsdaten bearbeiten',
|
||||
'health.no_record': 'Keine Gesundheitsdaten vorhanden',
|
||||
'health.doctor_name': 'Arztname',
|
||||
'health.doctor_phone': 'Arzttelefon',
|
||||
'health.insurance_number': 'Versicherungsnummer',
|
||||
'health.insurance_provider': 'Versicherungsträger',
|
||||
'health.last_checkup_date': 'Datum letzte Untersuchung',
|
||||
'health.notes': 'Gesundheitshinweise',
|
||||
'health.confidential_toggle': 'Vertraulichkeitsstatus',
|
||||
'health.dosage': 'Dosierung',
|
||||
'health.frequency': 'Häufigkeit',
|
||||
'health.medication_name': 'Medikamentenname',
|
||||
'health.add_allergy': 'Allergie hinzufügen',
|
||||
'health.add_medication': 'Medikament hinzufügen',
|
||||
'health.add_condition': 'Erkrankung hinzufügen',
|
||||
|
||||
// Interactive Seating Chart
|
||||
'seating.title': 'Sitzplan',
|
||||
'seating.chart': 'Sitzordnung',
|
||||
'seating.row': 'Reihe',
|
||||
'seating.column': 'Spalte',
|
||||
'seating.save': 'Sitzplan speichern',
|
||||
'seating.randomize': 'Mischen',
|
||||
'seating.clear': 'Zurücksetzen',
|
||||
'seating.print': 'Sitzplan drucken',
|
||||
'seating.drag_to_rearrange': 'Ziehen zum Verschieben',
|
||||
'seating.empty_seat': 'Freier Platz',
|
||||
'seating.rows': 'Reihen',
|
||||
'seating.columns': 'Spalten',
|
||||
'seating.grid_size': 'Rastergröße',
|
||||
'seating.student_initials': 'Initialen',
|
||||
'seating.no_students': 'Keine Schüler für Sitzplan',
|
||||
'seating.visual': 'Visueller Sitzplan',
|
||||
'seating.list': 'Listenansicht',
|
||||
|
||||
// QR Code System
|
||||
'qr.title': 'QR-Code',
|
||||
'qr.student': 'Schüler-QR-Code',
|
||||
'qr.class': 'Klassen-QR-Code',
|
||||
'qr.attendance': 'Anwesenheits-QR-Code',
|
||||
'qr.event': 'Veranstaltungs-QR-Code',
|
||||
'qr.download': 'QR-Code herunterladen',
|
||||
'qr.scan': 'QR-Code scannen',
|
||||
'qr.refresh': 'QR-Code aktualisieren',
|
||||
'qr.generate': 'QR-Code erzeugen',
|
||||
'qr.scan_to_checkin': 'Scannen zum Einchecken',
|
||||
'qr.session_active': 'QR-Sitzung aktiv',
|
||||
'qr.refreshes_in': 'Aktualisierung in {seconds}s',
|
||||
'qr.checked_in': 'Eingecheckt',
|
||||
'qr.pending': 'Ausstehend',
|
||||
'qr.absent': 'Abwesend',
|
||||
'qr.show_qr': 'QR-Code anzeigen',
|
||||
'qr.print_report': 'QR im Bericht',
|
||||
'qr.student_card': 'Schülerausweis',
|
||||
|
||||
// Badge / Gamification System
|
||||
'badges.title': 'Abzeichen',
|
||||
'badges.earned': 'Verdient',
|
||||
'badges.unearned': 'Noch nicht verdient',
|
||||
'badges.progress': 'Fortschritt',
|
||||
'badges.collection': 'Abzeichen-Sammlung',
|
||||
'badges.award': 'Abzeichen verleihen',
|
||||
'badges.auto': 'Automatisch vergeben',
|
||||
'badges.manual': 'Manuell vergeben',
|
||||
'badges.leaderboard': 'Abzeichen-Rangliste',
|
||||
'badges.recent': 'Kürzlich verdient',
|
||||
'badges.details': 'Abzeichen-Details',
|
||||
'badges.earned_count': '{earned} von {total} Abzeichen verdient',
|
||||
'badges.category': 'Kategorie',
|
||||
'badges.competency': 'Kompetenz',
|
||||
'badges.attendance': 'Anwesenheit',
|
||||
'badges.behavior': 'Verhalten',
|
||||
'badges.achievement': 'Leistung',
|
||||
'badges.milestone': 'Meilenstein',
|
||||
'badges.requirement': 'Voraussetzung',
|
||||
'badges.create': 'Abzeichen erstellen',
|
||||
'badges.edit': 'Abzeichen bearbeiten',
|
||||
'badges.delete': 'Abzeichen löschen',
|
||||
'badges.manage': 'Abzeichen verwalten',
|
||||
'badges.statistics': 'Abzeichen-Statistik',
|
||||
'badges.award_manually': 'Manuell verleihen',
|
||||
'badges.awarded_by': 'Verliehen von',
|
||||
'badges.awarded_at': 'Verliehen am',
|
||||
'badges.no_badges': 'Keine Abzeichen vorhanden',
|
||||
'badges.celebration': 'Neues Abzeichen verdient!',
|
||||
'badges.icon': 'Icon',
|
||||
'badges.color': 'Farbe',
|
||||
'badges.threshold': 'Schwellenwert',
|
||||
'badges.type': 'Typ',
|
||||
'badges.is_auto': 'Automatisch vergeben',
|
||||
|
||||
// Badge Names
|
||||
'badges.perfect_attendance': 'Perfekte Anwesenheit',
|
||||
'badges.competency_master': 'Kompetenz-Meister',
|
||||
'badges.progress_pioneer': 'Fortschritts-Pionier',
|
||||
'badges.behavior_star': 'Verhaltensstern',
|
||||
'badges.notebook_champion': 'Heft-Champion',
|
||||
'badges.drawing_artist': 'Zeichen-Künstler',
|
||||
'badges.homework_hero': 'Hausaufgaben-Held',
|
||||
'badges.team_player': 'Teamplayer',
|
||||
'badges.goal_achiever': 'Zielerreicher',
|
||||
'badges.eco_warrior': 'Umweltkrieger',
|
||||
};
|
||||
|
||||
const en: TranslationDict = {
|
||||
@@ -6167,6 +6312,151 @@ const en: TranslationDict = {
|
||||
'onboarding.previous': 'Previous',
|
||||
'onboarding.dont_show_again': 'Don\'t show again',
|
||||
'onboarding.restart_tour': 'Restart tour',
|
||||
|
||||
// Student Transportation
|
||||
'transport.title': 'Transportation',
|
||||
'transport.type': 'Transport Type',
|
||||
'transport.bus': 'Bus',
|
||||
'transport.tram': 'Tram',
|
||||
'transport.walk': 'Walk',
|
||||
'transport.car': 'Car',
|
||||
'transport.bike': 'Bike',
|
||||
'transport.other': 'Other',
|
||||
'transport.route': 'Route',
|
||||
'transport.stop': 'Stop',
|
||||
'transport.pickup': 'Pickup',
|
||||
'transport.dropoff': 'Drop-off',
|
||||
'transport.driver': 'Driver',
|
||||
'transport.distance': 'Distance',
|
||||
'transport.distance_km': '{count} km',
|
||||
'transport.notes': 'Notes',
|
||||
'transport.add': 'Add Transportation',
|
||||
'transport.edit': 'Edit Transportation',
|
||||
'transport.no_transport': 'No transportation saved',
|
||||
'transport.route_number': 'Route Number',
|
||||
'transport.stop_name': 'Stop Name',
|
||||
'transport.pickup_time': 'Pickup Time',
|
||||
'transport.dropoff_time': 'Drop-off Time',
|
||||
'transport.driver_name': 'Driver Name',
|
||||
'transport.driver_phone': 'Driver Phone',
|
||||
'transport.distance_km_field': 'Distance (km)',
|
||||
'transport.class_overview': 'Class Transport Overview',
|
||||
'transport.quick_call': 'Quick Call',
|
||||
|
||||
// Health Records
|
||||
'health.title': 'Health Records',
|
||||
'health.blood_type': 'Blood Type',
|
||||
'health.allergies': 'Allergies',
|
||||
'health.medications': 'Medications',
|
||||
'health.conditions': 'Conditions',
|
||||
'health.doctor': 'Doctor',
|
||||
'health.insurance': 'Insurance',
|
||||
'health.confidential': 'Confidential',
|
||||
'health.last_checkup': 'Last Checkup',
|
||||
'health.warning': 'Health Warning',
|
||||
'health.add': 'Add Health Record',
|
||||
'health.edit': 'Edit Health Record',
|
||||
'health.no_record': 'No health records available',
|
||||
'health.doctor_name': 'Doctor Name',
|
||||
'health.doctor_phone': 'Doctor Phone',
|
||||
'health.insurance_number': 'Insurance Number',
|
||||
'health.insurance_provider': 'Insurance Provider',
|
||||
'health.last_checkup_date': 'Last Checkup Date',
|
||||
'health.notes': 'Health Notes',
|
||||
'health.confidential_toggle': 'Confidentiality Status',
|
||||
'health.dosage': 'Dosage',
|
||||
'health.frequency': 'Frequency',
|
||||
'health.medication_name': 'Medication Name',
|
||||
'health.add_allergy': 'Add Allergy',
|
||||
'health.add_medication': 'Add Medication',
|
||||
'health.add_condition': 'Add Condition',
|
||||
|
||||
// Interactive Seating Chart
|
||||
'seating.title': 'Seating Chart',
|
||||
'seating.chart': 'Seating Order',
|
||||
'seating.row': 'Row',
|
||||
'seating.column': 'Column',
|
||||
'seating.save': 'Save Seating',
|
||||
'seating.randomize': 'Randomize',
|
||||
'seating.clear': 'Clear',
|
||||
'seating.print': 'Print Seating',
|
||||
'seating.drag_to_rearrange': 'Drag to rearrange',
|
||||
'seating.empty_seat': 'Empty Seat',
|
||||
'seating.rows': 'Rows',
|
||||
'seating.columns': 'Columns',
|
||||
'seating.grid_size': 'Grid Size',
|
||||
'seating.student_initials': 'Initials',
|
||||
'seating.no_students': 'No students for seating chart',
|
||||
'seating.visual': 'Visual Seating',
|
||||
'seating.list': 'List View',
|
||||
|
||||
// QR Code System
|
||||
'qr.title': 'QR Code',
|
||||
'qr.student': 'Student QR Code',
|
||||
'qr.class': 'Class QR Code',
|
||||
'qr.attendance': 'Attendance QR Code',
|
||||
'qr.event': 'Event QR Code',
|
||||
'qr.download': 'Download QR Code',
|
||||
'qr.scan': 'Scan QR Code',
|
||||
'qr.refresh': 'Refresh QR Code',
|
||||
'qr.generate': 'Generate QR Code',
|
||||
'qr.scan_to_checkin': 'Scan to check in',
|
||||
'qr.session_active': 'QR session active',
|
||||
'qr.refreshes_in': 'Refreshes in {seconds}s',
|
||||
'qr.checked_in': 'Checked in',
|
||||
'qr.pending': 'Pending',
|
||||
'qr.absent': 'Absent',
|
||||
'qr.show_qr': 'Show QR Code',
|
||||
'qr.print_report': 'QR on Report',
|
||||
'qr.student_card': 'Student Card',
|
||||
|
||||
// Badge / Gamification System
|
||||
'badges.title': 'Badges',
|
||||
'badges.earned': 'Earned',
|
||||
'badges.unearned': 'Not yet earned',
|
||||
'badges.progress': 'Progress',
|
||||
'badges.collection': 'Badge Collection',
|
||||
'badges.award': 'Award Badge',
|
||||
'badges.auto': 'Auto-awarded',
|
||||
'badges.manual': 'Manually awarded',
|
||||
'badges.leaderboard': 'Badge Leaderboard',
|
||||
'badges.recent': 'Recently earned',
|
||||
'badges.details': 'Badge Details',
|
||||
'badges.earned_count': '{earned} of {total} badges earned',
|
||||
'badges.category': 'Category',
|
||||
'badges.competency': 'Competency',
|
||||
'badges.attendance': 'Attendance',
|
||||
'badges.behavior': 'Behavior',
|
||||
'badges.achievement': 'Achievement',
|
||||
'badges.milestone': 'Milestone',
|
||||
'badges.requirement': 'Requirement',
|
||||
'badges.create': 'Create Badge',
|
||||
'badges.edit': 'Edit Badge',
|
||||
'badges.delete': 'Delete Badge',
|
||||
'badges.manage': 'Manage Badges',
|
||||
'badges.statistics': 'Badge Statistics',
|
||||
'badges.award_manually': 'Award Manually',
|
||||
'badges.awarded_by': 'Awarded by',
|
||||
'badges.awarded_at': 'Awarded on',
|
||||
'badges.no_badges': 'No badges available',
|
||||
'badges.celebration': 'New badge earned!',
|
||||
'badges.icon': 'Icon',
|
||||
'badges.color': 'Color',
|
||||
'badges.threshold': 'Threshold',
|
||||
'badges.type': 'Type',
|
||||
'badges.is_auto': 'Auto-awarded',
|
||||
|
||||
// Badge Names
|
||||
'badges.perfect_attendance': 'Perfect Attendance',
|
||||
'badges.competency_master': 'Competency Master',
|
||||
'badges.progress_pioneer': 'Progress Pioneer',
|
||||
'badges.behavior_star': 'Behavior Star',
|
||||
'badges.notebook_champion': 'Notebook Champion',
|
||||
'badges.drawing_artist': 'Drawing Artist',
|
||||
'badges.homework_hero': 'Homework Hero',
|
||||
'badges.team_player': 'Team Player',
|
||||
'badges.goal_achiever': 'Goal Achiever',
|
||||
'badges.eco_warrior': 'Eco Warrior',
|
||||
};
|
||||
|
||||
const translations: Record<string, TranslationDict> = { de, en };
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
// CompetenceTrack — QR Code Generation Utility
|
||||
// Generates QR codes as base64 PNG images using canvas
|
||||
|
||||
export type QRCodeType = 'student' | 'class' | 'attendance' | 'event';
|
||||
|
||||
export interface QRCodeData {
|
||||
type: QRCodeType;
|
||||
id: string;
|
||||
schoolId?: string;
|
||||
label?: string;
|
||||
// For attendance QR: session-specific data
|
||||
sessionId?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a URL string from QR code data that can be encoded into a QR code
|
||||
*/
|
||||
export function buildQRCodeUrl(data: QRCodeData): string {
|
||||
const base = typeof window !== 'undefined' ? window.location.origin : 'https://competencetrack.app';
|
||||
switch (data.type) {
|
||||
case 'student':
|
||||
return `${base}/?student=${data.id}`;
|
||||
case 'class':
|
||||
return `${base}/?class=${data.id}`;
|
||||
case 'attendance':
|
||||
return `${base}/?attendance=${data.sessionId || data.id}&t=${data.timestamp || Date.now()}`;
|
||||
case 'event':
|
||||
return `${base}/?event=${data.id}`;
|
||||
default:
|
||||
return `${base}/?id=${data.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple QR code encoder using Canvas API
|
||||
// This implements a minimal QR code generator
|
||||
|
||||
// QR Code error correction levels
|
||||
const ERROR_CORRECTION_LEVEL = {
|
||||
L: 0, // 7% recovery
|
||||
M: 1, // 15% recovery
|
||||
Q: 2, // 25% recovery
|
||||
H: 3, // 30% recovery
|
||||
};
|
||||
|
||||
// QR Code version sizes (modules per side)
|
||||
const VERSION_SIZES = [
|
||||
0, 21, 25, 29, 33, 37, 41, 45, 49, 53, 57, // 1-10
|
||||
61, 65, 69, 73, 77, 81, 85, 89, 93, 97, // 11-20
|
||||
101, 105, 109, 113, 117, 121, 125, 129, 133, 137, // 21-30
|
||||
141, 145, 149, 153, 157, 161, 165, 169, 173, 177, // 31-40
|
||||
];
|
||||
|
||||
// Simplified QR code generation using canvas
|
||||
// We use a well-known approach: encode data to a binary matrix, then render to canvas
|
||||
|
||||
function getAlphanumericCode(char: string): number {
|
||||
const codes = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:';
|
||||
const idx = codes.indexOf(char);
|
||||
return idx >= 0 ? idx : -1;
|
||||
}
|
||||
|
||||
function isAlphanumeric(text: string): boolean {
|
||||
for (const c of text) {
|
||||
if (getAlphanumericCode(c) < 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface QRMatrix {
|
||||
size: number;
|
||||
modules: boolean[][]; // true = dark
|
||||
reserved: boolean[][];
|
||||
}
|
||||
|
||||
function createMatrix(version: number): QRMatrix {
|
||||
const size = VERSION_SIZES[version];
|
||||
const modules: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false));
|
||||
const reserved: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false));
|
||||
return { size, modules, reserved };
|
||||
}
|
||||
|
||||
function setFunctionPatterns(matrix: QRMatrix): void {
|
||||
const { size, modules, reserved } = matrix;
|
||||
|
||||
// Finders: 7x7 at three corners
|
||||
const finderPositions = [
|
||||
[0, 0], [0, size - 7], [size - 7, 0],
|
||||
];
|
||||
|
||||
for (const [row, col] of finderPositions) {
|
||||
for (let r = -1; r <= 7; r++) {
|
||||
for (let c = -1; c <= 7; c++) {
|
||||
const rr = row + r;
|
||||
const cc = col + c;
|
||||
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
|
||||
const isDark = (r >= 0 && r <= 6 && (c === 0 || c === 6)) ||
|
||||
(c >= 0 && c <= 6 && (r === 0 || r === 6)) ||
|
||||
(r >= 2 && r <= 4 && c >= 2 && c <= 4);
|
||||
modules[rr][cc] = isDark;
|
||||
reserved[rr][cc] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timing patterns
|
||||
for (let i = 8; i < size - 8; i++) {
|
||||
if (!reserved[6][i]) {
|
||||
modules[6][i] = i % 2 === 0;
|
||||
reserved[6][i] = true;
|
||||
}
|
||||
if (!reserved[i][6]) {
|
||||
modules[i][6] = i % 2 === 0;
|
||||
reserved[i][6] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Alignment patterns (for version >= 2)
|
||||
if (size >= 25) {
|
||||
const alignPositions = getAlignmentPositions(size);
|
||||
for (const row of alignPositions) {
|
||||
for (const col of alignPositions) {
|
||||
if (reserved[row]?.[col]) continue;
|
||||
for (let r = -2; r <= 2; r++) {
|
||||
for (let c = -2; c <= 2; c++) {
|
||||
const rr = row + r;
|
||||
const cc = col + c;
|
||||
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
|
||||
if (reserved[rr][cc]) continue;
|
||||
const isDark = Math.abs(r) === 2 || Math.abs(c) === 2 || (r === 0 && c === 0);
|
||||
modules[rr][cc] = isDark;
|
||||
reserved[rr][cc] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve format info areas
|
||||
for (let i = 0; i < 8; i++) {
|
||||
reserved[8][i] = true;
|
||||
reserved[i][8] = true;
|
||||
reserved[8][size - 1 - i] = true;
|
||||
reserved[size - 1 - i][8] = true;
|
||||
}
|
||||
reserved[8][8] = true;
|
||||
reserved[size - 8][8] = true;
|
||||
reserved[8][size - 8] = true;
|
||||
}
|
||||
|
||||
function getAlignmentPositions(size: number): number[] {
|
||||
// Simplified: common alignment positions for various versions
|
||||
const positions: number[] = [6];
|
||||
if (size === 25) positions.push(18);
|
||||
else if (size === 29) positions.push(22);
|
||||
else if (size === 33) positions.push(26);
|
||||
else if (size === 37) positions.push(30);
|
||||
else if (size === 41) positions.push(34);
|
||||
else if (size === 45) positions.push(38);
|
||||
else if (size === 49) positions.push(42);
|
||||
else if (size === 53) positions.push(46);
|
||||
else positions.push(size - 7);
|
||||
return positions;
|
||||
}
|
||||
|
||||
function encodeData(text: string, version: number): number[] {
|
||||
// Simple byte encoding
|
||||
const data: number[] = [];
|
||||
|
||||
// Mode indicator: 0100 (byte mode)
|
||||
data.push(0, 1, 0, 0);
|
||||
|
||||
// Character count (8 bits for byte mode, version 1-9)
|
||||
const charCountBits = version <= 9 ? 8 : 16;
|
||||
const countBin = text.length.toString(2).padStart(charCountBits, '0');
|
||||
for (const bit of countBin) {
|
||||
data.push(parseInt(bit));
|
||||
}
|
||||
|
||||
// Data
|
||||
for (const char of text) {
|
||||
const byte = char.charCodeAt(0);
|
||||
const byteBin = byte.toString(2).padStart(8, '0');
|
||||
for (const bit of byteBin) {
|
||||
data.push(parseInt(bit));
|
||||
}
|
||||
}
|
||||
|
||||
// Terminator
|
||||
const maxDataBits = getDataCapacity(version);
|
||||
const terminatorLen = Math.min(4, maxDataBits - data.length);
|
||||
for (let i = 0; i < terminatorLen; i++) {
|
||||
data.push(0);
|
||||
}
|
||||
|
||||
// Pad to byte boundary
|
||||
while (data.length % 8 !== 0) {
|
||||
data.push(0);
|
||||
}
|
||||
|
||||
// Pad bytes
|
||||
const padBytes = [0xEC, 0x11];
|
||||
let padIdx = 0;
|
||||
while (data.length < maxDataBits) {
|
||||
const byte = padBytes[padIdx % 2];
|
||||
const byteBin = byte.toString(2).padStart(8, '0');
|
||||
for (const bit of byteBin) {
|
||||
data.push(parseInt(bit));
|
||||
}
|
||||
padIdx++;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function getDataCapacity(version: number): number {
|
||||
// Simplified: total data bits for byte mode with L error correction
|
||||
const capacities = [0, 152, 272, 400, 576, 752, 976, 1200, 1456, 1728, 2032];
|
||||
return capacities[Math.min(version, 10)] || 2032;
|
||||
}
|
||||
|
||||
function placeData(matrix: QRMatrix, data: number[]): void {
|
||||
const { size, modules, reserved } = matrix;
|
||||
let bitIdx = 0;
|
||||
let upward = true;
|
||||
|
||||
for (let col = size - 1; col >= 0; col -= 2) {
|
||||
if (col === 6) col = 5; // Skip timing column
|
||||
|
||||
for (let row = 0; row < size; row++) {
|
||||
const actualRow = upward ? (size - 1 - row) : row;
|
||||
|
||||
for (let c = 0; c < 2; c++) {
|
||||
const actualCol = col - c;
|
||||
if (actualCol < 0 || actualCol >= size) continue;
|
||||
if (reserved[actualRow][actualCol]) continue;
|
||||
|
||||
if (bitIdx < data.length) {
|
||||
modules[actualRow][actualCol] = data[bitIdx] === 1;
|
||||
bitIdx++;
|
||||
}
|
||||
// If no more data, leave as false (white)
|
||||
}
|
||||
}
|
||||
upward = !upward;
|
||||
}
|
||||
}
|
||||
|
||||
function applyMask(matrix: QRMatrix): void {
|
||||
const { size, modules, reserved } = matrix;
|
||||
|
||||
// Apply mask pattern 0: (row + col) % 2 === 0
|
||||
for (let row = 0; row < size; row++) {
|
||||
for (let col = 0; col < size; col++) {
|
||||
if (reserved[row][col]) continue;
|
||||
if ((row + col) % 2 === 0) {
|
||||
modules[row][col] = !modules[row][col];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addFormatInfo(matrix: QRMatrix): void {
|
||||
const { size, modules, reserved } = matrix;
|
||||
|
||||
// Format info for L error correction + mask 0: 0x77C4
|
||||
const formatBits = [1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0];
|
||||
|
||||
// Horizontal format info
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (!reserved[8][i]) modules[8][i] = formatBits[i] === 1;
|
||||
if (!reserved[8][size - 1 - i]) modules[8][size - 1 - i] = formatBits[7 + i] === 1;
|
||||
}
|
||||
|
||||
// Vertical format info
|
||||
for (let i = 0; i < 7; i++) {
|
||||
if (!reserved[i][8]) modules[i][8] = formatBits[i] === 1;
|
||||
if (!reserved[size - 7 + i][8]) modules[size - 7 + i][8] = formatBits[i] === 1;
|
||||
}
|
||||
}
|
||||
|
||||
function selectVersion(text: string): number {
|
||||
const byteLen = new TextEncoder().encode(text).length;
|
||||
// Capacities for byte mode, L error correction
|
||||
const capacities = [0, 17, 32, 53, 78, 106, 134, 154, 192, 230, 271];
|
||||
for (let v = 1; v <= 10; v++) {
|
||||
if (byteLen <= capacities[v]) return v;
|
||||
}
|
||||
return 10; // Max version we support
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code matrix from text
|
||||
*/
|
||||
function generateQRMatrix(text: string): QRMatrix {
|
||||
const version = selectVersion(text);
|
||||
const matrix = createMatrix(version);
|
||||
|
||||
setFunctionPatterns(matrix);
|
||||
|
||||
const data = encodeData(text, version);
|
||||
placeData(matrix, data);
|
||||
applyMask(matrix);
|
||||
addFormatInfo(matrix);
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render QR matrix to canvas and return base64 PNG
|
||||
*/
|
||||
function renderToBase64(matrix: QRMatrix, size: number = 256, fgColor: string = '#000000', bgColor: string = '#ffffff'): string {
|
||||
// Only works in browser
|
||||
if (typeof document === 'undefined') return '';
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return '';
|
||||
|
||||
const quietZone = 4; // 4 module quiet zone
|
||||
const totalModules = matrix.size + quietZone * 2;
|
||||
const moduleSize = size / totalModules;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
// Modules
|
||||
ctx.fillStyle = fgColor;
|
||||
for (let row = 0; row < matrix.size; row++) {
|
||||
for (let col = 0; col < matrix.size; col++) {
|
||||
if (matrix.modules[row][col]) {
|
||||
const x = (col + quietZone) * moduleSize;
|
||||
const y = (row + quietZone) * moduleSize;
|
||||
ctx.fillRect(x, y, moduleSize + 0.5, moduleSize + 0.5); // +0.5 to avoid gaps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code as a base64 PNG data URL
|
||||
*/
|
||||
export async function generateQRCode(
|
||||
data: QRCodeData,
|
||||
options?: { size?: number; fgColor?: string; bgColor?: string }
|
||||
): Promise<string> {
|
||||
const url = buildQRCodeUrl(data);
|
||||
const matrix = generateQRMatrix(url);
|
||||
const size = options?.size ?? 256;
|
||||
const fgColor = options?.fgColor ?? '#000000';
|
||||
const bgColor = options?.bgColor ?? '#ffffff';
|
||||
|
||||
return renderToBase64(matrix, size, fgColor, bgColor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code as a base64 PNG data URL (sync version for browser)
|
||||
*/
|
||||
export function generateQRCodeSync(
|
||||
data: QRCodeData,
|
||||
options?: { size?: number; fgColor?: string; bgColor?: string }
|
||||
): string {
|
||||
const url = buildQRCodeUrl(data);
|
||||
const matrix = generateQRMatrix(url);
|
||||
const size = options?.size ?? 256;
|
||||
const fgColor = options?.fgColor ?? '#000000';
|
||||
const bgColor = options?.bgColor ?? '#ffffff';
|
||||
|
||||
return renderToBase64(matrix, size, fgColor, bgColor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a QR code image
|
||||
*/
|
||||
export function downloadQRCode(dataUrl: string, filename: string = 'qrcode.png'): void {
|
||||
const link = document.createElement('a');
|
||||
link.download = filename;
|
||||
link.href = dataUrl;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
Reference in new issue
Block a user