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

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

No files matched your search

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