diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5823fdd..425e453 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,6 +42,10 @@ model School { parentStudentLinks ParentStudentLink[] curriculumStandards CurriculumStandard[] backups Backup[] + behaviorInterventions BehaviorIntervention[] + reportSchedules ReportSchedule[] + district SchoolDistrict? @relation(fields: [districtId], references: [id]) + districtId String? } model User { @@ -79,6 +83,7 @@ model User { notifications Notification[] calendarEvents CalendarEvent[] parentLinks ParentStudentLink[] + assignedInterventions BehaviorIntervention[] @relation("InterventionAssignee") } model SchoolYear { @@ -124,6 +129,7 @@ model ClassGroup { notebooks Notebook[] drawings Drawing[] calendarEvents CalendarEvent[] + reportSchedules ReportSchedule[] } model ClassGroupTeacher { @@ -160,6 +166,7 @@ model Student { parentContacts ParentContact[] parentMessages ParentMessage[] behaviorIncidents BehaviorIncident[] + behaviorInterventions BehaviorIntervention[] parentStudentLinks ParentStudentLink[] } @@ -427,6 +434,9 @@ model AuditLog { action String entityType String entityId String? + changes String? // JSON: { before: {...}, after: {...} } + ipAddress String? + userAgent String? timestamp DateTime @default(now()) metadata String? // JSON @@ -630,6 +640,8 @@ model BehaviorIncident { @@index([classGroupId, date]) @@index([schoolId, date]) @@index([teacherId]) + + interventions BehaviorIntervention[] } // ─── Rubric Library ──────────────────────────────────────────────── @@ -964,3 +976,82 @@ model Backup { @@map("backups") } + +// ─── Behavior Interventions ───────────────────────────────────────── + +model BehaviorIntervention { + id String @id @default(cuid()) + schoolId String + studentId String + incidentId String? + type String // "warning", "meeting", "action_plan", "support", "parent_contact" + description String + status String @default("planned") // "planned", "in_progress", "completed", "cancelled" + assignedTo String? + startDate DateTime? + endDate DateTime? + outcome String? + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + incident BehaviorIncident? @relation(fields: [incidentId], references: [id]) + assignedUser User? @relation("InterventionAssignee", fields: [assignedTo], references: [id]) + + @@index([schoolId, studentId]) + @@map("behavior_interventions") +} + +// ─── Report Scheduling ────────────────────────────────────────────── + +model ReportSchedule { + id String @id @default(cuid()) + schoolId String + classGroupId String? + template String // "short", "full", "custom" + frequency String // "weekly", "monthly", "quarterly", "end_of_term" + dayOfWeek Int? // 0-6 for weekly + dayOfMonth Int? // 1-31 for monthly + monthOfYear Int? // 1-12 for quarterly + recipients String? // JSON array of userIds + includeStudents Boolean @default(true) + includeGrades Boolean @default(true) + includeAttendance Boolean @default(true) + includeBehavior Boolean @default(false) + includeCompetencies Boolean @default(true) + isActive Boolean @default(true) + lastRunAt DateTime? + nextRunAt DateTime? + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) + + @@index([schoolId, isActive]) + @@map("report_schedules") +} + +// ─── School District ──────────────────────────────────────────────── + +model SchoolDistrict { + id String @id @default(cuid()) + name String + code String? // district code + region String? + country String @default("DE") + adminEmail String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + schools School[] + + @@map("school_districts") +} diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..f7ac693 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..51ddc6b Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..4f5156f --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "CompetenceTrack", + "short_name": "CompTrack", + "description": "Kompetenz- und Benotungsplattform für Schulen", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#10b981", + "orientation": "any", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["education", "productivity"], + "lang": "de", + "dir": "ltr" +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..fe8a2a6 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,300 @@ +// CompetenceTrack — Service Worker for PWA Offline Support +const CACHE_NAME = 'competencetrack-v1'; +const STATIC_CACHE = 'competencetrack-static-v1'; +const DATA_CACHE = 'competencetrack-data-v1'; +const OFFLINE_CACHE = 'competencetrack-offline-v1'; + +// Static assets to cache on install +const STATIC_ASSETS = [ + '/', + '/manifest.json', + '/logo.svg', + '/robots.txt', +]; + +// Install event — cache static assets and offline fallback +self.addEventListener('install', (event) => { + event.waitUntil( + Promise.all([ + caches.open(STATIC_CACHE).then((cache) => { + return cache.addAll(STATIC_ASSETS).catch((err) => { + console.warn('[SW] Some static assets failed to cache:', err); + }); + }), + caches.open(OFFLINE_CACHE).then((cache) => { + return cache.add('/').catch(() => { + // Fallback: just create the cache + }); + }), + ]).then(() => { + console.log('[SW] Installed and caches populated'); + return self.skipWaiting(); + }) + ); +}); + +// Activate event — clean up old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => { + return Promise.all( + keys + .filter((key) => key !== CACHE_NAME && key !== STATIC_CACHE && key !== DATA_CACHE && key !== OFFLINE_CACHE) + .map((key) => caches.delete(key)) + ); + }).then(() => { + console.log('[SW] Activated and old caches cleaned'); + return self.clients.claim(); + }) + ); +}); + +// Fetch event — routing strategy +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests (POST, PUT, DELETE) — handle with background sync + if (request.method !== 'GET') { + // Queue write operations for background sync + if (navigator.onLine === false) { + event.respondWith( + new Response(JSON.stringify({ queued: true, offline: true }), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + }) + ); + // Store the request for later sync + storeRequestForSync(request); + return; + } + return; // Let non-GET requests go through normally when online + } + + // Skip chrome-extension and other non-http requests + if (!url.protocol.startsWith('http')) return; + + // API data requests — NetworkFirst strategy + if (url.pathname.startsWith('/api/')) { + event.respondWith(networkFirstStrategy(request, DATA_CACHE)); + return; + } + + // Static assets (CSS, JS, images) — CacheFirst strategy + if ( + url.pathname.endsWith('.css') || + url.pathname.endsWith('.js') || + url.pathname.endsWith('.png') || + url.pathname.endsWith('.jpg') || + url.pathname.endsWith('.svg') || + url.pathname.endsWith('.ico') || + url.pathname.endsWith('.woff2') || + url.pathname.endsWith('.woff') + ) { + event.respondWith(cacheFirstStrategy(request, STATIC_CACHE)); + return; + } + + // Navigation requests — NetworkFirst with offline fallback + if (request.mode === 'navigate') { + event.respondWith(networkFirstWithOfflineFallback(request)); + return; + } + + // Default: NetworkFirst + event.respondWith(networkFirstStrategy(request, CACHE_NAME)); +}); + +// NetworkFirst strategy — try network, fall back to cache +async function networkFirstStrategy(request, cacheName) { + try { + const networkResponse = await fetch(request); + if (networkResponse.ok) { + const cache = await caches.open(cacheName); + cache.put(request, networkResponse.clone()); + } + return networkResponse; + } catch (error) { + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + // Return offline response for API calls + if (new URL(request.url).pathname.startsWith('/api/')) { + return new Response(JSON.stringify({ error: 'offline', message: 'No cached data available' }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response('Offline', { status: 503 }); + } +} + +// CacheFirst strategy — try cache, fall back to network +async function cacheFirstStrategy(request, cacheName) { + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + try { + const networkResponse = await fetch(request); + if (networkResponse.ok) { + const cache = await caches.open(cacheName); + cache.put(request, networkResponse.clone()); + } + return networkResponse; + } catch (error) { + // No cache and no network + return new Response('Offline', { status: 503 }); + } +} + +// NetworkFirst with offline fallback page +async function networkFirstWithOfflineFallback(request) { + try { + const networkResponse = await fetch(request); + if (networkResponse.ok) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, networkResponse.clone()); + } + return networkResponse; + } catch (error) { + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + // Fall back to cached root page + const fallbackResponse = await caches.match('/'); + if (fallbackResponse) { + return fallbackResponse; + } + return new Response('Offline — Please check your connection', { + status: 503, + headers: { 'Content-Type': 'text/html' }, + }); + } +} + +// Store failed requests for background sync +async function storeRequestForSync(request) { + const db = await openIndexedDB(); + const tx = db.transaction('pending-requests', 'readwrite'); + const store = tx.objectStore('pending-requests'); + const requestData = { + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + body: await request.text().catch(() => ''), + timestamp: Date.now(), + }; + store.add(requestData); + await tx.done; + + // Register for background sync + self.registration.sync.register('competencetrack-sync'); +} + +// Open IndexedDB for storing pending requests +function openIndexedDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open('competencetrack-sync-db', 1); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains('pending-requests')) { + db.createObjectStore('pending-requests', { keyPath: 'timestamp' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +// Background sync event — replay queued requests when back online +self.addEventListener('sync', (event) => { + if (event.tag === 'competencetrack-sync') { + event.waitUntil(replayPendingRequests()); + } +}); + +async function replayPendingRequests() { + const db = await openIndexedDB(); + const tx = db.transaction('pending-requests', 'readwrite'); + const store = tx.objectStore('pending-requests'); + const allRequests = await store.getAll(); + + for (const reqData of allRequests) { + try { + const headers = new Headers(reqData.headers); + const response = await fetch(reqData.url, { + method: reqData.method, + headers, + body: reqData.method !== 'GET' ? reqData.body : undefined, + }); + if (response.ok) { + store.delete(reqData.timestamp); + } + } catch (error) { + console.warn('[SW] Failed to replay request:', error); + // Keep the request in the store for next sync attempt + } + } + await tx.done; +} + +// Push event — show notification (if push notifications are configured) +self.addEventListener('push', (event) => { + if (!event.data) return; + const data = event.data.json(); + const options = { + body: data.message || '', + icon: '/icon-192.png', + badge: '/icon-192.png', + vibrate: [100, 50, 100], + data: { + url: data.url || '/', + }, + }; + event.waitUntil( + self.registration.showNotification(data.title || 'CompetenceTrack', options) + ); +}); + +// Notification click — open the app +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const url = event.notification.data?.url || '/'; + event.waitUntil( + self.clients.matchAll({ type: 'window' }).then((clients) => { + // Focus existing window if available + for (const client of clients) { + if (client.url.includes(url) && 'focus' in client) { + return client.focus(); + } + } + // Open new window + return self.clients.openWindow(url); + }) + ); +}); + +// Message event — handle messages from the app +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } + if (event.data && event.data.type === 'CLEAR_CACHES') { + caches.keys().then((keys) => { + Promise.all(keys.map((key) => caches.delete(key))).then(() => { + console.log('[SW] All caches cleared'); + }); + }); + } + if (event.data && event.data.type === 'CACHE_URLS') { + const urls = event.data.urls || []; + caches.open(STATIC_CACHE).then((cache) => { + cache.addAll(urls).catch((err) => { + console.warn('[SW] Failed to cache URLs:', err); + }); + }); + } +}); diff --git a/src/app/api/audit-log/route.ts b/src/app/api/audit-log/route.ts index 3975139..5a4dfeb 100644 --- a/src/app/api/audit-log/route.ts +++ b/src/app/api/audit-log/route.ts @@ -12,8 +12,13 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const schoolId = searchParams.get('schoolId'); const action = searchParams.get('action'); + const entityType = searchParams.get('entityType'); + const userId = searchParams.get('userId'); const startDate = searchParams.get('startDate'); const endDate = searchParams.get('endDate'); + const page = parseInt(searchParams.get('page') || '1', 10); + const pageSize = parseInt(searchParams.get('pageSize') || '50', 10); + const exportCsv = searchParams.get('export') === 'csv'; const where: Record = {}; @@ -25,6 +30,8 @@ export async function GET(request: Request) { } if (action) where.action = action; + if (entityType) where.entityType = entityType; + if (userId) where.userId = userId; if (startDate || endDate) { where.timestamp = { @@ -33,10 +40,52 @@ export async function GET(request: Request) { }; } + // CSV export + if (exportCsv) { + const entries = await db.auditLog.findMany({ + where, + orderBy: { timestamp: 'desc' }, + take: 10000, + include: { + user: { + select: { id: true, firstName: true, lastName: true, email: true }, + }, + }, + }); + + const header = 'Timestamp,User,Action,Entity Type,Entity ID,Changes,IP Address,User Agent,Metadata'; + const rows = entries.map((e) => { + const userName = e.user ? `${e.user.firstName} ${e.user.lastName}` : 'System'; + const escape = (s: string | null) => (s ? `"${s.replace(/"/g, '""')}"` : '""'); + return [ + new Date(e.timestamp).toISOString(), + escape(userName), + escape(e.action), + escape(e.entityType), + escape(e.entityId), + escape(e.changes), + escape(e.ipAddress), + escape(e.userAgent), + escape(e.metadata), + ].join(','); + }); + + const csv = [header, ...rows].join('\n'); + return new Response(csv, { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': 'attachment; filename="audit-log.csv"', + }, + }); + } + + // Regular paginated response + const total = await db.auditLog.count({ where }); const entries = await db.auditLog.findMany({ where, orderBy: { timestamp: 'desc' }, - take: 200, + skip: (page - 1) * pageSize, + take: pageSize, include: { user: { select: { id: true, firstName: true, lastName: true }, @@ -44,7 +93,15 @@ export async function GET(request: Request) { }, }); - return NextResponse.json(entries); + return NextResponse.json({ + entries, + pagination: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }); } catch (error) { console.error('AuditLog GET error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts index 4100423..c52938f 100644 --- a/src/app/api/auth/route.ts +++ b/src/app/api/auth/route.ts @@ -8,6 +8,7 @@ import { getSession, clearSession, } from '@/lib/auth'; +import { withRateLimit, checkRateLimit, getRateLimitKey, getRateLimitHeaders, createRateLimitResponse } from '@/lib/rate-limit'; const loginSchema = z.object({ email: z.string().email(), @@ -23,7 +24,7 @@ const registerSchema = z.object({ role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN']).default('TEACHER'), }); -export async function POST(request: Request) { +export const POST = withRateLimit(async function POST(request: Request) { try { const body = await request.json(); const { action } = body; @@ -138,15 +139,33 @@ export async function POST(request: Request) { { status: 500 } ); } -} +}, 'auth'); + +export async function GET(request: Request) { + // Apply rate limit for auth check endpoint + const key = getRateLimitKey(request); + const result = checkRateLimit(key, 60, 60 * 1000); // 60 per minute for GET + + if (!result.allowed) { + const rateLimitResponse = createRateLimitResponse(result); + return NextResponse.json(rateLimitResponse.body, { + status: rateLimitResponse.status, + headers: rateLimitResponse.headers, + }); + } -export async function GET() { try { const session = await getSession(); if (!session || !session.user) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } - return NextResponse.json(session.user); + + const response = NextResponse.json(session.user); + const rateLimitHeaders = getRateLimitHeaders(result); + for (const [headerKey, headerValue] of rateLimitHeaders.entries()) { + response.headers.set(headerKey, headerValue); + } + return response; } catch (error) { console.error('Auth me error:', error); return NextResponse.json( diff --git a/src/app/api/backup/route.ts b/src/app/api/backup/route.ts index c413a70..521dee7 100644 --- a/src/app/api/backup/route.ts +++ b/src/app/api/backup/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; +import { withRateLimit } from '@/lib/rate-limit'; // GET /api/backup — List backups for a school export async function GET(req: NextRequest) { @@ -21,7 +22,7 @@ export async function GET(req: NextRequest) { } // POST /api/backup — Create a new backup or restore from backup -export async function POST(req: NextRequest) { +export const POST = withRateLimit(async function POST(req: NextRequest) { try { const body = await req.json(); const { schoolId, action, backupId, notes } = body; @@ -139,7 +140,7 @@ export async function POST(req: NextRequest) { } return NextResponse.json({ error: 'Failed to create backup' }, { status: 500 }); } -} +}, 'heavy'); // DELETE /api/backup — Delete a backup export async function DELETE(req: NextRequest) { diff --git a/src/app/api/behavior-interventions/[id]/route.ts b/src/app/api/behavior-interventions/[id]/route.ts new file mode 100644 index 0000000..ea6b2b6 --- /dev/null +++ b/src/app/api/behavior-interventions/[id]/route.ts @@ -0,0 +1,173 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { logAudit, extractClientInfo } from '@/lib/audit'; + +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 intervention = await db.behaviorIntervention.findFirst({ + where: { id, deletedAt: null }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + incident: { + select: { id: true, description: true, date: true, severity: true }, + }, + assignedUser: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + if (!intervention) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + // If not super admin, restrict to user's school + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== intervention.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(intervention); + } catch (error) { + console.error('BehaviorIntervention 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.behaviorIntervention.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 { + type, + description, + status: interventionStatus, + assignedTo, + startDate, + endDate, + outcome, + incidentId, + } = body; + + const updated = await db.behaviorIntervention.update({ + where: { id }, + data: { + ...(type !== undefined && { type }), + ...(description !== undefined && { description }), + ...(interventionStatus !== undefined && { status: interventionStatus }), + ...(assignedTo !== undefined && { assignedTo: assignedTo || null }), + ...(startDate !== undefined && { startDate: startDate ? new Date(startDate) : null }), + ...(endDate !== undefined && { endDate: endDate ? new Date(endDate) : null }), + ...(outcome !== undefined && { outcome }), + ...(incidentId !== undefined && { incidentId: incidentId || null }), + }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + incident: { + select: { id: true, description: true, date: true, severity: true }, + }, + assignedUser: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + // Audit log + const clientInfo = extractClientInfo(request); + await logAudit({ + userId: session.user?.id, + schoolId: existing.schoolId, + action: 'UPDATE', + entityType: 'BehaviorIntervention', + entityId: id, + changes: { before: existing, after: updated }, + ...clientInfo, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('BehaviorIntervention 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.behaviorIntervention.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.behaviorIntervention.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + // Audit log + const clientInfo = extractClientInfo(request); + await logAudit({ + userId: session.user?.id, + schoolId: existing.schoolId, + action: 'DELETE', + entityType: 'BehaviorIntervention', + entityId: id, + changes: { before: existing }, + ...clientInfo, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('BehaviorIntervention DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/behavior-interventions/route.ts b/src/app/api/behavior-interventions/route.ts new file mode 100644 index 0000000..85ecd9b --- /dev/null +++ b/src/app/api/behavior-interventions/route.ts @@ -0,0 +1,135 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { logAudit, extractClientInfo } from '@/lib/audit'; + +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'); + const incidentId = searchParams.get('incidentId'); + const type = searchParams.get('type'); + const status = searchParams.get('status'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + // If not super admin, restrict to user's school + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const where: Record = { + schoolId, + deletedAt: null, + }; + + if (studentId) where.studentId = studentId; + if (incidentId) where.incidentId = incidentId; + if (type) where.type = type; + if (status) where.status = status; + + const interventions = await db.behaviorIntervention.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + incident: { + select: { id: true, description: true, date: true, severity: true }, + }, + assignedUser: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + return NextResponse.json(interventions); + } catch (error) { + console.error('BehaviorIntervention 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, + incidentId, + type, + description, + status: interventionStatus, + assignedTo, + startDate, + endDate, + outcome, + isDemo, + } = body; + + if (!schoolId || !studentId || !type || !description) { + return NextResponse.json( + { error: 'schoolId, studentId, type, and description are required' }, + { status: 400 } + ); + } + + const intervention = await db.behaviorIntervention.create({ + data: { + schoolId, + studentId, + incidentId: incidentId || null, + type, + description, + status: interventionStatus || 'planned', + assignedTo: assignedTo || null, + startDate: startDate ? new Date(startDate) : null, + endDate: endDate ? new Date(endDate) : null, + outcome: outcome || null, + isDemo: isDemo ?? false, + }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + incident: { + select: { id: true, description: true, date: true, severity: true }, + }, + assignedUser: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + // Audit log + const clientInfo = extractClientInfo(request); + await logAudit({ + userId: session.user?.id, + schoolId, + action: 'CREATE', + entityType: 'BehaviorIntervention', + entityId: intervention.id, + changes: { after: intervention }, + ...clientInfo, + }); + + return NextResponse.json(intervention, { status: 201 }); + } catch (error) { + console.error('BehaviorIntervention POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/data-export/csv/route.ts b/src/app/api/data-export/csv/route.ts index d9a57b2..fdb58e2 100644 --- a/src/app/api/data-export/csv/route.ts +++ b/src/app/api/data-export/csv/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { withRateLimit } from '@/lib/rate-limit'; function escapeCsvField(field: string | number | null | undefined): string { if (field === null || field === undefined) return ''; @@ -16,7 +17,7 @@ function toCsvRow(fields: (string | number | null | undefined)[]): string { return fields.map(escapeCsvField).join(','); } -export async function GET(request: Request) { +export const GET = withRateLimit(async function GET(request: Request) { try { const session = await getSession(); if (!session) { @@ -318,4 +319,4 @@ export async function GET(request: Request) { console.error('CSV Export error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } -} +}, 'heavy'); diff --git a/src/app/api/data-import/route.ts b/src/app/api/data-import/route.ts index b1b6380..79c67e9 100644 --- a/src/app/api/data-import/route.ts +++ b/src/app/api/data-import/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { withRateLimit } from '@/lib/rate-limit'; function escapeCsvField(field: string | number | null | undefined): string { if (field === null || field === undefined) return ''; @@ -59,7 +60,7 @@ function parseCsv(text: string): { headers: string[]; rows: ImportRow[] } { return { headers, rows }; } -export async function POST(request: Request) { +export const POST = withRateLimit(async function POST(request: Request) { try { const session = await getSession(); if (!session) { @@ -217,7 +218,7 @@ export async function POST(request: Request) { console.error('Data Import error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } -} +}, 'heavy'); export async function GET(request: Request) { try { diff --git a/src/app/api/districts/[id]/route.ts b/src/app/api/districts/[id]/route.ts new file mode 100644 index 0000000..483b8de --- /dev/null +++ b/src/app/api/districts/[id]/route.ts @@ -0,0 +1,97 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/districts/[id] +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const district = await db.schoolDistrict.findFirst({ + where: { id, deletedAt: null }, + include: { + schools: { + where: { deletedAt: null }, + select: { + id: true, + name: true, + schoolType: true, + country: true, + _count: { + select: { students: true, classGroups: true, users: true }, + }, + }, + }, + }, + }); + + if (!district) { + return NextResponse.json({ error: 'District not found' }, { status: 404 }); + } + + return NextResponse.json(district); + } catch (error) { + console.error('[districts/[id]] GET error:', error); + return NextResponse.json({ error: 'Failed to fetch district' }, { status: 500 }); + } +} + +// PUT /api/districts/[id] +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await req.json(); + const { name, code, region, country, adminEmail, isActive } = body; + + const data: Record = {}; + if (name !== undefined) data.name = name; + if (code !== undefined) data.code = code || null; + if (region !== undefined) data.region = region || null; + if (country !== undefined) data.country = country; + if (adminEmail !== undefined) data.adminEmail = adminEmail || null; + if (isActive !== undefined) data.isActive = isActive; + + const district = await db.schoolDistrict.update({ + where: { id }, + data, + include: { + schools: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(district); + } catch (error) { + console.error('[districts/[id]] PUT error:', error); + return NextResponse.json({ error: 'Failed to update district' }, { status: 500 }); + } +} + +// DELETE /api/districts/[id] +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + + // Remove district from schools first + await db.school.updateMany({ + where: { districtId: id }, + data: { districtId: null }, + }); + + await db.schoolDistrict.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('[districts/[id]] DELETE error:', error); + return NextResponse.json({ error: 'Failed to delete district' }, { status: 500 }); + } +} diff --git a/src/app/api/districts/[id]/schools/route.ts b/src/app/api/districts/[id]/schools/route.ts new file mode 100644 index 0000000..89e3471 --- /dev/null +++ b/src/app/api/districts/[id]/schools/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/districts/[id]/schools — schools in district +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const district = await db.schoolDistrict.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!district) { + return NextResponse.json({ error: 'District not found' }, { status: 404 }); + } + + const schools = await db.school.findMany({ + where: { districtId: id, deletedAt: null }, + include: { + _count: { + select: { students: true, classGroups: true, users: true }, + }, + }, + orderBy: { name: 'asc' }, + }); + + return NextResponse.json(schools); + } catch (error) { + console.error('[districts/[id]/schools] GET error:', error); + return NextResponse.json({ error: 'Failed to fetch district schools' }, { status: 500 }); + } +} + +// POST /api/districts/[id]/schools — assign school to district +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await req.json(); + const { schoolId } = body; + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + await db.school.update({ + where: { id: schoolId }, + data: { districtId: id }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('[districts/[id]/schools] POST error:', error); + return NextResponse.json({ error: 'Failed to assign school to district' }, { status: 500 }); + } +} diff --git a/src/app/api/districts/route.ts b/src/app/api/districts/route.ts new file mode 100644 index 0000000..befda14 --- /dev/null +++ b/src/app/api/districts/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/districts +export async function GET() { + try { + const districts = await db.schoolDistrict.findMany({ + where: { deletedAt: null }, + include: { + schools: { + where: { deletedAt: null }, + select: { id: true, name: true, schoolType: true, country: true }, + }, + }, + orderBy: { name: 'asc' }, + }); + + // Add computed stats + const result = districts.map((d) => ({ + ...d, + schoolCount: d.schools.length, + totalStudents: 0, // Would need aggregation query in production + })); + + return NextResponse.json(result); + } catch (error) { + console.error('[districts] GET error:', error); + return NextResponse.json({ error: 'Failed to fetch districts' }, { status: 500 }); + } +} + +// POST /api/districts +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { name, code, region, country, adminEmail } = body; + + if (!name) { + return NextResponse.json({ error: 'name is required' }, { status: 400 }); + } + + const district = await db.schoolDistrict.create({ + data: { + name, + code: code || null, + region: region || null, + country: country || 'DE', + adminEmail: adminEmail || null, + }, + include: { + schools: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(district, { status: 201 }); + } catch (error) { + console.error('[districts] POST error:', error); + return NextResponse.json({ error: 'Failed to create district' }, { status: 500 }); + } +} diff --git a/src/app/api/rate-limit-stats/route.ts b/src/app/api/rate-limit-stats/route.ts new file mode 100644 index 0000000..e8a959b --- /dev/null +++ b/src/app/api/rate-limit-stats/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; +import { getRateLimitStats } from '@/lib/rate-limit'; + +// GET /api/rate-limit-stats — Get rate limit statistics (admin monitoring) +export async function GET() { + try { + const stats = getRateLimitStats(); + return NextResponse.json(stats); + } catch (error) { + console.error('Rate limit stats error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/report-schedules/[id]/route.ts b/src/app/api/report-schedules/[id]/route.ts new file mode 100644 index 0000000..0187051 --- /dev/null +++ b/src/app/api/report-schedules/[id]/route.ts @@ -0,0 +1,158 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/report-schedules/[id] +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const schedule = await db.reportSchedule.findFirst({ + where: { id, deletedAt: null }, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + school: { select: { id: true, name: true } }, + }, + }); + if (!schedule) { + return NextResponse.json({ error: 'Schedule not found' }, { status: 404 }); + } + return NextResponse.json(schedule); + } catch (error) { + console.error('[report-schedules/[id]] GET error:', error); + return NextResponse.json({ error: 'Failed to fetch schedule' }, { status: 500 }); + } +} + +// PUT /api/report-schedules/[id] +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await req.json(); + const { + classGroupId, + template, + frequency, + dayOfWeek, + dayOfMonth, + monthOfYear, + recipients, + includeStudents, + includeGrades, + includeAttendance, + includeBehavior, + includeCompetencies, + isActive, + } = body; + + const data: Record = {}; + if (classGroupId !== undefined) data.classGroupId = classGroupId || null; + if (template !== undefined) data.template = template; + if (frequency !== undefined) data.frequency = frequency; + if (dayOfWeek !== undefined) data.dayOfWeek = dayOfWeek; + if (dayOfMonth !== undefined) data.dayOfMonth = dayOfMonth; + if (monthOfYear !== undefined) data.monthOfYear = monthOfYear; + if (recipients !== undefined) data.recipients = recipients ? JSON.stringify(recipients) : null; + if (includeStudents !== undefined) data.includeStudents = includeStudents; + if (includeGrades !== undefined) data.includeGrades = includeGrades; + if (includeAttendance !== undefined) data.includeAttendance = includeAttendance; + if (includeBehavior !== undefined) data.includeBehavior = includeBehavior; + if (includeCompetencies !== undefined) data.includeCompetencies = includeCompetencies; + if (isActive !== undefined) data.isActive = isActive; + + // Recalculate nextRunAt if frequency or timing changed + if (frequency || dayOfWeek !== undefined || dayOfMonth !== undefined || monthOfYear !== undefined) { + const current = await db.reportSchedule.findUnique({ where: { id } }); + if (current) { + const freq = frequency || current.frequency; + const dow = dayOfWeek !== undefined ? dayOfWeek : current.dayOfWeek; + const dom = dayOfMonth !== undefined ? dayOfMonth : current.dayOfMonth; + const moy = monthOfYear !== undefined ? monthOfYear : current.monthOfYear; + data.nextRunAt = calculateNextRun(freq, dow, dom, moy); + } + } + + const schedule = await db.reportSchedule.update({ + where: { id }, + data, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + }, + }); + + return NextResponse.json(schedule); + } catch (error) { + console.error('[report-schedules/[id]] PUT error:', error); + return NextResponse.json({ error: 'Failed to update schedule' }, { status: 500 }); + } +} + +// DELETE /api/report-schedules/[id] +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + await db.reportSchedule.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('[report-schedules/[id]] DELETE error:', error); + return NextResponse.json({ error: 'Failed to delete schedule' }, { status: 500 }); + } +} + +function calculateNextRun( + frequency: string, + dayOfWeek?: number | null, + dayOfMonth?: number | null, + monthOfYear?: number | null, +): Date { + const now = new Date(); + const next = new Date(now); + + switch (frequency) { + case 'weekly': { + const targetDay = dayOfWeek ?? 1; + const currentDay = next.getDay(); + const daysUntil = ((targetDay - currentDay + 7) % 7) || 7; + next.setDate(next.getDate() + daysUntil); + next.setHours(8, 0, 0, 0); + break; + } + case 'monthly': { + const targetDate = dayOfMonth ?? 1; + next.setDate(targetDate); + next.setHours(8, 0, 0, 0); + if (next <= now) next.setMonth(next.getMonth() + 1); + break; + } + case 'quarterly': { + const targetMonth = monthOfYear ?? 1; + next.setMonth(targetMonth - 1); + next.setDate(1); + next.setHours(8, 0, 0, 0); + if (next <= now) next.setMonth(next.getMonth() + 3); + break; + } + case 'end_of_term': { + next.setMonth(6); + next.setDate(15); + next.setHours(8, 0, 0, 0); + if (next <= now) next.setFullYear(next.getFullYear() + 1); + break; + } + default: { + next.setDate(next.getDate() + 7); + next.setHours(8, 0, 0, 0); + } + } + return next; +} diff --git a/src/app/api/report-schedules/[id]/run/route.ts b/src/app/api/report-schedules/[id]/run/route.ts new file mode 100644 index 0000000..0616b83 --- /dev/null +++ b/src/app/api/report-schedules/[id]/run/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// POST /api/report-schedules/[id]/run — trigger manual run +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const schedule = await db.reportSchedule.findFirst({ + where: { id, deletedAt: null }, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + school: { select: { id: true, name: true } }, + }, + }); + + if (!schedule) { + return NextResponse.json({ error: 'Schedule not found' }, { status: 404 }); + } + + // Mark as run and calculate next run + const now = new Date(); + const nextRunAt = calculateNextRun( + schedule.frequency, + schedule.dayOfWeek, + schedule.dayOfMonth, + schedule.monthOfYear, + ); + + await db.reportSchedule.update({ + where: { id }, + data: { lastRunAt: now, nextRunAt }, + }); + + // In a real system, this would trigger the actual report generation pipeline + // and send notifications to recipients. For now, we simulate success. + const recipientIds: string[] = schedule.recipients + ? JSON.parse(schedule.recipients) + : []; + + // Create notifications for recipients + if (recipientIds.length > 0) { + try { + await db.notification.createMany({ + data: recipientIds.map((userId) => ({ + schoolId: schedule.schoolId, + userId, + type: 'report_ready', + title: `Scheduled Report Generated`, + message: `Report schedule "${schedule.template}" for ${schedule.classGroup?.name ?? 'all classes'} has been generated.`, + actionUrl: '/reports', + relatedId: schedule.id, + })), + }); + } catch { + // Notification creation is non-critical + } + } + + return NextResponse.json({ + success: true, + lastRunAt: now, + nextRunAt, + recipientsNotified: recipientIds.length, + }); + } catch (error) { + console.error('[report-schedules/[id]/run] POST error:', error); + return NextResponse.json({ error: 'Failed to run schedule' }, { status: 500 }); + } +} + +function calculateNextRun( + frequency: string, + dayOfWeek?: number | null, + dayOfMonth?: number | null, + monthOfYear?: number | null, +): Date { + const now = new Date(); + const next = new Date(now); + + switch (frequency) { + case 'weekly': { + const targetDay = dayOfWeek ?? 1; + const currentDay = next.getDay(); + const daysUntil = ((targetDay - currentDay + 7) % 7) || 7; + next.setDate(next.getDate() + daysUntil); + next.setHours(8, 0, 0, 0); + break; + } + case 'monthly': { + const targetDate = dayOfMonth ?? 1; + next.setDate(targetDate); + next.setHours(8, 0, 0, 0); + if (next <= now) next.setMonth(next.getMonth() + 1); + break; + } + case 'quarterly': { + const targetMonth = monthOfYear ?? 1; + next.setMonth(targetMonth - 1); + next.setDate(1); + next.setHours(8, 0, 0, 0); + if (next <= now) next.setMonth(next.getMonth() + 3); + break; + } + case 'end_of_term': { + next.setMonth(6); + next.setDate(15); + next.setHours(8, 0, 0, 0); + if (next <= now) next.setFullYear(next.getFullYear() + 1); + break; + } + default: { + next.setDate(next.getDate() + 7); + next.setHours(8, 0, 0, 0); + } + } + return next; +} diff --git a/src/app/api/report-schedules/route.ts b/src/app/api/report-schedules/route.ts new file mode 100644 index 0000000..dd1d313 --- /dev/null +++ b/src/app/api/report-schedules/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/report-schedules?schoolId=xxx +export async function GET(req: NextRequest) { + try { + const schoolId = req.nextUrl.searchParams.get('schoolId'); + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + const schedules = await db.reportSchedule.findMany({ + where: { schoolId, deletedAt: null }, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json(schedules); + } catch (error) { + console.error('[report-schedules] GET error:', error); + return NextResponse.json({ error: 'Failed to fetch report schedules' }, { status: 500 }); + } +} + +// POST /api/report-schedules +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { + schoolId, + classGroupId, + template, + frequency, + dayOfWeek, + dayOfMonth, + monthOfYear, + recipients, + includeStudents, + includeGrades, + includeAttendance, + includeBehavior, + includeCompetencies, + } = body; + + if (!schoolId || !template || !frequency) { + return NextResponse.json({ error: 'schoolId, template, and frequency are required' }, { status: 400 }); + } + + // Calculate nextRunAt + const nextRunAt = calculateNextRun(frequency, dayOfWeek, dayOfMonth, monthOfYear); + + const schedule = await db.reportSchedule.create({ + data: { + schoolId, + classGroupId: classGroupId || null, + template, + frequency, + dayOfWeek: dayOfWeek ?? null, + dayOfMonth: dayOfMonth ?? null, + monthOfYear: monthOfYear ?? null, + recipients: recipients ? JSON.stringify(recipients) : null, + includeStudents: includeStudents ?? true, + includeGrades: includeGrades ?? true, + includeAttendance: includeAttendance ?? true, + includeBehavior: includeBehavior ?? false, + includeCompetencies: includeCompetencies ?? true, + nextRunAt, + }, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + }, + }); + + return NextResponse.json(schedule, { status: 201 }); + } catch (error) { + console.error('[report-schedules] POST error:', error); + return NextResponse.json({ error: 'Failed to create report schedule' }, { status: 500 }); + } +} + +function calculateNextRun( + frequency: string, + dayOfWeek?: number | null, + dayOfMonth?: number | null, + monthOfYear?: number | null, +): Date { + const now = new Date(); + const next = new Date(now); + + switch (frequency) { + case 'weekly': { + const targetDay = dayOfWeek ?? 1; // Monday default + const currentDay = next.getDay(); + const daysUntil = ((targetDay - currentDay + 7) % 7) || 7; + next.setDate(next.getDate() + daysUntil); + next.setHours(8, 0, 0, 0); + break; + } + case 'monthly': { + const targetDate = dayOfMonth ?? 1; + next.setDate(targetDate); + next.setHours(8, 0, 0, 0); + if (next <= now) { + next.setMonth(next.getMonth() + 1); + } + break; + } + case 'quarterly': { + const targetMonth = monthOfYear ?? 1; + next.setMonth(targetMonth - 1); + next.setDate(1); + next.setHours(8, 0, 0, 0); + if (next <= now) { + next.setMonth(next.getMonth() + 3); + } + break; + } + case 'end_of_term': { + // Default: July 15 (end of German school year) + next.setMonth(6); // July + next.setDate(15); + next.setHours(8, 0, 0, 0); + if (next <= now) { + next.setFullYear(next.getFullYear() + 1); + } + break; + } + default: { + next.setDate(next.getDate() + 7); + next.setHours(8, 0, 0, 0); + } + } + + return next; +} diff --git a/src/app/api/reports/pdf/route.ts b/src/app/api/reports/pdf/route.ts index 11cbcda..28acf2e 100644 --- a/src/app/api/reports/pdf/route.ts +++ b/src/app/api/reports/pdf/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { withRateLimit } from '@/lib/rate-limit'; -export async function GET(request: Request) { +export const GET = withRateLimit(async function GET(request: Request) { try { const session = await getSession(); if (!session) { @@ -105,7 +106,7 @@ export async function GET(request: Request) { console.error('Report PDF error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } -} +}, 'heavy'); function generatePrintHtml(report: Record, template: string): string { const student = report.student as { firstName: string; lastName: string }; diff --git a/src/app/globals.css b/src/app/globals.css index e90f8dd..be920db 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1548,3 +1548,258 @@ pointer-events: none; } } + +/* ─── Report Scheduling Styles (Task 13) ────────────────────────────────── */ +@layer utilities { + /* Schedule timeline visualization */ + .schedule-timeline { + position: relative; + padding-left: 1.5rem; + } + .schedule-timeline::before { + content: ''; + position: absolute; + left: 0.5rem; + top: 0; + bottom: 0; + width: 2px; + background: linear-gradient(to bottom, oklch(0.55 0.17 155), oklch(0.65 0.12 175)); + border-radius: 1px; + } + .schedule-timeline-item { + position: relative; + padding-bottom: 1.5rem; + } + .schedule-timeline-item::before { + content: ''; + position: absolute; + left: -1.1rem; + top: 0.35rem; + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; + background: oklch(0.55 0.17 155); + border: 2px solid white; + box-shadow: 0 0 0 2px oklch(0.55 0.17 155 / 0.3); + } + .schedule-timeline-item.inactive::before { + background: oklch(0.7 0 0); + box-shadow: 0 0 0 2px oklch(0.7 0 0 / 0.3); + } + .schedule-timeline-item:last-child { + padding-bottom: 0; + } + + /* District card styling */ + .district-card { + position: relative; + overflow: hidden; + border-radius: 1rem; + transition: transform 0.2s ease, box-shadow 0.2s ease; + } + .district-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px -5px oklch(0 0 0 / 0.1), 0 0 0 1px oklch(0.55 0.17 155 / 0.1); + } + .district-card::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(to right, oklch(0.55 0.17 155), oklch(0.65 0.12 175), oklch(0.55 0.17 155)); + } + .dark .district-card::after { + background: linear-gradient(to right, oklch(0.65 0.17 155), oklch(0.75 0.12 175), oklch(0.65 0.17 155)); + } + + /* Cross-school comparison styling */ + .cross-school-comparison { + display: grid; + gap: 1rem; + } + .cross-school-comparison .comparison-bar { + height: 0.5rem; + border-radius: 9999px; + overflow: hidden; + background: oklch(0.95 0.02 155); + } + .dark .cross-school-comparison .comparison-bar { + background: oklch(0.25 0.02 155); + } + .cross-school-comparison .comparison-bar-fill { + height: 100%; + border-radius: 9999px; + transition: width 0.5s ease; + } + + /* Report generation progress bar */ + .report-progress { + position: relative; + height: 0.5rem; + border-radius: 9999px; + overflow: hidden; + background: oklch(0.93 0.02 155); + } + .dark .report-progress { + background: oklch(0.25 0.02 155); + } + .report-progress-fill { + height: 100%; + border-radius: 9999px; + background: linear-gradient(to right, oklch(0.55 0.17 155), oklch(0.65 0.12 175)); + transition: width 0.5s ease; + position: relative; + } + .report-progress-fill::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(90deg, transparent, oklch(1 0 0 / 0.3), transparent); + animation: shimmer-progress 2s ease-in-out infinite; + } + @keyframes shimmer-progress { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } + } + + /* Section divider with gradient line */ + .section-divider { + position: relative; + height: 1px; + background: linear-gradient(to right, transparent, oklch(0.55 0.17 155 / 0.3), transparent); + margin: 1.5rem 0; + } + .dark .section-divider { + background: linear-gradient(to right, transparent, oklch(0.65 0.17 155 / 0.3), transparent); + } + + /* ─── Offline / PWA Styles ──────────────────────────────────────── */ + + /* Offline indicator bar */ + .offline-indicator-bar { + z-index: 100; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + } + + /* PWA install prompt */ + .pwa-install-prompt { + animation: slide-up-fade 0.3s ease-out; + } + + @keyframes slide-up-fade { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + /* Offline badge pulse */ + .offline-badge-pulse { + animation: offline-pulse 2s ease-in-out infinite; + } + + @keyframes offline-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } + } + + /* Offline sync spinner */ + .offline-sync-spinner { + animation: offline-sync-spin 1s linear infinite; + } + + @keyframes offline-sync-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + /* Cached notebook indicator */ + .cached-notebook-indicator { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + /* Rate limit status card */ + .rate-limit-card { + border: 1px solid oklch(0.55 0.17 155 / 0.2); + border-radius: 0.75rem; + padding: 1rem; + background: oklch(0.98 0.01 155 / 0.5); + } + .dark .rate-limit-card { + border-color: oklch(0.55 0.17 155 / 0.15); + background: oklch(0.2 0.02 155 / 0.3); + } + + /* Rate limit bar */ + .rate-limit-bar { + height: 4px; + border-radius: 2px; + background: oklch(0.92 0.01 155); + overflow: hidden; + } + .dark .rate-limit-bar { + background: oklch(0.25 0.01 155); + } + .rate-limit-bar-fill { + height: 100%; + border-radius: 2px; + transition: width 0.3s ease; + } + + /* PWA standalone mode adjustments */ + @media (display-mode: standalone) { + body { + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + } + } + + /* Offline mode visual feedback */ + .offline-mode-active { + filter: none; + } + + .offline-mode-active .offline-dimmed { + opacity: 0.7; + pointer-events: none; + } + + /* Toast rate limit countdown */ + .rate-limit-countdown { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-variant-numeric: tabular-nums; + font-weight: 600; + color: oklch(0.55 0.17 25); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 395a373..2bcd198 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "next-themes"; @@ -19,9 +19,23 @@ export const metadata: Metadata = { description: "Open-Source School Software for Competency-Based Assessment and Grading. Built with Next.js, TypeScript, and shadcn/ui.", keywords: ["CompetenceTrack", "competency", "grading", "school", "education", "Next.js", "TypeScript", "shadcn/ui"], authors: [{ name: "CompetenceTrack Team" }], + manifest: "/manifest.json", icons: { icon: "https://z-cdn.chatglm.cn/z-ai/static/logo.svg", + apple: "/icon-192.png", }, + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "CompetenceTrack", + }, +}; + +export const viewport: Viewport = { + themeColor: "#10b981", + width: "device-width", + initialScale: 1, + maximumScale: 1, }; export default function RootLayout({ @@ -30,7 +44,10 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + + + + diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index 0e33a6b..80ad546 100644 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -115,6 +115,7 @@ import { } from '@/components/ui/command'; import { toast } from 'sonner'; import { useWebSocket, usePushNotifications, playNotificationSound, getNotificationSoundPref } from '@/lib/websocket'; +import { OfflineIndicator, PWAInstallPrompt, useServiceWorker, OfflineSyncManager } from '@/components/offline-indicator'; import DashboardView from './dashboard-view'; import ClassesView from './classes-view'; @@ -257,6 +258,9 @@ export default function AppLayout() { const [schoolYears, setSchoolYears] = useState([]); const [selectedYearId, setSelectedYearId] = useState(storeSchoolYearId ?? ''); + // Service Worker registration for PWA + useServiceWorker(); + // WebSocket connection for real-time push notifications const { connected: wsConnected } = useWebSocket(); const { notification: pushNotification } = usePushNotifications(); @@ -636,6 +640,12 @@ export default function AppLayout() { return ( + {/* Offline indicator bar */} + + {/* PWA install prompt */} + + {/* Offline sync manager */} + = { Shield, ShieldAlert, Smile, Frown, Meh, Sparkles, Award, CheckCircle2, Circle, Plus, Pencil, Trash2, Filter, X, CalendarIcon, MapPin, UserCheck, PieChartIcon, BarChart3, ListChecks, Loader2, + TrendingUp, Target, Heart, Users, }; function renderLucideIcon(name: string, className?: string): React.ReactNode { @@ -93,6 +100,7 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { Progress } from '@/components/ui/progress'; import { toast } from 'sonner'; import { useAppStore } from '@/lib/store'; import { t } from '@/lib/i18n'; @@ -107,6 +115,11 @@ import { deleteBehaviorIncident, fetchClasses, fetchStudents, + fetchBehaviorInterventions, + createBehaviorIntervention, + updateBehaviorIntervention, + deleteBehaviorIntervention, + fetchUsers, type BehaviorCategory, type BehaviorIncident, type BehaviorValence, @@ -116,6 +129,8 @@ import { type BehaviorCategoryInput, type ClassGroup, type Student, + type BehaviorInterventionType, + type UserAccount, } from '@/lib/api'; /* ── Constants ─────────────────────────────────────────────────────── */ @@ -207,6 +222,24 @@ const FOLLOWUP_OPTIONS = [ 'prize', ]; +const INTERVENTION_TYPES = ['warning', 'meeting', 'action_plan', 'support', 'parent_contact'] as const; +const INTERVENTION_STATUSES = ['planned', 'in_progress', 'completed', 'cancelled'] as const; + +const INTERVENTION_STATUS_CONFIG: Record = { + planned: { dot: 'bg-slate-500', badge: 'bg-slate-100 text-slate-700 dark:bg-slate-950/60 dark:text-slate-300', progress: 0, icon: }, + in_progress: { dot: 'bg-amber-500', badge: 'bg-amber-100 text-amber-700 dark:bg-amber-950/60 dark:text-amber-300', progress: 50, icon: }, + completed: { dot: 'bg-emerald-500', badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300', progress: 100, icon: }, + cancelled: { dot: 'bg-rose-500', badge: 'bg-rose-100 text-rose-700 dark:bg-rose-950/60 dark:text-rose-300', progress: 0, icon: }, +}; + +const INTERVENTION_TYPE_CONFIG: Record = { + warning: { color: 'text-amber-600 dark:text-amber-400', icon: }, + meeting: { color: 'text-violet-600 dark:text-violet-400', icon: }, + action_plan: { color: 'text-teal-600 dark:text-teal-400', icon: }, + support: { color: 'text-emerald-600 dark:text-emerald-400', icon: }, + parent_contact: { color: 'text-rose-600 dark:text-rose-400', icon: }, +}; + /* ── Helpers ───────────────────────────────────────────────────────── */ function pad(n: number): string { @@ -289,6 +322,33 @@ interface CategoryFormState { icon: string; } +interface InterventionFormState { + id?: string; + studentId: string; + incidentId: string; + type: string; + description: string; + status: string; + assignedTo: string; + startDate: string; + endDate: string; + outcome: string; +} + +function emptyInterventionForm(studentId = '', incidentId = ''): InterventionFormState { + return { + studentId, + incidentId, + type: 'meeting', + description: '', + status: 'planned', + assignedTo: '', + startDate: toDateInputValue(new Date()), + endDate: '', + outcome: '', + }; +} + function emptyIncidentForm(studentId = '', classGroupId = '', categoryId = ''): IncidentFormState { const today = new Date(); return { @@ -402,7 +462,7 @@ export default function BehaviorTrackingView() { const [classesLoading, setClassesLoading] = useState(true); const [categoriesLoading, setCategoriesLoading] = useState(true); const [incidentsLoading, setIncidentsLoading] = useState(true); - const [tab, setTab] = useState<'incidents' | 'categories' | 'statistics'>('incidents'); + const [tab, setTab] = useState<'incidents' | 'categories' | 'statistics' | 'analytics' | 'interventions'>('incidents'); // Filters const [filterClassId, setFilterClassId] = useState('all'); @@ -426,6 +486,20 @@ export default function BehaviorTrackingView() { const [savingCategory, setSavingCategory] = useState(false); const [deleteCategoryId, setDeleteCategoryId] = useState(null); + // Interventions state + const [interventions, setInterventions] = useState([]); + const [interventionsLoading, setInterventionsLoading] = useState(false); + const [interventionDialogOpen, setInterventionDialogOpen] = useState(false); + const [interventionForm, setInterventionForm] = useState(null); + const [savingIntervention, setSavingIntervention] = useState(false); + const [deleteInterventionId, setDeleteInterventionId] = useState(null); + const [interventionFilterStatus, setInterventionFilterStatus] = useState('all'); + const [interventionFilterType, setInterventionFilterType] = useState('all'); + const [teachers, setTeachers] = useState([]); + + // Analytics period + const [analyticsPeriod, setAnalyticsPeriod] = useState<'30' | '90' | 'year'>('90'); + const schoolId = currentUser?.schoolId ?? ''; /* ── Load classes & students ─────────────────────────────────────── */ @@ -496,6 +570,34 @@ export default function BehaviorTrackingView() { loadIncidents(); }, [loadIncidents]); + /* ── Load interventions ──────────────────────────────────────────── */ + const loadInterventions = useCallback(async () => { + if (!schoolId) { + setInterventions([]); + return; + } + setInterventionsLoading(true); + try { + const data = await fetchBehaviorInterventions({ schoolId }); + setInterventions(data); + } catch { + setInterventions([]); + } finally { + setInterventionsLoading(false); + } + }, [schoolId]); + + useEffect(() => { + loadInterventions(); + }, [loadInterventions]); + + /* ── Load teachers ───────────────────────────────────────────────── */ + useEffect(() => { + if (schoolId) { + fetchUsers(schoolId).then(setTeachers).catch(() => setTeachers([])); + } + }, [schoolId]); + /* ── Incident actions ────────────────────────────────────────────── */ const openCreateIncident = () => { if (categories.length === 0) { @@ -669,6 +771,199 @@ export default function BehaviorTrackingView() { } }; + /* ── Intervention actions ──────────────────────────────────────── */ + const openCreateIntervention = (studentId = '', incidentId = '') => { + setInterventionForm(emptyInterventionForm(studentId, incidentId)); + setInterventionDialogOpen(true); + }; + + const openEditIntervention = (intv: BehaviorInterventionType) => { + setInterventionForm({ + id: intv.id, + studentId: intv.studentId, + incidentId: intv.incidentId ?? '', + type: intv.type, + description: intv.description, + status: intv.status, + assignedTo: intv.assignedTo ?? '', + startDate: intv.startDate ? toDateInputValue(new Date(intv.startDate)) : '', + endDate: intv.endDate ? toDateInputValue(new Date(intv.endDate)) : '', + outcome: intv.outcome ?? '', + }); + setInterventionDialogOpen(true); + }; + + const closeInterventionDialog = () => { + if (savingIntervention) return; + setInterventionDialogOpen(false); + setInterventionForm(null); + }; + + const handleSaveIntervention = async () => { + if (!interventionForm) return; + if (!interventionForm.type) { + toast.error(t('behavior.intervention.required_type')); + return; + } + if (!interventionForm.description.trim()) { + toast.error(t('behavior.intervention.required_description')); + return; + } + setSavingIntervention(true); + try { + const payload = { + schoolId, + studentId: interventionForm.studentId || students[0]?.id || '', + incidentId: interventionForm.incidentId || undefined, + type: interventionForm.type, + description: interventionForm.description.trim(), + status: interventionForm.status, + assignedTo: interventionForm.assignedTo || undefined, + startDate: interventionForm.startDate || undefined, + endDate: interventionForm.endDate || undefined, + outcome: interventionForm.outcome || undefined, + }; + if (interventionForm.id) { + await updateBehaviorIntervention(interventionForm.id, payload); + } else { + await createBehaviorIntervention(payload); + } + toast.success(t('behavior.intervention.saved')); + setInterventionDialogOpen(false); + setInterventionForm(null); + await loadInterventions(); + } catch (err) { + toast.error(err instanceof Error ? err.message : t('behavior.intervention.error_save')); + } finally { + setSavingIntervention(false); + } + }; + + const handleDeleteIntervention = async () => { + if (!deleteInterventionId) return; + try { + await deleteBehaviorIntervention(deleteInterventionId); + toast.success(t('behavior.intervention.deleted')); + setDeleteInterventionId(null); + await loadInterventions(); + } catch (err) { + toast.error(err instanceof Error ? err.message : t('behavior.intervention.error_delete')); + } + }; + + /* ── Analytics computations ─────────────────────────────────────── */ + const analytics = useMemo(() => { + const now = new Date(); + const periodStart = analyticsPeriod === '30' + ? new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) + : analyticsPeriod === '90' + ? new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000) + : new Date(now.getFullYear(), 0, 1); // this year + + const periodIncidents = incidents.filter((i) => new Date(i.date) >= periodStart); + + // Trend data: group by week + const trendMap = new Map(); + for (const inc of periodIncidents) { + const d = new Date(inc.date); + const weekStart = new Date(d); + weekStart.setDate(d.getDate() - d.getDay()); + const key = toDateInputValue(weekStart); + const existing = trendMap.get(key); + if (existing) { + existing[inc.category.valence] += 1; + } else { + trendMap.set(key, { + week: formatDateShort(key, locale), + positive: inc.category.valence === 'positive' ? 1 : 0, + negative: inc.category.valence === 'negative' ? 1 : 0, + neutral: inc.category.valence === 'neutral' ? 1 : 0, + }); + } + } + const trendData = Array.from(trendMap.values()).sort((a, b) => a.week.localeCompare(b.week)); + + // Category distribution (pie) + const catDistMap = new Map(); + for (const inc of periodIncidents) { + const existing = catDistMap.get(inc.categoryId); + if (existing) existing.value += 1; + else catDistMap.set(inc.categoryId, { name: inc.category.name, value: 1, color: inc.category.color }); + } + const categoryDist = Array.from(catDistMap.values()); + + // Risk profile: students with >= 3 negative incidents + const riskMap = new Map(); + for (const inc of periodIncidents) { + const existing = riskMap.get(inc.studentId); + if (existing) { + existing.total += 1; + if (inc.category.valence === 'negative') existing.negative += 1; + if (inc.category.valence === 'positive') existing.positive += 1; + } else { + riskMap.set(inc.studentId, { + studentId: inc.studentId, + firstName: inc.student.firstName, + lastName: inc.student.lastName, + negative: inc.category.valence === 'negative' ? 1 : 0, + positive: inc.category.valence === 'positive' ? 1 : 0, + total: 1, + risk: 'low', + }); + } + } + const riskProfile = Array.from(riskMap.values()).map((s) => ({ + ...s, + risk: s.negative >= 5 ? 'high' : s.negative >= 3 ? 'medium' : 'low', + })).filter((s) => s.negative >= 1).sort((a, b) => b.negative - a.negative); + + // Class comparison + const classMap = new Map(); + for (const inc of periodIncidents) { + const className = inc.classGroup?.name ?? 'Unknown'; + const existing = classMap.get(className); + if (existing) { + existing.total += 1; + existing[inc.category.valence] += 1; + } else { + classMap.set(className, { + name: className, + positive: inc.category.valence === 'positive' ? 1 : 0, + negative: inc.category.valence === 'negative' ? 1 : 0, + neutral: inc.category.valence === 'neutral' ? 1 : 0, + total: 1, + }); + } + } + const classComparison = Array.from(classMap.values()).sort((a, b) => b.total - a.total); + + // Time-of-day analysis + const hourMap = new Map(); + for (const inc of periodIncidents) { + const d = new Date(inc.date); + const h = d.getHours(); + const existing = hourMap.get(h); + if (existing) existing.count += 1; + else hourMap.set(h, { hour: h, count: 1, label: `${pad(h)}:00` }); + } + const timeOfDay = Array.from(hourMap.values()).sort((a, b) => a.hour - b.hour); + + // Positive reinforcement: students with more positive than negative + const positiveReinforcement = Array.from(riskMap.values()) + .filter((s) => s.positive > s.negative && s.positive >= 2) + .sort((a, b) => (b.positive - b.negative) - (a.positive - a.negative)); + + return { + trendData, + categoryDist, + riskProfile, + classComparison, + timeOfDay, + positiveReinforcement, + periodIncidents, + }; + }, [incidents, analyticsPeriod, locale]); + /* ── Statistics computations ─────────────────────────────────────── */ const stats = useMemo(() => { const now = new Date(); @@ -829,6 +1124,25 @@ export default function BehaviorTrackingView() { {t('behavior.tab_statistics')} + + + {t('behavior.tab_analytics')} + + + + {t('behavior.tab_interventions')} + {interventions.length > 0 && ( + + {interventions.length} + + )} + {/* ── Tab: Incidents ─────────────────────────────────────────── */} @@ -1391,6 +1705,443 @@ export default function BehaviorTrackingView() { + + {/* ── Tab: Analytics ────────────────────────────────────────── */} + + + + {/* Period selector */} +
+ +
+ {(['30', '90', 'year'] as const).map((p) => ( + + ))} +
+
+ + {/* Trend Chart */} + + + +
+ +
+ {t('behavior.analytics.trend')} +
+

{t('behavior.analytics.trend_desc')}

+
+ + {analytics.trendData.length === 0 ? ( +

{t('behavior.stat.no_data')}

+ ) : ( + + + + + + + + + + + + + )} +
+
+ +
+ {/* Category Distribution */} + + + +
+ +
+ {t('behavior.analytics.category_dist')} +
+

{t('behavior.analytics.category_dist_desc')}

+
+ + {analytics.categoryDist.length === 0 ? ( +

{t('behavior.stat.no_data')}

+ ) : ( + + + `${name}: ${value}`} + labelLine={false} + > + {analytics.categoryDist.map((entry, idx) => ( + + ))} + + + + + + )} +
+
+ + {/* Time-of-Day Analysis */} + + + +
+ +
+ {t('behavior.analytics.time_of_day')} +
+

{t('behavior.analytics.time_of_day_desc')}

+
+ + {analytics.timeOfDay.length === 0 ? ( +

{t('behavior.stat.no_data')}

+ ) : ( + + + + + + + + + + )} +
+
+
+ + {/* Class Comparison */} + + + +
+ +
+ {t('behavior.analytics.class_comparison')} +
+

{t('behavior.analytics.class_comparison_desc')}

+
+ + {analytics.classComparison.length === 0 ? ( +

{t('behavior.stat.no_data')}

+ ) : ( + + + + + + + + + + + + + )} +
+
+ +
+ {/* Risk Profile */} + + + +
+ +
+ {t('behavior.analytics.risk_profile')} +
+

{t('behavior.analytics.risk_profile_desc')}

+
+ + {analytics.riskProfile.length === 0 ? ( +

{t('behavior.stat.no_data')}

+ ) : ( + +
+ {analytics.riskProfile.map((s) => { + const riskBadge = s.risk === 'high' + ? 'bg-rose-100 text-rose-700 dark:bg-rose-950/60 dark:text-rose-300' + : s.risk === 'medium' + ? 'bg-amber-100 text-amber-700 dark:bg-amber-950/60 dark:text-amber-300' + : 'bg-slate-100 text-slate-700 dark:bg-slate-950/60 dark:text-slate-300'; + return ( +
+ +
+
+ {s.firstName} {s.lastName} +
+
+ {t('behavior.analytics.incidents_count_short')}: {s.negative} neg / {s.positive} pos +
+
+ + {t(`behavior.analytics.risk_${s.risk}`)} + +
+ ); + })} +
+
+ )} +
+
+ + {/* Positive Reinforcement */} + + + +
+ +
+ {t('behavior.analytics.positive_trend')} +
+

{t('behavior.analytics.positive_trend_desc')}

+
+ + {analytics.positiveReinforcement.length === 0 ? ( +

{t('behavior.stat.no_data')}

+ ) : ( + +
+ {analytics.positiveReinforcement.map((s) => ( +
+ +
+
+ {s.firstName} {s.lastName} +
+
+ + +{s.positive - s.negative} {t('behavior.analytics.positive_change')} + +
+
+ + + {s.positive} + +
+ ))} +
+
+ )} +
+
+
+
+
+
+ + {/* ── Tab: Interventions ─────────────────────────────────────── */} + + + + {/* Header */} +
+
+ + +
+ +
+ + {/* Intervention list */} + {interventionsLoading ? ( +
+ +
+ ) : (() => { + const filtered = interventions.filter((intv) => { + if (interventionFilterStatus !== 'all' && intv.status !== interventionFilterStatus) return false; + if (interventionFilterType !== 'all' && intv.type !== interventionFilterType) return false; + return true; + }); + return filtered.length === 0 ? ( + + +
+ +
+

+ {t('behavior.intervention.no_interventions')} +

+

+ {t('behavior.intervention.no_interventions_desc')} +

+ +
+
+ ) : ( +
+ {filtered.map((intv, idx) => { + const statusCfg = INTERVENTION_STATUS_CONFIG[intv.status] ?? INTERVENTION_STATUS_CONFIG.planned; + const typeCfg = INTERVENTION_TYPE_CONFIG[intv.type] ?? { color: 'text-gray-600', icon: }; + return ( + + + +
+ +
+
+
+ {intv.student.firstName} {intv.student.lastName} +
+ + {statusCfg.icon} + {t(`behavior.intervention.status.${intv.status}`)} + +
+
+ + {typeCfg.icon} + {t(`behavior.intervention.type.${intv.type}`)} + + {intv.assignedUser && ( + + + {intv.assignedUser.firstName} {intv.assignedUser.lastName} + + )} + {intv.incident && ( + + {t('behavior.intervention.linked_incident')}: {intv.incident.description.slice(0, 20)} + + )} +
+

{intv.description}

+ + {/* Progress indicator */} +
+
+ {t('behavior.intervention.progress')} + {statusCfg.progress}% +
+ +
+ + {/* Effectiveness outcome */} + {intv.outcome && intv.status === 'completed' && ( +
+
{t('behavior.intervention.effectiveness')}
+

{intv.outcome}

+
+ )} + + {/* Actions */} +
+ + + {intv.status === 'planned' && ( + + )} + {intv.status === 'in_progress' && ( + + )} +
+
+
+
+
+
+ ); + })} +
+ ); + })()} +
+
+
{/* ── Incident Form Dialog ─────────────────────────────────────── */} @@ -1833,6 +2584,169 @@ export default function BehaviorTrackingView() { + + {/* ── Intervention Form Dialog ──────────────────────────────────── */} + { if (!o) closeInterventionDialog(); }}> + + + + + {interventionForm?.id ? t('behavior.intervention.edit') : t('behavior.intervention.add')} + + {t('behavior.intervention.subtitle')} + + + {interventionForm && ( +
+
+
+ + +
+
+ + +
+
+ +
+ +