Round 13: PWA Offline Mode, Rate Limiting, Behavior Analytics, Interventions, Audit Logging

This commit is contained in:
Mustafa Cetin committed 2026-07-29 10:23:50 +00:00
1 parent ceea8fb591
commit ff4dd670d4
33 files changed
+5916 -98

No files matched your search

+91
View File
@@ -42,6 +42,10 @@ model School {
parentStudentLinks ParentStudentLink[] parentStudentLinks ParentStudentLink[]
curriculumStandards CurriculumStandard[] curriculumStandards CurriculumStandard[]
backups Backup[] backups Backup[]
behaviorInterventions BehaviorIntervention[]
reportSchedules ReportSchedule[]
district SchoolDistrict? @relation(fields: [districtId], references: [id])
districtId String?
} }
model User { model User {
@@ -79,6 +83,7 @@ model User {
notifications Notification[] notifications Notification[]
calendarEvents CalendarEvent[] calendarEvents CalendarEvent[]
parentLinks ParentStudentLink[] parentLinks ParentStudentLink[]
assignedInterventions BehaviorIntervention[] @relation("InterventionAssignee")
} }
model SchoolYear { model SchoolYear {
@@ -124,6 +129,7 @@ model ClassGroup {
notebooks Notebook[] notebooks Notebook[]
drawings Drawing[] drawings Drawing[]
calendarEvents CalendarEvent[] calendarEvents CalendarEvent[]
reportSchedules ReportSchedule[]
} }
model ClassGroupTeacher { model ClassGroupTeacher {
@@ -160,6 +166,7 @@ model Student {
parentContacts ParentContact[] parentContacts ParentContact[]
parentMessages ParentMessage[] parentMessages ParentMessage[]
behaviorIncidents BehaviorIncident[] behaviorIncidents BehaviorIncident[]
behaviorInterventions BehaviorIntervention[]
parentStudentLinks ParentStudentLink[] parentStudentLinks ParentStudentLink[]
} }
@@ -427,6 +434,9 @@ model AuditLog {
action String action String
entityType String entityType String
entityId String? entityId String?
changes String? // JSON: { before: {...}, after: {...} }
ipAddress String?
userAgent String?
timestamp DateTime @default(now()) timestamp DateTime @default(now())
metadata String? // JSON metadata String? // JSON
@@ -630,6 +640,8 @@ model BehaviorIncident {
@@index([classGroupId, date]) @@index([classGroupId, date])
@@index([schoolId, date]) @@index([schoolId, date])
@@index([teacherId]) @@index([teacherId])
interventions BehaviorIntervention[]
} }
// ─── Rubric Library ──────────────────────────────────────────────── // ─── Rubric Library ────────────────────────────────────────────────
@@ -964,3 +976,82 @@ model Backup {
@@map("backups") @@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")
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+27
View File
@@ -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"
}
+300
View File
@@ -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);
});
});
}
});
+59 -2
View File
@@ -12,8 +12,13 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId'); const schoolId = searchParams.get('schoolId');
const action = searchParams.get('action'); const action = searchParams.get('action');
const entityType = searchParams.get('entityType');
const userId = searchParams.get('userId');
const startDate = searchParams.get('startDate'); const startDate = searchParams.get('startDate');
const endDate = searchParams.get('endDate'); 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<string, unknown> = {}; const where: Record<string, unknown> = {};
@@ -25,6 +30,8 @@ export async function GET(request: Request) {
} }
if (action) where.action = action; if (action) where.action = action;
if (entityType) where.entityType = entityType;
if (userId) where.userId = userId;
if (startDate || endDate) { if (startDate || endDate) {
where.timestamp = { where.timestamp = {
@@ -33,10 +40,52 @@ export async function GET(request: Request) {
}; };
} }
// CSV export
if (exportCsv) {
const entries = await db.auditLog.findMany({ const entries = await db.auditLog.findMany({
where, where,
orderBy: { timestamp: 'desc' }, orderBy: { timestamp: 'desc' },
take: 200, 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' },
skip: (page - 1) * pageSize,
take: pageSize,
include: { include: {
user: { user: {
select: { id: true, firstName: true, lastName: true }, 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) { } catch (error) {
console.error('AuditLog GET error:', error); console.error('AuditLog GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+22 -3
View File
@@ -8,6 +8,7 @@ import {
getSession, getSession,
clearSession, clearSession,
} from '@/lib/auth'; } from '@/lib/auth';
import { withRateLimit, checkRateLimit, getRateLimitKey, getRateLimitHeaders, createRateLimitResponse } from '@/lib/rate-limit';
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email(), email: z.string().email(),
@@ -23,7 +24,7 @@ const registerSchema = z.object({
role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN']).default('TEACHER'), 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 { try {
const body = await request.json(); const body = await request.json();
const { action } = body; const { action } = body;
@@ -138,15 +139,33 @@ export async function POST(request: Request) {
{ status: 500 } { 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 { try {
const session = await getSession(); const session = await getSession();
if (!session || !session.user) { if (!session || !session.user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); 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) { } catch (error) {
console.error('Auth me error:', error); console.error('Auth me error:', error);
return NextResponse.json( return NextResponse.json(
+3 -2
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { withRateLimit } from '@/lib/rate-limit';
// GET /api/backup — List backups for a school // GET /api/backup — List backups for a school
export async function GET(req: NextRequest) { 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 // 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 { try {
const body = await req.json(); const body = await req.json();
const { schoolId, action, backupId, notes } = body; 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 }); return NextResponse.json({ error: 'Failed to create backup' }, { status: 500 });
} }
} }, 'heavy');
// DELETE /api/backup — Delete a backup // DELETE /api/backup — Delete a backup
export async function DELETE(req: NextRequest) { export async function DELETE(req: NextRequest) {
@@ -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 });
}
}
+135
View File
@@ -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<string, unknown> = {
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 });
}
}
+3 -2
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getSession } from '@/lib/auth'; import { getSession } from '@/lib/auth';
import { withRateLimit } from '@/lib/rate-limit';
function escapeCsvField(field: string | number | null | undefined): string { function escapeCsvField(field: string | number | null | undefined): string {
if (field === null || field === undefined) return ''; if (field === null || field === undefined) return '';
@@ -16,7 +17,7 @@ function toCsvRow(fields: (string | number | null | undefined)[]): string {
return fields.map(escapeCsvField).join(','); return fields.map(escapeCsvField).join(',');
} }
export async function GET(request: Request) { export const GET = withRateLimit(async function GET(request: Request) {
try { try {
const session = await getSession(); const session = await getSession();
if (!session) { if (!session) {
@@ -318,4 +319,4 @@ export async function GET(request: Request) {
console.error('CSV Export error:', error); console.error('CSV Export error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
} }
} }, 'heavy');
+3 -2
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getSession } from '@/lib/auth'; import { getSession } from '@/lib/auth';
import { withRateLimit } from '@/lib/rate-limit';
function escapeCsvField(field: string | number | null | undefined): string { function escapeCsvField(field: string | number | null | undefined): string {
if (field === null || field === undefined) return ''; if (field === null || field === undefined) return '';
@@ -59,7 +60,7 @@ function parseCsv(text: string): { headers: string[]; rows: ImportRow[] } {
return { headers, rows }; return { headers, rows };
} }
export async function POST(request: Request) { export const POST = withRateLimit(async function POST(request: Request) {
try { try {
const session = await getSession(); const session = await getSession();
if (!session) { if (!session) {
@@ -217,7 +218,7 @@ export async function POST(request: Request) {
console.error('Data Import error:', error); console.error('Data Import error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
} }
} }, 'heavy');
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
+97
View File
@@ -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<string, unknown> = {};
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 });
}
}
@@ -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 });
}
}
+60
View File
@@ -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 });
}
}
+16
View File
@@ -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 }
);
}
}
+158
View File
@@ -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<string, unknown> = {};
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;
}
@@ -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;
}
+137
View File
@@ -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;
}
+3 -2
View File
@@ -1,8 +1,9 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getSession } from '@/lib/auth'; 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 { try {
const session = await getSession(); const session = await getSession();
if (!session) { if (!session) {
@@ -105,7 +106,7 @@ export async function GET(request: Request) {
console.error('Report PDF error:', error); console.error('Report PDF error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
} }
} }, 'heavy');
function generatePrintHtml(report: Record<string, unknown>, template: string): string { function generatePrintHtml(report: Record<string, unknown>, template: string): string {
const student = report.student as { firstName: string; lastName: string }; const student = report.student as { firstName: string; lastName: string };
+255
View File
@@ -1548,3 +1548,258 @@
pointer-events: none; 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);
}
}
+19 -2
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "next-themes"; 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.", 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"], keywords: ["CompetenceTrack", "competency", "grading", "school", "education", "Next.js", "TypeScript", "shadcn/ui"],
authors: [{ name: "CompetenceTrack Team" }], authors: [{ name: "CompetenceTrack Team" }],
manifest: "/manifest.json",
icons: { icons: {
icon: "https://z-cdn.chatglm.cn/z-ai/static/logo.svg", 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({ export default function RootLayout({
@@ -30,7 +44,10 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="de" suppressHydrationWarning> <html lang="de" suppressHydrationWarning className="min-h-screen">
<head>
<link rel="apple-touch-icon" href="/icon-192.png" />
</head>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`} className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
> >
+10
View File
@@ -115,6 +115,7 @@ import {
} from '@/components/ui/command'; } from '@/components/ui/command';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useWebSocket, usePushNotifications, playNotificationSound, getNotificationSoundPref } from '@/lib/websocket'; import { useWebSocket, usePushNotifications, playNotificationSound, getNotificationSoundPref } from '@/lib/websocket';
import { OfflineIndicator, PWAInstallPrompt, useServiceWorker, OfflineSyncManager } from '@/components/offline-indicator';
import DashboardView from './dashboard-view'; import DashboardView from './dashboard-view';
import ClassesView from './classes-view'; import ClassesView from './classes-view';
@@ -257,6 +258,9 @@ export default function AppLayout() {
const [schoolYears, setSchoolYears] = useState<SchoolYear[]>([]); const [schoolYears, setSchoolYears] = useState<SchoolYear[]>([]);
const [selectedYearId, setSelectedYearId] = useState<string>(storeSchoolYearId ?? ''); const [selectedYearId, setSelectedYearId] = useState<string>(storeSchoolYearId ?? '');
// Service Worker registration for PWA
useServiceWorker();
// WebSocket connection for real-time push notifications // WebSocket connection for real-time push notifications
const { connected: wsConnected } = useWebSocket(); const { connected: wsConnected } = useWebSocket();
const { notification: pushNotification } = usePushNotifications(); const { notification: pushNotification } = usePushNotifications();
@@ -636,6 +640,12 @@ export default function AppLayout() {
return ( return (
<SidebarProvider> <SidebarProvider>
{/* Offline indicator bar */}
<OfflineIndicator />
{/* PWA install prompt */}
<PWAInstallPrompt />
{/* Offline sync manager */}
<OfflineSyncManager />
<Sidebar <Sidebar
variant="sidebar" variant="sidebar"
collapsible="icon" collapsible="icon"
File diff suppressed because it is too large. Load diff
+507
View File
@@ -0,0 +1,507 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { WifiOff, Wifi, Download, CloudOff, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { t } from '@/lib/i18n';
// ─── Offline Indicator Bar ────────────────────────────────────────────────
export function OfflineIndicator() {
const [isOffline, setIsOffline] = useState(false);
const [wasOffline, setWasOffline] = useState(false);
useEffect(() => {
// Set initial state
setIsOffline(!navigator.onLine);
const handleOnline = () => {
setIsOffline(false);
if (wasOffline) {
toast.success(t('pwa.back_online'), {
description: t('pwa.back_online_desc'),
duration: 4000,
icon: <Wifi className="h-4 w-4 text-emerald-500" />,
});
// Trigger sync of pending requests
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: 'SYNC_PENDING' });
}
}
};
const handleOffline = () => {
setIsOffline(true);
setWasOffline(true);
toast.error(t('pwa.offline'), {
description: t('pwa.offline_desc'),
duration: 6000,
icon: <WifiOff className="h-4 w-4" />,
});
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, [wasOffline]);
// Reset wasOffline after a short delay when back online
useEffect(() => {
if (!isOffline && wasOffline) {
const timer = setTimeout(() => setWasOffline(false), 5000);
return () => clearTimeout(timer);
}
}, [isOffline, wasOffline]);
return (
<AnimatePresence>
{isOffline && (
<motion.div
initial={{ y: -40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -40, opacity: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
className="offline-indicator-bar fixed top-0 left-0 right-0 z-[100] flex items-center justify-center gap-2 px-4 py-2 bg-amber-500 text-white text-sm font-medium shadow-lg shadow-amber-500/20"
>
<WifiOff className="h-4 w-4 shrink-0" />
<span>{t('pwa.offline')}</span>
<span className="hidden sm:inline text-amber-100">— {t('pwa.offline_desc')}</span>
<Badge variant="outline" className="ml-2 text-[10px] border-amber-200/50 text-amber-100 bg-amber-600/40">
<CloudOff className="h-3 w-3 mr-1" />
{t('pwa.cached_data')}
</Badge>
</motion.div>
)}
</AnimatePresence>
);
}
// ─── PWA Install Prompt ───────────────────────────────────────────────────
export function PWAInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [showInstall, setShowInstall] = useState(false);
useEffect(() => {
// Check if the app is already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
return;
}
const handleBeforeInstall = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setShowInstall(true);
};
const handleAppInstalled = () => {
setShowInstall(false);
setDeferredPrompt(null);
toast.success(t('pwa.installed'), {
description: t('pwa.installed_desc'),
duration: 5000,
});
};
window.addEventListener('beforeinstallprompt', handleBeforeInstall);
window.addEventListener('appinstalled', handleAppInstalled);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
window.removeEventListener('appinstalled', handleAppInstalled);
};
}, []);
const handleInstall = useCallback(async () => {
if (!deferredPrompt) return;
try {
await (deferredPrompt as BeforeInstallPromptEvent).prompt();
const result = await (deferredPrompt as BeforeInstallPromptEvent).userChoice;
if (result.outcome === 'accepted') {
toast.success(t('pwa.install_accepted'));
}
} catch {
// Ignore errors
}
setDeferredPrompt(null);
setShowInstall(false);
}, [deferredPrompt]);
if (!showInstall) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
className="fixed bottom-4 right-4 z-50"
>
<div className="flex items-center gap-3 px-4 py-3 bg-white dark:bg-gray-900 rounded-xl border border-emerald-200/60 dark:border-emerald-900/40 shadow-xl shadow-emerald-900/10">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shrink-0">
<Download className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{t('pwa.install_app')}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('pwa.install_app_desc')}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setShowInstall(false)}
className="h-8 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
{t('action.cancel')}
</Button>
<Button
size="sm"
onClick={handleInstall}
className="h-8 text-xs bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-sm"
>
<Download className="h-3.5 w-3.5 mr-1" />
{t('pwa.install')}
</Button>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}
// ─── Service Worker Registration Hook ──────────────────────────────────────
export function useServiceWorker() {
useEffect(() => {
if (typeof window === 'undefined') return;
if (!('serviceWorker' in navigator)) return;
async function registerSW() {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
// Check for updates every 30 minutes
setInterval(() => {
registration.update();
}, 30 * 60 * 1000);
// Handle updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
toast.info(t('pwa.update_available'), {
description: t('pwa.update_available_desc'),
action: {
label: t('pwa.reload'),
onClick: () => window.location.reload(),
},
duration: 10000,
});
}
});
}
});
console.log('[PWA] Service Worker registered successfully');
} catch (error) {
console.warn('[PWA] Service Worker registration failed:', error);
}
}
registerSW();
}, []);
}
// ─── Offline Badge for Notebooks ──────────────────────────────────────────
export function OfflineBadge() {
const [isOffline, setIsOffline] = useState(false);
useEffect(() => {
setIsOffline(!navigator.onLine);
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
if (!isOffline) return null;
return (
<Badge variant="outline" className="text-[10px] border-amber-300/60 dark:border-amber-700/40 text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 gap-1">
<CloudOff className="h-3 w-3" />
{t('pwa.offline_mode')}
</Badge>
);
}
// ─── BeforeInstallPromptEvent type ────────────────────────────────────────
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
// ─── Offline Form Submission Queue ────────────────────────────────────────
const OFFLINE_QUEUE_KEY = 'ct_offline_queue';
interface QueuedRequest {
id: string;
url: string;
method: string;
body: string;
headers: Record<string, string>;
timestamp: number;
retries: number;
}
export function queueOfflineRequest(
url: string,
method: string,
body: string,
headers: Record<string, string> = {}
): void {
try {
const queue: QueuedRequest[] = JSON.parse(localStorage.getItem(OFFLINE_QUEUE_KEY) || '[]');
queue.push({
id: `req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
url,
method,
body,
headers,
timestamp: Date.now(),
retries: 0,
});
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue));
} catch {
// localStorage not available
}
}
export function getOfflineQueue(): QueuedRequest[] {
try {
return JSON.parse(localStorage.getItem(OFFLINE_QUEUE_KEY) || '[]');
} catch {
return [];
}
}
export function clearOfflineQueue(): void {
try {
localStorage.removeItem(OFFLINE_QUEUE_KEY);
} catch {
// ignore
}
}
export function removeOfflineRequest(id: string): void {
try {
const queue: QueuedRequest[] = JSON.parse(localStorage.getItem(OFFLINE_QUEUE_KEY) || '[]');
const filtered = queue.filter((r) => r.id !== id);
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(filtered));
} catch {
// ignore
}
}
// ─── Offline Sync Manager ─────────────────────────────────────────────────
export function OfflineSyncManager() {
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
const updateCount = () => {
setPendingCount(getOfflineQueue().length);
};
updateCount();
// Check every 10 seconds
const interval = setInterval(updateCount, 10000);
// When back online, try to replay queued requests
const handleOnline = async () => {
const queue = getOfflineQueue();
if (queue.length === 0) return;
let successCount = 0;
let failCount = 0;
for (const req of queue) {
try {
const res = await fetch(req.url, {
method: req.method,
headers: { 'Content-Type': 'application/json', ...req.headers },
body: req.method !== 'GET' ? req.body : undefined,
});
if (res.ok) {
removeOfflineRequest(req.id);
successCount++;
} else {
failCount++;
}
} catch {
failCount++;
}
}
if (successCount > 0) {
toast.success(t('pwa.sync_complete'), {
description: t('pwa.sync_complete_desc').replace('{count}', String(successCount)),
duration: 5000,
});
}
updateCount();
};
window.addEventListener('online', handleOnline);
return () => {
clearInterval(interval);
window.removeEventListener('online', handleOnline);
};
}, []);
if (pendingCount === 0) return null;
return (
<div className="fixed bottom-4 left-4 z-50">
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className="flex items-center gap-2 px-3 py-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200/60 dark:border-amber-800/40 rounded-lg shadow-md"
>
<RefreshCw className="h-4 w-4 text-amber-500 animate-spin" />
<span className="text-xs text-amber-700 dark:text-amber-300 font-medium">
{t('pwa.pending_sync').replace('{count}', String(pendingCount))}
</span>
</motion.div>
</div>
);
}
// ─── Rate Limit Toast Handler ───────────────────────────────────────
// This component provides a utility to show rate limit error toasts
// Call showRateLimitToast from any catch block when a 429 is received
export function showRateLimitToast(retryAfterSeconds: number, limit?: number): void {
const retryAfterFormatted = retryAfterSeconds > 60
? `${Math.ceil(retryAfterSeconds / 60)} min`
: `${retryAfterSeconds}s`;
toast.error(t('rate_limit.title'), {
description: t('rate_limit.description')
.replace('{retryAfter}', retryAfterFormatted)
.replace('{limit}', limit ? String(limit) : ''),
duration: retryAfterSeconds * 1000 + 2000,
icon: <Clock className="h-4 w-4" />,
});
}
// ─── Rate Limit Status Card (for admin settings) ────────────────────
export function RateLimitStatus() {
const [stats, setStats] = useState<{
totalEntries: number;
topConsumers: Array<{ key: string; count: number; resetTime: number }>;
} | null>(null);
const [loading, setLoading] = useState(false);
const loadStats = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/rate-limit-stats');
if (res.ok) {
const data = await res.json();
setStats(data);
}
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadStats();
const interval = setInterval(loadStats, 30000);
return () => clearInterval(interval);
}, [loadStats]);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-emerald-700 dark:text-emerald-300 flex items-center gap-1.5">
<Shield className="h-4 w-4" />
{t('rate_limit.status_title')}
</h4>
<Button
variant="ghost"
size="sm"
onClick={loadStats}
className="h-8 text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
>
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${loading ? 'animate-spin' : ''}`} />
{t('action.refresh')}
</Button>
</div>
{stats && (
<div className="grid gap-3">
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-emerald-50/50 dark:bg-emerald-900/10 border border-emerald-200/30 dark:border-emerald-900/20">
<span className="text-xs text-gray-600 dark:text-gray-400">{t('rate_limit.active_entries')}</span>
<Badge variant="outline" className="text-xs border-emerald-200/50 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300">
{stats.totalEntries}
</Badge>
</div>
{stats.topConsumers.length > 0 && (
<div className="space-y-2">
<p className="text-[10px] uppercase tracking-wider font-semibold text-emerald-600/70 dark:text-emerald-400/60">
{t('rate_limit.top_consumers')}
</p>
<ScrollArea className="max-h-48">
{stats.topConsumers.map((consumer) => (
<div key={consumer.key} className="flex items-center justify-between px-2 py-1.5 rounded-lg hover:bg-emerald-50/30 dark:hover:bg-emerald-900/10">
<span className="text-xs text-gray-600 dark:text-gray-400 truncate max-w-[200px]">
{consumer.key}
</span>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className="text-[10px] border-emerald-200/50 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300">
{consumer.count}/{t('rate_limit.requests')}
</Badge>
</div>
</div>
))}
</ScrollArea>
</div>
)}
{stats.topConsumers.length === 0 && (
<div className="px-3 py-4 text-center">
<Shield className="h-6 w-6 mx-auto mb-1 text-gray-300 dark:text-gray-600" />
<p className="text-xs text-gray-500 dark:text-gray-400">{t('rate_limit.no_active')}</p>
</div>
)}
</div>
)}
{!stats && !loading && (
<div className="px-3 py-4 text-center">
<Shield className="h-6 w-6 mx-auto mb-1 text-gray-300 dark:text-gray-600" />
<p className="text-xs text-gray-500 dark:text-gray-400">{t('rate_limit.unavailable')}</p>
</div>
)}
</div>
);
}
// ─── Additional icons import ────────────────────────────────────────
import { Clock, Shield } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
+674 -71
View File
@@ -6,6 +6,8 @@ import {
FileText, Plus, Eye, Download, Check, PenLine, BookOpen, Printer, FileText, Plus, Eye, Download, Check, PenLine, BookOpen, Printer,
School, Clock, Sparkles, FileEdit, FileCheck, FileType, School, Clock, Sparkles, FileEdit, FileCheck, FileType,
CheckCircle, BarChart3, ClipboardList, FileDown, Users, CheckCircle, BarChart3, ClipboardList, FileDown, Users,
CalendarClock, Play, Pause, Trash2, ToggleLeft, ToggleRight,
Calendar, RefreshCw, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -16,13 +18,29 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useAppStore } from '@/lib/store'; import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
fetchClasses, fetchClassStudents, fetchClasses, fetchClassStudents,
fetchReports, createReport, updateReport, getReportPdfUrl, fetchReports, createReport, updateReport, getReportPdfUrl,
fetchCompetenceFlower, addNotification, fetchCompetenceFlower, addNotification,
fetchReportSchedules, createReportSchedule, updateReportSchedule,
deleteReportSchedule, runReportSchedule,
type ClassGroup, type Student, type Report, type ClassGroup, type Student, type Report,
type ReportScheduleData,
} from '@/lib/api'; } from '@/lib/api';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -48,6 +66,29 @@ function reportCompletionPct(r: Report): number {
return Math.round((filled / r.sections.length) * 100); return Math.round((filled / r.sections.length) * 100);
} }
const DAYS_OF_WEEK = [
{ value: 0, label: 'Sonntag' },
{ value: 1, label: 'Montag' },
{ value: 2, label: 'Dienstag' },
{ value: 3, label: 'Mittwoch' },
{ value: 4, label: 'Donnerstag' },
{ value: 5, label: 'Freitag' },
{ value: 6, label: 'Samstag' },
];
const FREQUENCY_OPTIONS = [
{ value: 'weekly', labelKey: 'schedules.weekly' },
{ value: 'monthly', labelKey: 'schedules.monthly' },
{ value: 'quarterly', labelKey: 'schedules.quarterly' },
{ value: 'end_of_term', labelKey: 'schedules.end_of_term' },
];
const TEMPLATE_OPTIONS = [
{ value: 'short', label: 'Short' },
{ value: 'full', label: 'Full' },
{ value: 'custom', label: 'Custom' },
];
export default function ReportsView() { export default function ReportsView() {
const currentUser = useAppStore((s) => s.currentUser); const currentUser = useAppStore((s) => s.currentUser);
const currentClassId = useAppStore((s) => s.currentClassId); const currentClassId = useAppStore((s) => s.currentClassId);
@@ -59,6 +100,7 @@ export default function ReportsView() {
const [selectedReport, setSelectedReport] = useState<Report | null>(null); const [selectedReport, setSelectedReport] = useState<Report | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingReports, setLoadingReports] = useState(false); const [loadingReports, setLoadingReports] = useState(false);
const [activeTab, setActiveTab] = useState('reports');
// Create dialog // Create dialog
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
@@ -79,6 +121,29 @@ export default function ReportsView() {
// Edit section // Edit section
const [editingSection, setEditingSection] = useState<{ id: string; text: string } | null>(null); const [editingSection, setEditingSection] = useState<{ id: string; text: string } | null>(null);
// Schedule state
const [schedules, setSchedules] = useState<ReportScheduleData[]>([]);
const [schedulesLoading, setSchedulesLoading] = useState(false);
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<ReportScheduleData | null>(null);
const [scheduleForm, setScheduleForm] = useState({
template: 'full',
frequency: 'monthly',
classGroupId: '',
dayOfWeek: 1,
dayOfMonth: 1,
monthOfYear: 1,
includeStudents: true,
includeGrades: true,
includeAttendance: true,
includeBehavior: false,
includeCompetencies: true,
});
const [scheduleSaving, setScheduleSaving] = useState(false);
const [deleteScheduleId, setDeleteScheduleId] = useState<string | null>(null);
const [runScheduleId, setRunScheduleId] = useState<string | null>(null);
const [runningSchedule, setRunningSchedule] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
async function load() { async function load() {
try { try {
@@ -97,6 +162,26 @@ export default function ReportsView() {
load(); load();
}, [currentUser?.schoolId]); }, [currentUser?.schoolId]);
// Load schedules
useEffect(() => {
if (currentUser?.schoolId && activeTab === 'schedules') {
loadSchedules();
}
}, [currentUser?.schoolId, activeTab]);
async function loadSchedules() {
if (!currentUser?.schoolId) return;
setSchedulesLoading(true);
try {
const data = await fetchReportSchedules(currentUser.schoolId);
setSchedules(data);
} catch {
// ignore
} finally {
setSchedulesLoading(false);
}
}
async function handleSelectClass(cls: ClassGroup) { async function handleSelectClass(cls: ClassGroup) {
setSelectedClass(cls); setSelectedClass(cls);
useAppStore.getState().setCurrentClass(cls.id); useAppStore.getState().setCurrentClass(cls.id);
@@ -210,6 +295,134 @@ export default function ReportsView() {
} }
}; };
// Schedule handlers
const handleCreateSchedule = async () => {
if (!currentUser?.schoolId) return;
setScheduleSaving(true);
try {
await createReportSchedule({
schoolId: currentUser.schoolId,
template: scheduleForm.template,
frequency: scheduleForm.frequency,
classGroupId: scheduleForm.classGroupId || undefined,
dayOfWeek: scheduleForm.frequency === 'weekly' ? scheduleForm.dayOfWeek : undefined,
dayOfMonth: scheduleForm.frequency === 'monthly' ? scheduleForm.dayOfMonth : undefined,
monthOfYear: scheduleForm.frequency === 'quarterly' ? scheduleForm.monthOfYear : undefined,
includeStudents: scheduleForm.includeStudents,
includeGrades: scheduleForm.includeGrades,
includeAttendance: scheduleForm.includeAttendance,
includeBehavior: scheduleForm.includeBehavior,
includeCompetencies: scheduleForm.includeCompetencies,
});
toast.success(t('schedules.create_schedule'));
setScheduleDialogOpen(false);
resetScheduleForm();
loadSchedules();
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
} finally {
setScheduleSaving(false);
}
};
const handleUpdateSchedule = async () => {
if (!editingSchedule) return;
setScheduleSaving(true);
try {
await updateReportSchedule(editingSchedule.id, {
template: scheduleForm.template,
frequency: scheduleForm.frequency,
classGroupId: scheduleForm.classGroupId || undefined,
dayOfWeek: scheduleForm.frequency === 'weekly' ? scheduleForm.dayOfWeek : null,
dayOfMonth: scheduleForm.frequency === 'monthly' ? scheduleForm.dayOfMonth : null,
monthOfYear: scheduleForm.frequency === 'quarterly' ? scheduleForm.monthOfYear : null,
includeStudents: scheduleForm.includeStudents,
includeGrades: scheduleForm.includeGrades,
includeAttendance: scheduleForm.includeAttendance,
includeBehavior: scheduleForm.includeBehavior,
includeCompetencies: scheduleForm.includeCompetencies,
});
toast.success(t('schedules.edit_schedule'));
setScheduleDialogOpen(false);
setEditingSchedule(null);
resetScheduleForm();
loadSchedules();
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
} finally {
setScheduleSaving(false);
}
};
const handleToggleSchedule = async (schedule: ReportScheduleData) => {
try {
await updateReportSchedule(schedule.id, { isActive: !schedule.isActive });
toast.success(schedule.isActive ? t('schedules.inactive') : t('schedules.active'));
loadSchedules();
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
}
};
const handleDeleteSchedule = async (id: string) => {
try {
await deleteReportSchedule(id);
toast.success(t('schedules.delete_schedule'));
setDeleteScheduleId(null);
loadSchedules();
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
}
};
const handleRunSchedule = async (id: string) => {
setRunningSchedule(id);
try {
const result = await runReportSchedule(id);
toast.success(t('schedules.run_success') + ` (${result.recipientsNotified} ${t('schedules.recipients')})`);
setRunScheduleId(null);
loadSchedules();
} catch (err) {
toast.error(t('schedules.run_error'));
} finally {
setRunningSchedule(null);
}
};
const openEditSchedule = (schedule: ReportScheduleData) => {
setEditingSchedule(schedule);
setScheduleForm({
template: schedule.template,
frequency: schedule.frequency,
classGroupId: schedule.classGroupId ?? '',
dayOfWeek: schedule.dayOfWeek ?? 1,
dayOfMonth: schedule.dayOfMonth ?? 1,
monthOfYear: schedule.monthOfYear ?? 1,
includeStudents: schedule.includeStudents,
includeGrades: schedule.includeGrades,
includeAttendance: schedule.includeAttendance,
includeBehavior: schedule.includeBehavior,
includeCompetencies: schedule.includeCompetencies,
});
setScheduleDialogOpen(true);
};
const resetScheduleForm = () => {
setScheduleForm({
template: 'full',
frequency: 'monthly',
classGroupId: '',
dayOfWeek: 1,
dayOfMonth: 1,
monthOfYear: 1,
includeStudents: true,
includeGrades: true,
includeAttendance: true,
includeBehavior: false,
includeCompetencies: true,
});
};
const draftReports = reports.filter((r) => r.status === 'DRAFT'); const draftReports = reports.filter((r) => r.status === 'DRAFT');
const finalReports = reports.filter((r) => r.status === 'FINAL'); const finalReports = reports.filter((r) => r.status === 'FINAL');
@@ -235,6 +448,21 @@ export default function ReportsView() {
</div> </div>
</div> </div>
{/* Tabs: Reports + Schedules */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="bg-emerald-50 dark:bg-emerald-900/20 rounded-xl flex gap-1 h-auto p-1">
<TabsTrigger value="reports" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white min-h-[44px]">
<FileText className="h-4 w-4 mr-1.5" />
<span className="hidden sm:inline">{t('reports.title')}</span>
</TabsTrigger>
<TabsTrigger value="schedules" className="rounded-lg data-[state=active]:bg-teal-500 data-[state=active]:text-white min-h-[44px]">
<CalendarClock className="h-4 w-4 mr-1.5" />
<span className="hidden sm:inline">{t('schedules.title')}</span>
</TabsTrigger>
</TabsList>
{/* ── Reports Tab ─────────────────────────────────────────── */}
<TabsContent value="reports">
{/* Selection header */} {/* Selection header */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden"> <Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="p-6"> <CardContent className="p-6">
@@ -273,7 +501,6 @@ export default function ReportsView() {
{!selectedClass ? ( {!selectedClass ? (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-4"> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-4">
<Card className="relative border-0 shadow-sm rounded-xl overflow-hidden"> <Card className="relative border-0 shadow-sm rounded-xl overflow-hidden">
{/* Subtle dot grid pattern background */}
<div className="absolute inset-0 bg-dots opacity-30 pointer-events-none" /> <div className="absolute inset-0 bg-dots opacity-30 pointer-events-none" />
<CardContent className="relative py-16 text-center"> <CardContent className="relative py-16 text-center">
<motion.div <motion.div
@@ -309,7 +536,6 @@ export default function ReportsView() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{/* Draft */}
<div className="p-4 rounded-xl bg-gradient-to-br from-amber-50/60 to-amber-50/0 dark:from-amber-900/15 dark:to-amber-900/0 border border-amber-100/60 dark:border-amber-900/30"> <div className="p-4 rounded-xl bg-gradient-to-br from-amber-50/60 to-amber-50/0 dark:from-amber-900/15 dark:to-amber-900/0 border border-amber-100/60 dark:border-amber-900/30">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm">
@@ -323,7 +549,6 @@ export default function ReportsView() {
{t('polish.report_template_draft_desc')} {t('polish.report_template_draft_desc')}
</p> </p>
</div> </div>
{/* Final */}
<div className="p-4 rounded-xl bg-gradient-to-br from-emerald-50/60 to-emerald-50/0 dark:from-emerald-900/15 dark:to-emerald-900/0 border border-emerald-100/60 dark:border-emerald-900/30"> <div className="p-4 rounded-xl bg-gradient-to-br from-emerald-50/60 to-emerald-50/0 dark:from-emerald-900/15 dark:to-emerald-900/0 border border-emerald-100/60 dark:border-emerald-900/30">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-500 text-white shadow-sm"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-500 text-white shadow-sm">
@@ -337,7 +562,6 @@ export default function ReportsView() {
{t('polish.report_template_final_desc')} {t('polish.report_template_final_desc')}
</p> </p>
</div> </div>
{/* PDF Export */}
<div className="p-4 rounded-xl bg-gradient-to-br from-teal-50/60 to-teal-50/0 dark:from-teal-900/15 dark:to-teal-900/0 border border-teal-100/60 dark:border-teal-900/30"> <div className="p-4 rounded-xl bg-gradient-to-br from-teal-50/60 to-teal-50/0 dark:from-teal-900/15 dark:to-teal-900/0 border border-teal-100/60 dark:border-teal-900/30">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-teal-400 to-teal-500 text-white shadow-sm"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-teal-400 to-teal-500 text-white shadow-sm">
@@ -592,9 +816,9 @@ export default function ReportsView() {
{selectedReport.sections.filter((s) => s.generatedText && s.generatedText.trim().length > 0).length} / {selectedReport.sections.length} {selectedReport.sections.filter((s) => s.generatedText && s.generatedText.trim().length > 0).length} / {selectedReport.sections.length}
</span> </span>
</div> </div>
<div className="h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden"> <div className="report-progress">
<div <div
className={`h-full rounded-full transition-all duration-500 ${ className={`report-progress-fill ${
reportCompletionPct(selectedReport) === 100 reportCompletionPct(selectedReport) === 100
? 'bg-gradient-to-r from-emerald-400 to-teal-500' ? 'bg-gradient-to-r from-emerald-400 to-teal-500'
: 'bg-gradient-to-r from-amber-400 to-amber-500' : 'bg-gradient-to-r from-amber-400 to-amber-500'
@@ -671,6 +895,275 @@ export default function ReportsView() {
</motion.div> </motion.div>
)} )}
{/* PDF Template Selection + Batch Generate */}
{selectedClass && students.length > 0 && (
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden mt-6">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<FileDown className="h-4 w-4" />
</div>
{t('reports.generate_pdf')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Label className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('reports.template')}</Label>
<div className="flex gap-2">
{(['short', 'full', 'custom'] as const).map((tmpl) => (
<Button
key={tmpl}
variant={pdfTemplate === tmpl ? 'default' : 'outline'}
size="sm"
className={pdfTemplate === tmpl ? 'bg-emerald-500 hover:bg-emerald-600 text-white min-h-[44px] rounded-xl' : 'min-h-[44px] rounded-xl'}
onClick={() => setPdfTemplate(tmpl)}
>
<span className="badge-type">{t(`reports.template_${tmpl}`)}</span>
</Button>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md rounded-xl min-h-[44px]"
onClick={handleBatchGenerate}
disabled={batchGenerating}
>
<Users className="h-4 w-4 mr-1.5" />
{batchGenerating ? t('empty.loading') : t('reports.batch_generate')}
</Button>
</div>
{downloadHistory.length > 0 && (
<div className="space-y-2 mt-2">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t('reports.download_history')}</p>
<div className="max-h-32 overflow-y-auto scrollbar-education space-y-1">
{downloadHistory.map((item) => (
<div key={item.id} className="flex items-center justify-between text-xs p-2 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<span className="text-gray-700 dark:text-gray-300">{item.studentName}</span>
<span className="text-gray-500 dark:text-gray-400">{item.template} · {item.date}</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
</TabsContent>
{/* ── Schedules Tab ─────────────────────────────────────────── */}
<TabsContent value="schedules">
<div className="space-y-6">
{/* Header */}
<Card className="border-0 shadow-sm rounded-xl 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">
<div className="flex items-center justify-between">
<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-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<CalendarClock className="h-4 w-4" />
</div>
{t('schedules.title')}
</CardTitle>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<Button
className="bg-gradient-to-r from-teal-500 to-emerald-500 hover:from-teal-600 hover:to-emerald-600 text-white shadow-md shadow-teal-300/20 rounded-xl min-h-[44px]"
onClick={() => { resetScheduleForm(); setEditingSchedule(null); setScheduleDialogOpen(true); }}
>
<Plus className="h-4 w-4 mr-1.5" />
{t('schedules.create_schedule')}
</Button>
</motion.div>
</div>
</CardHeader>
<CardContent>
{schedulesLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-xl" />)}
</div>
) : schedules.length === 0 ? (
<div className="text-center py-12">
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-teal-100 to-emerald-100 dark:from-teal-900/30 dark:to-emerald-900/30 mx-auto mb-5 shadow-md shadow-teal-200/40 dark:shadow-teal-900/20">
<CalendarClock className="h-10 w-10 text-teal-500 dark:text-teal-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('schedules.no_schedules')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{t('schedules.no_schedules_desc')}</p>
<Button className="mt-5 bg-gradient-to-r from-teal-500 to-emerald-500 hover:from-teal-600 hover:to-emerald-600 text-white rounded-xl min-h-[44px]" onClick={() => { resetScheduleForm(); setEditingSchedule(null); setScheduleDialogOpen(true); }}>
<Plus className="h-4 w-4 mr-1" />
{t('schedules.create_schedule')}
</Button>
</div>
) : (
<div className="schedule-timeline space-y-0">
{schedules.map((schedule) => {
const freqLabel = t(`schedules.${schedule.frequency}`);
const isRunning = runningSchedule === schedule.id;
return (
<div key={schedule.id} className={`schedule-timeline-item ${!schedule.isActive ? 'inactive' : ''}`}>
<div className="p-4 rounded-xl bg-gray-50/80 dark:bg-gray-800/30 border border-gray-100 dark:border-gray-800 hover:shadow-md transition-shadow">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className={`flex items-center justify-center w-9 h-9 rounded-lg shrink-0 ${
schedule.isActive
? 'bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500'
}`}>
<CalendarClock className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm">
{schedule.template.charAt(0).toUpperCase() + schedule.template.slice(1)} · {freqLabel}
</p>
<Badge className={`text-[10px] px-1.5 py-0 rounded-xl ${
schedule.isActive
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-gray-100 text-gray-500 dark:bg-gray-900/30 dark:text-gray-400'
}`}>
{schedule.isActive ? t('schedules.active') : t('schedules.inactive')}
</Badge>
{schedule.classGroup && (
<Badge className="bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 text-[10px] px-1.5 py-0 rounded-xl">
{schedule.classGroup.name}
</Badge>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
{schedule.nextRunAt && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{t('schedules.next_run')}: {new Date(schedule.nextRunAt).toLocaleDateString()}
</span>
)}
{schedule.lastRunAt && (
<span className="flex items-center gap-1">
<RefreshCw className="h-3 w-3" />
{t('schedules.last_run')}: {relativeDate(schedule.lastRunAt)}
</span>
)}
</div>
{/* Include options */}
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
{schedule.includeStudents && <Badge className="bg-emerald-50 text-emerald-600 dark:bg-emerald-900/20 dark:text-emerald-400 text-[9px] px-1 py-0 rounded-lg">{t('schedules.include_students')}</Badge>}
{schedule.includeGrades && <Badge className="bg-amber-50 text-amber-600 dark:bg-amber-900/20 dark:text-amber-400 text-[9px] px-1 py-0 rounded-lg">{t('schedules.include_grades')}</Badge>}
{schedule.includeAttendance && <Badge className="bg-teal-50 text-teal-600 dark:bg-teal-900/20 dark:text-teal-400 text-[9px] px-1 py-0 rounded-lg">{t('schedules.include_attendance')}</Badge>}
{schedule.includeBehavior && <Badge className="bg-rose-50 text-rose-600 dark:bg-rose-900/20 dark:text-rose-400 text-[9px] px-1 py-0 rounded-lg">{t('schedules.include_behavior')}</Badge>}
{schedule.includeCompetencies && <Badge className="bg-violet-50 text-violet-600 dark:bg-violet-900/20 dark:text-violet-400 text-[9px] px-1 py-0 rounded-lg">{t('schedules.include_competencies')}</Badge>}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="ghost"
size="sm"
className="min-h-[44px] min-w-[44px] rounded-xl"
onClick={() => handleToggleSchedule(schedule)}
aria-label={t('schedules.toggle_active')}
>
{schedule.isActive ? (
<ToggleRight className="h-5 w-5 text-emerald-500" />
) : (
<ToggleLeft className="h-5 w-5 text-gray-400" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="min-h-[44px] min-w-[44px] rounded-xl text-teal-600 hover:text-teal-700 dark:text-teal-400 dark:hover:text-teal-300"
onClick={() => setRunScheduleId(schedule.id)}
disabled={isRunning || !schedule.isActive}
aria-label={t('schedules.manual_run')}
>
{isRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="sm"
className="min-h-[44px] min-w-[44px] rounded-xl text-gray-400 hover:text-amber-500"
onClick={() => openEditSchedule(schedule)}
aria-label={t('action.edit')}
>
<PenLine className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="min-h-[44px] min-w-[44px] rounded-xl text-gray-400 hover:text-rose-500"
onClick={() => setDeleteScheduleId(schedule.id)}
aria-label={t('action.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Schedule Calendar Preview */}
{schedules.length > 0 && (
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 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">
<CardTitle className="text-base 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">
<Calendar className="h-4 w-4" />
</div>
{t('schedules.timeline')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{schedules.filter((s) => s.isActive && s.nextRunAt).sort((a, b) => new Date(a.nextRunAt!).getTime() - new Date(b.nextRunAt!).getTime()).map((schedule) => {
const nextRun = new Date(schedule.nextRunAt!);
const daysUntil = Math.ceil((nextRun.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
return (
<div key={schedule.id} className="p-4 rounded-xl bg-gradient-to-br from-emerald-50/60 to-teal-50/30 dark:from-emerald-900/15 dark:to-teal-900/10 border border-emerald-200/40 dark:border-emerald-900/20">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
<CalendarClock className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{schedule.template.charAt(0).toUpperCase() + schedule.template.slice(1)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t(`schedules.${schedule.frequency}`)}
</p>
</div>
</div>
<div className="mt-2">
<p className="text-xs text-emerald-600/70 dark:text-emerald-400/60 font-medium">
{t('schedules.next_run')}
</p>
<p className="text-sm font-bold text-gray-900 dark:text-gray-100">
{nextRun.toLocaleDateString()}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{daysUntil > 0 ? `${daysUntil} days` : t('date.today')}
</p>
</div>
{/* Progress bar showing time until next run */}
<div className="report-progress mt-2">
<div
className="report-progress-fill bg-gradient-to-r from-emerald-400 to-teal-500"
style={{ width: `${Math.max(10, 100 - (daysUntil / 30) * 100)}%` }}
/>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
</TabsContent>
</Tabs>
{/* Create report dialog */} {/* Create report dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="rounded-xl"> <DialogContent className="rounded-xl">
@@ -730,9 +1223,7 @@ export default function ReportsView() {
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
<DialogTitle>{t('polish.print_preview')}</DialogTitle> <DialogTitle>{t('polish.print_preview')}</DialogTitle>
</DialogHeader> </DialogHeader>
{/* Paper-style preview */}
<div className="paper-preview px-8 py-10 sm:px-12 sm:py-14 max-h-[80vh] overflow-y-auto scrollbar-education print-avoid-break"> <div className="paper-preview px-8 py-10 sm:px-12 sm:py-14 max-h-[80vh] overflow-y-auto scrollbar-education print-avoid-break">
{/* School header */}
<div className="flex items-center justify-between border-b-2 border-emerald-600/30 pb-4 mb-6"> <div className="flex items-center justify-between border-b-2 border-emerald-600/30 pb-4 mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-md shadow-emerald-300/30"> <div className="flex items-center justify-center w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-md shadow-emerald-300/30">
@@ -752,8 +1243,6 @@ export default function ReportsView() {
<p className="text-sm font-bold text-gray-900 dark:text-gray-100">{selectedReport?.period}</p> <p className="text-sm font-bold text-gray-900 dark:text-gray-100">{selectedReport?.period}</p>
</div> </div>
</div> </div>
{/* Student info */}
<div className="mb-6"> <div className="mb-6">
<p className="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-400">{t('polish.student_value')}</p> <p className="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-400">{t('polish.student_value')}</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100"> <p className="text-xl font-bold text-gray-900 dark:text-gray-100">
@@ -763,8 +1252,6 @@ export default function ReportsView() {
{selectedReport?.classGroup.name} · {t('label.grade')} {selectedReport?.classGroup.gradeLevel} {selectedReport?.classGroup.name} · {t('label.grade')} {selectedReport?.classGroup.gradeLevel}
</p> </p>
</div> </div>
{/* Sections */}
<div className="space-y-5"> <div className="space-y-5">
{selectedReport?.sections.map((section) => ( {selectedReport?.sections.map((section) => (
<div key={section.id} className="space-y-1.5"> <div key={section.id} className="space-y-1.5">
@@ -785,8 +1272,6 @@ export default function ReportsView() {
</div> </div>
))} ))}
</div> </div>
{/* Footer signature line */}
<div className="mt-12 pt-6 border-t border-gray-300 dark:border-gray-700 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"> <div className="mt-12 pt-6 border-t border-gray-300 dark:border-gray-700 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<div> <div>
<p className="mb-6">{t('reports.generated_by')}:</p> <p className="mb-6">{t('reports.generated_by')}:</p>
@@ -800,8 +1285,6 @@ export default function ReportsView() {
</div> </div>
</div> </div>
</div> </div>
{/* Footer with action buttons */}
<div className="flex items-center justify-between px-4 py-3 border-t border-emerald-200/50 dark:border-emerald-900/30 bg-gradient-to-r from-emerald-50/30 to-transparent dark:from-emerald-900/10"> <div className="flex items-center justify-between px-4 py-3 border-t border-emerald-200/50 dark:border-emerald-900/30 bg-gradient-to-r from-emerald-50/30 to-transparent dark:from-emerald-900/10">
<span className="text-xs text-emerald-600/70 dark:text-emerald-400/60 flex items-center gap-1"> <span className="text-xs text-emerald-600/70 dark:text-emerald-400/60 flex items-center gap-1">
<Eye className="h-3 w-3" /> <Eye className="h-3 w-3" />
@@ -812,61 +1295,6 @@ export default function ReportsView() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* PDF Template Selection + Batch Generate */}
{selectedClass && students.length > 0 && (
<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">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<FileDown className="h-4 w-4" />
</div>
{t('reports.generate_pdf')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Label className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('reports.template')}</Label>
<div className="flex gap-2">
{(['short', 'full', 'custom'] as const).map((tmpl) => (
<Button
key={tmpl}
variant={pdfTemplate === tmpl ? 'default' : 'outline'}
size="sm"
className={pdfTemplate === tmpl ? 'bg-emerald-500 hover:bg-emerald-600 text-white min-h-[44px] rounded-xl' : 'min-h-[44px] rounded-xl'}
onClick={() => setPdfTemplate(tmpl)}
>
<span className="badge-type">{t(`reports.template_${tmpl}`)}</span>
</Button>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md rounded-xl min-h-[44px]"
onClick={handleBatchGenerate}
disabled={batchGenerating}
>
<Users className="h-4 w-4 mr-1.5" />
{batchGenerating ? t('empty.loading') : t('reports.batch_generate')}
</Button>
</div>
{downloadHistory.length > 0 && (
<div className="space-y-2 mt-2">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t('reports.download_history')}</p>
<div className="max-h-32 overflow-y-auto scrollbar-education space-y-1">
{downloadHistory.map((item) => (
<div key={item.id} className="flex items-center justify-between text-xs p-2 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<span className="text-gray-700 dark:text-gray-300">{item.studentName}</span>
<span className="text-gray-500 dark:text-gray-400">{item.template} · {item.date}</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Edit section dialog */} {/* Edit section dialog */}
<Dialog open={!!editingSection} onOpenChange={() => setEditingSection(null)}> <Dialog open={!!editingSection} onOpenChange={() => setEditingSection(null)}>
<DialogContent className="rounded-xl"> <DialogContent className="rounded-xl">
@@ -887,6 +1315,181 @@ export default function ReportsView() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Schedule Create/Edit Dialog */}
<Dialog open={scheduleDialogOpen} onOpenChange={(open) => { if (!open) { setScheduleDialogOpen(false); setEditingSchedule(null); } }}>
<DialogContent className="rounded-xl max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{editingSchedule ? t('schedules.edit_schedule') : t('schedules.create_schedule')}
</DialogTitle>
</DialogHeader>
<div className="space-y-5 pt-2 max-h-[65vh] overflow-y-auto scrollbar-education pr-2">
{/* Template */}
<div className="space-y-2">
<Label className="text-sm font-medium">{t('schedules.template')}</Label>
<Select value={scheduleForm.template} onValueChange={(v) => setScheduleForm({ ...scheduleForm, template: v })}>
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TEMPLATE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Frequency */}
<div className="space-y-2">
<Label className="text-sm font-medium">{t('schedules.frequency')}</Label>
<Select value={scheduleForm.frequency} onValueChange={(v) => setScheduleForm({ ...scheduleForm, frequency: v })}>
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREQUENCY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{t(opt.labelKey)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Class */}
<div className="space-y-2">
<Label className="text-sm font-medium">{t('label.class')}</Label>
<Select value={scheduleForm.classGroupId} onValueChange={(v) => setScheduleForm({ ...scheduleForm, classGroupId: v })}>
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
<SelectValue placeholder={t('schedules.all_classes')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{t('schedules.all_classes')}</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Day of week (for weekly) */}
{scheduleForm.frequency === 'weekly' && (
<div className="space-y-2">
<Label className="text-sm font-medium">{t('schedules.day_of_week')}</Label>
<Select value={String(scheduleForm.dayOfWeek)} onValueChange={(v) => setScheduleForm({ ...scheduleForm, dayOfWeek: parseInt(v) })}>
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DAYS_OF_WEEK.map((d) => (
<SelectItem key={d.value} value={String(d.value)}>{d.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Day of month (for monthly) */}
{scheduleForm.frequency === 'monthly' && (
<div className="space-y-2">
<Label className="text-sm font-medium">{t('schedules.day_of_month')}</Label>
<Input
type="number"
min={1}
max={31}
value={scheduleForm.dayOfMonth}
onChange={(e) => setScheduleForm({ ...scheduleForm, dayOfMonth: parseInt(e.target.value) || 1 })}
className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30"
/>
</div>
)}
{/* Month of year (for quarterly) */}
{scheduleForm.frequency === 'quarterly' && (
<div className="space-y-2">
<Label className="text-sm font-medium">{t('schedules.month_of_year')}</Label>
<Select value={String(scheduleForm.monthOfYear)} onValueChange={(v) => setScheduleForm({ ...scheduleForm, monthOfYear: parseInt(v) })}>
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<SelectItem key={m} value={String(m)}>{new Date(2024, m - 1).toLocaleString(undefined, { month: 'long' })}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Section divider */}
<div className="section-divider" />
{/* Include options */}
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('schedules.recipients')}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{[
{ key: 'includeStudents', label: t('schedules.include_students') },
{ key: 'includeGrades', label: t('schedules.include_grades') },
{ key: 'includeAttendance', label: t('schedules.include_attendance') },
{ key: 'includeBehavior', label: t('schedules.include_behavior') },
{ key: 'includeCompetencies', label: t('schedules.include_competencies') },
].map((item) => (
<div key={item.key} className="flex items-center gap-2 p-2 rounded-lg bg-gray-50 dark:bg-gray-800/30">
<Checkbox
checked={scheduleForm[item.key as keyof typeof scheduleForm] as boolean}
onCheckedChange={(checked) => setScheduleForm({ ...scheduleForm, [item.key]: !!checked })}
/>
<Label className="text-xs font-medium text-gray-700 dark:text-gray-300">{item.label}</Label>
</div>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setScheduleDialogOpen(false); setEditingSchedule(null); }} className="rounded-xl">{t('action.cancel')}</Button>
<Button
className="bg-gradient-to-r from-teal-500 to-emerald-500 hover:from-teal-600 hover:to-emerald-600 text-white rounded-xl shadow-md min-h-[44px]"
onClick={editingSchedule ? handleUpdateSchedule : handleCreateSchedule}
disabled={scheduleSaving}
>
{scheduleSaving ? t('empty.loading') : (editingSchedule ? t('action.save') : t('schedules.create_schedule'))}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Schedule Confirmation */}
<AlertDialog open={!!deleteScheduleId} onOpenChange={(open) => { if (!open) setDeleteScheduleId(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('schedules.confirm_delete')}</AlertDialogTitle>
<AlertDialogDescription>{t('schedules.confirm_delete_desc')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('action.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteScheduleId && handleDeleteSchedule(deleteScheduleId)} className="bg-rose-600 hover:bg-rose-700 text-white">
{t('action.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Run Schedule Confirmation */}
<AlertDialog open={!!runScheduleId} onOpenChange={(open) => { if (!open) setRunScheduleId(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('schedules.confirm_run')}</AlertDialogTitle>
<AlertDialogDescription>{t('schedules.confirm_run_desc')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('action.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => runScheduleId && handleRunSchedule(runScheduleId)} className="bg-teal-600 hover:bg-teal-700 text-white">
<Play className="h-4 w-4 mr-1.5" />
{t('schedules.manual_run')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</motion.div> </motion.div>
); );
} }
+694 -9
View File
@@ -46,6 +46,11 @@ import {
Volume2, Volume2,
VolumeX, VolumeX,
Bell, Bell,
MapPin,
Building2,
ArrowRightLeft,
TrendingDown,
ArrowUpRight,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -113,7 +118,16 @@ import {
type Subject, type Subject,
type AuditLogEntry, type AuditLogEntry,
type UserAccount, type UserAccount,
exportAuditLogCsv,
type SchoolDistrictData,
fetchDistricts,
createDistrict,
updateDistrict,
deleteDistrict,
fetchDistrictSchools,
assignSchoolToDistrict,
} from '@/lib/api'; } from '@/lib/api';
import { RateLimitStatus } from '@/components/offline-indicator';
const containerVariants = { const containerVariants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
@@ -204,9 +218,13 @@ export default function SettingsView() {
const [auditEntries, setAuditEntries] = useState<AuditLogEntry[]>([]); const [auditEntries, setAuditEntries] = useState<AuditLogEntry[]>([]);
const [auditLoading, setAuditLoading] = useState(true); const [auditLoading, setAuditLoading] = useState(true);
const [auditActionFilter, setAuditActionFilter] = useState('all'); const [auditActionFilter, setAuditActionFilter] = useState('all');
const [auditEntityTypeFilter, setAuditEntityTypeFilter] = useState('all');
const [auditDateFrom, setAuditDateFrom] = useState(''); const [auditDateFrom, setAuditDateFrom] = useState('');
const [auditDateTo, setAuditDateTo] = useState(''); const [auditDateTo, setAuditDateTo] = useState('');
const [auditSearch, setAuditSearch] = useState(''); const [auditSearch, setAuditSearch] = useState('');
const [auditPage, setAuditPage] = useState(1);
const [auditTotalPages, setAuditTotalPages] = useState(1);
const [auditDetailEntry, setAuditDetailEntry] = useState<AuditLogEntry | null>(null);
// Data erasure state // Data erasure state
const [showErasureDialog, setShowErasureDialog] = useState(false); const [showErasureDialog, setShowErasureDialog] = useState(false);
@@ -294,6 +312,23 @@ export default function SettingsView() {
const [autoBackupEnabled, setAutoBackupEnabled] = useState(false); const [autoBackupEnabled, setAutoBackupEnabled] = useState(false);
const [autoBackupFrequency, setAutoBackupFrequency] = useState<'daily' | 'weekly'>('weekly'); const [autoBackupFrequency, setAutoBackupFrequency] = useState<'daily' | 'weekly'>('weekly');
// District state
const [districts, setDistricts] = useState<SchoolDistrictData[]>([]);
const [districtsLoading, setDistrictsLoading] = useState(false);
const [districtDialogOpen, setDistrictDialogOpen] = useState(false);
const [editingDistrict, setEditingDistrict] = useState<SchoolDistrictData | null>(null);
const [districtForm, setDistrictForm] = useState({ name: '', code: '', region: '', country: 'DE', adminEmail: '' });
const [districtSaving, setDistrictSaving] = useState(false);
const [deleteDistrictId, setDeleteDistrictId] = useState<string | null>(null);
const [districtSchools, setDistrictSchools] = useState<Array<{
id: string; name: string; schoolType: string; country: string;
_count: { students: number; classGroups: number; users: number };
}>>([]);
const [districtSchoolsLoading, setDistrictSchoolsLoading] = useState(false);
const [selectedDistrictId, setSelectedDistrictId] = useState<string | null>(null);
const [assignSchoolId, setAssignSchoolId] = useState('');
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN';
const isAdmin = currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN'; const isAdmin = currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const loadSchools = useCallback(async () => { const loadSchools = useCallback(async () => {
@@ -359,16 +394,20 @@ export default function SettingsView() {
const data = await fetchAuditLog({ const data = await fetchAuditLog({
schoolId: currentUser?.schoolId ?? undefined, schoolId: currentUser?.schoolId ?? undefined,
action: auditActionFilter !== 'all' ? auditActionFilter : undefined, action: auditActionFilter !== 'all' ? auditActionFilter : undefined,
entityType: auditEntityTypeFilter !== 'all' ? auditEntityTypeFilter : undefined,
startDate: auditDateFrom || undefined, startDate: auditDateFrom || undefined,
endDate: auditDateTo || undefined, endDate: auditDateTo || undefined,
page: auditPage,
pageSize: 50,
}); });
setAuditEntries(data); setAuditEntries(data.entries);
setAuditTotalPages(data.pagination.totalPages);
} catch { } catch {
toast.error(t('error.generic')); toast.error(t('error.generic'));
} finally { } finally {
setAuditLoading(false); setAuditLoading(false);
} }
}, [currentUser?.schoolId, auditActionFilter, auditDateFrom, auditDateTo]); }, [currentUser?.schoolId, auditActionFilter, auditEntityTypeFilter, auditDateFrom, auditDateTo, auditPage]);
const loadUsers = useCallback(async () => { const loadUsers = useCallback(async () => {
setUsersLoading(true); setUsersLoading(true);
@@ -425,7 +464,133 @@ export default function SettingsView() {
if (tab === 'backup') { if (tab === 'backup') {
loadBackups(); loadBackups();
} }
}, [isAdmin, loadDemoAccounts, loadBackups]); if (tab === 'district' && isSuperAdmin) {
loadDistricts();
}
}, [isAdmin, loadDemoAccounts, loadBackups, isSuperAdmin]);
// District handlers
const loadDistricts = useCallback(async () => {
setDistrictsLoading(true);
try {
const data = await fetchDistricts();
setDistricts(data);
} catch {
// ignore
} finally {
setDistrictsLoading(false);
}
}, []);
const loadDistrictSchoolsData = useCallback(async (districtId: string) => {
setSelectedDistrictId(districtId);
setDistrictSchoolsLoading(true);
try {
const data = await fetchDistrictSchools(districtId);
setDistrictSchools(data);
} catch {
// ignore
} finally {
setDistrictSchoolsLoading(false);
}
}, []);
const handleCreateDistrict = async () => {
setDistrictSaving(true);
try {
await createDistrict({
name: districtForm.name,
code: districtForm.code || undefined,
region: districtForm.region || undefined,
country: districtForm.country,
adminEmail: districtForm.adminEmail || undefined,
});
toast.success(t('district.create'));
setDistrictDialogOpen(false);
resetDistrictForm();
loadDistricts();
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
} finally {
setDistrictSaving(false);
}
};
const handleUpdateDistrict = async () => {
if (!editingDistrict) return;
setDistrictSaving(true);
try {
await updateDistrict(editingDistrict.id, {
name: districtForm.name,
code: districtForm.code || undefined,
region: districtForm.region || undefined,
country: districtForm.country,
adminEmail: districtForm.adminEmail || undefined,
});
toast.success(t('district.edit'));
setDistrictDialogOpen(false);
setEditingDistrict(null);
resetDistrictForm();
loadDistricts();
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
} finally {
setDistrictSaving(false);
}
};
const handleDeleteDistrict = async (id: string) => {
try {
await deleteDistrict(id);
toast.success(t('district.delete'));
setDeleteDistrictId(null);
if (selectedDistrictId === id) setSelectedDistrictId(null);
loadDistricts();
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
}
};
const handleAssignSchool = async () => {
if (!selectedDistrictId || !assignSchoolId) return;
try {
await assignSchoolToDistrict(selectedDistrictId, assignSchoolId);
toast.success(t('district.assign_school'));
setAssignSchoolId('');
loadDistrictSchoolsData(selectedDistrictId);
loadDistricts();
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
}
};
const handleUnassignSchool = async (schoolId: string) => {
// Unassign by setting districtId to null on the school
try {
await apiPut(`/api/schools`, { id: schoolId, districtId: null });
toast.success(t('district.unassign_school'));
if (selectedDistrictId) loadDistrictSchoolsData(selectedDistrictId);
loadDistricts();
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
}
};
const openEditDistrict = (district: SchoolDistrictData) => {
setEditingDistrict(district);
setDistrictForm({
name: district.name,
code: district.code ?? '',
region: district.region ?? '',
country: district.country,
adminEmail: district.adminEmail ?? '',
});
setDistrictDialogOpen(true);
};
const resetDistrictForm = () => {
setDistrictForm({ name: '', code: '', region: '', country: 'DE', adminEmail: '' });
};
const handleCreateBackup = useCallback(async () => { const handleCreateBackup = useCallback(async () => {
if (!currentUser?.schoolId) return; if (!currentUser?.schoolId) return;
@@ -962,6 +1127,16 @@ export default function SettingsView() {
<HardDrive className="h-4 w-4 mr-1.5" /> <HardDrive className="h-4 w-4 mr-1.5" />
<span className="hidden sm:inline">{t('backup.title')}</span> <span className="hidden sm:inline">{t('backup.title')}</span>
</TabsTrigger> </TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="district" className="rounded-lg min-h-[44px] data-[state=active]:bg-violet-500 data-[state=active]:text-white">
<Building2 className="h-4 w-4 mr-1.5" />
<span className="hidden sm:inline">{t('district.title')}</span>
</TabsTrigger>
)}
<TabsTrigger value="rate-limit" className="rounded-lg min-h-[44px] data-[state=active]:bg-rose-500 data-[state=active]:text-white">
<Shield className="h-4 w-4 mr-1.5" />
<span className="hidden sm:inline">{t('rate_limit.status_title')}</span>
</TabsTrigger>
</TabsList> </TabsList>
{/* ── School Info Tab ─────────────────────────────────── */} {/* ── School Info Tab ─────────────────────────────────── */}
@@ -1692,12 +1867,29 @@ export default function SettingsView() {
<TabsContent value="audit"> <TabsContent value="audit">
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden"> <Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
<CardHeader className="bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent"> <CardHeader className="bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
<Shield className="h-4 w-4" /> <Shield className="h-4 w-4" />
</div> </div>
{t('settings.audit_title')} {t('settings.audit_title')}
</CardTitle> </CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => exportAuditLogCsv({
schoolId: currentUser?.schoolId ?? undefined,
action: auditActionFilter !== 'all' ? auditActionFilter : undefined,
entityType: auditEntityTypeFilter !== 'all' ? auditEntityTypeFilter : undefined,
startDate: auditDateFrom || undefined,
endDate: auditDateTo || undefined,
})}
className="h-8 rounded-lg border-violet-200 dark:border-violet-900/30 text-violet-700 dark:text-violet-300 min-h-[36px]"
>
<FileDown className="h-3.5 w-3.5 mr-1" />
{t('audit.export_csv')}
</Button>
</div>
<CardDescription>{t('settings.audit_action')} & {t('settings.audit_timestamp')}</CardDescription> <CardDescription>{t('settings.audit_action')} & {t('settings.audit_timestamp')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -1724,8 +1916,8 @@ export default function SettingsView() {
].map((chip) => ( ].map((chip) => (
<button <button
key={chip.value} key={chip.value}
onClick={() => setAuditActionFilter(chip.value)} onClick={() => { setAuditActionFilter(chip.value); setAuditPage(1); }}
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-all ${ className={`px-2.5 py-1 rounded-md text-xs font-medium transition-all min-h-[36px] ${
auditActionFilter === chip.value auditActionFilter === chip.value
? `${chip.color} ring-1 shadow-sm` ? `${chip.color} ring-1 shadow-sm`
: 'bg-white/60 dark:bg-gray-800/40 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 border border-gray-200/50 dark:border-gray-700/30' : 'bg-white/60 dark:bg-gray-800/40 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 border border-gray-200/50 dark:border-gray-700/30'
@@ -1736,6 +1928,30 @@ export default function SettingsView() {
))} ))}
</div> </div>
{/* Entity type filter */}
<div className="flex flex-wrap gap-1.5">
<Label className="text-xs text-gray-500 dark:text-gray-400 self-center">{t('audit.entity_type_filter')}</Label>
{(() => {
const entityTypes = Array.from(new Set(auditEntries.map((e) => e.entityType))).sort();
return [
{ value: 'all', label: t('audit.all_entity_types') },
...entityTypes.map((et) => ({ value: et, label: et })),
].map((chip) => (
<button
key={chip.value}
onClick={() => { setAuditEntityTypeFilter(chip.value); setAuditPage(1); }}
className={`px-2 py-1 rounded-md text-xs font-medium transition-all min-h-[36px] ${
auditEntityTypeFilter === chip.value
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 ring-1 ring-violet-300 dark:ring-violet-700'
: 'bg-white/60 dark:bg-gray-800/40 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 border border-gray-200/50 dark:border-gray-700/30'
}`}
>
{chip.label}
</button>
));
})()}
</div>
{/* Date filters */} {/* Date filters */}
<div className="flex flex-wrap gap-3 items-end"> <div className="flex flex-wrap gap-3 items-end">
<div className="space-y-1"> <div className="space-y-1">
@@ -1743,7 +1959,7 @@ export default function SettingsView() {
<Input <Input
type="date" type="date"
value={auditDateFrom} value={auditDateFrom}
onChange={(e) => setAuditDateFrom(e.target.value)} onChange={(e) => { setAuditDateFrom(e.target.value); setAuditPage(1); }}
className="h-8 w-36 rounded-lg text-xs border-violet-200 dark:border-violet-900/30" className="h-8 w-36 rounded-lg text-xs border-violet-200 dark:border-violet-900/30"
/> />
</div> </div>
@@ -1752,7 +1968,7 @@ export default function SettingsView() {
<Input <Input
type="date" type="date"
value={auditDateTo} value={auditDateTo}
onChange={(e) => setAuditDateTo(e.target.value)} onChange={(e) => { setAuditDateTo(e.target.value); setAuditPage(1); }}
className="h-8 w-36 rounded-lg text-xs border-violet-200 dark:border-violet-900/30" className="h-8 w-36 rounded-lg text-xs border-violet-200 dark:border-violet-900/30"
/> />
</div> </div>
@@ -1760,7 +1976,7 @@ export default function SettingsView() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={loadAuditLog} onClick={loadAuditLog}
className="h-8 rounded-lg border-violet-200 dark:border-violet-900/30 text-violet-700 dark:text-violet-300" className="h-8 rounded-lg border-violet-200 dark:border-violet-900/30 text-violet-700 dark:text-violet-300 min-h-[36px]"
> >
<RefreshCw className="h-3.5 w-3.5 mr-1" /> <RefreshCw className="h-3.5 w-3.5 mr-1" />
{t('action.refresh')} {t('action.refresh')}
@@ -1824,7 +2040,8 @@ export default function SettingsView() {
initial={{ opacity: 0, x: -8 }} initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.03 }} transition={{ delay: i * 0.03 }}
className="relative flex items-start gap-4 pl-4 py-3 group" className="relative flex items-start gap-4 pl-4 py-3 group cursor-pointer"
onClick={() => setAuditDetailEntry(entry)}
> >
{/* Timeline dot */} {/* Timeline dot */}
<div className={`absolute left-3.5 w-3 h-3 rounded-full ring-2 ring-white dark:ring-gray-900 shrink-0 ${dotColor} group-hover:scale-125 transition-transform`} style={{ zIndex: 1 }} /> <div className={`absolute left-3.5 w-3 h-3 rounded-full ring-2 ring-white dark:ring-gray-900 shrink-0 ${dotColor} group-hover:scale-125 transition-transform`} style={{ zIndex: 1 }} />
@@ -1864,6 +2081,9 @@ export default function SettingsView() {
</div> </div>
<span>{actorName}</span> <span>{actorName}</span>
</div> </div>
{entry.ipAddress && (
<span className="text-gray-400 dark:text-gray-500 ml-2">IP: {entry.ipAddress}</span>
)}
</div> </div>
</div> </div>
</motion.div> </motion.div>
@@ -1872,8 +2092,115 @@ export default function SettingsView() {
</div> </div>
); );
})()} })()}
{/* Pagination */}
{auditTotalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<Button
variant="outline"
size="sm"
disabled={auditPage <= 1}
onClick={() => setAuditPage((p) => Math.max(1, p - 1))}
className="h-8 min-h-[36px] rounded-lg border-violet-200 dark:border-violet-900/30"
>
{t('audit.previous')}
</Button>
<span className="text-xs text-gray-500 dark:text-gray-400">
{t('audit.page')} {auditPage} {t('audit.of')} {auditTotalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={auditPage >= auditTotalPages}
onClick={() => setAuditPage((p) => Math.min(auditTotalPages, p + 1))}
className="h-8 min-h-[36px] rounded-lg border-violet-200 dark:border-violet-900/30"
>
{t('audit.next')}
</Button>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* Audit Detail Dialog */}
<Dialog open={!!auditDetailEntry} onOpenChange={(o) => { if (!o) setAuditDetailEntry(null); }}>
<DialogContent className="sm:max-w-[560px] max-h-[90vh] overflow-y-auto">
{auditDetailEntry && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-violet-500" />
{t('audit.detail')}
</DialogTitle>
<DialogDescription>
{auditDetailEntry.action} - {auditDetailEntry.entityType}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-1">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<Label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">{t('settings.audit_action')}</Label>
<p className="mt-1 font-medium">{auditDetailEntry.action}</p>
</div>
<div>
<Label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">{t('audit.entity_type')}</Label>
<p className="mt-1 font-medium">{auditDetailEntry.entityType}</p>
</div>
<div>
<Label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">{t('settings.audit_timestamp')}</Label>
<p className="mt-1 text-xs">{new Date(auditDetailEntry.timestamp).toLocaleString()}</p>
</div>
<div>
<Label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">ID</Label>
<p className="mt-1 text-xs font-mono">{auditDetailEntry.entityId ?? '-'}</p>
</div>
{auditDetailEntry.ipAddress && (
<div>
<Label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">{t('audit.ip_address')}</Label>
<p className="mt-1 text-xs">{auditDetailEntry.ipAddress}</p>
</div>
)}
{auditDetailEntry.userAgent && (
<div>
<Label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">{t('audit.user_agent')}</Label>
<p className="mt-1 text-xs truncate max-w-[200px]">{auditDetailEntry.userAgent}</p>
</div>
)}
</div>
{/* Changes before/after */}
{auditDetailEntry.changes && (() => {
try {
const parsed = JSON.parse(auditDetailEntry.changes);
return (
<div className="space-y-2">
{parsed.before && (
<div>
<Label className="text-[10px] uppercase tracking-wider text-rose-500 font-semibold">{t('audit.before')}</Label>
<pre className="mt-1 p-2 rounded-lg bg-rose-50 dark:bg-rose-950/20 text-xs text-rose-700 dark:text-rose-300 overflow-auto max-h-40 font-mono whitespace-pre-wrap">
{JSON.stringify(parsed.before, null, 2)}
</pre>
</div>
)}
{parsed.after && (
<div>
<Label className="text-[10px] uppercase tracking-wider text-emerald-500 font-semibold">{t('audit.after')}</Label>
<pre className="mt-1 p-2 rounded-lg bg-emerald-50 dark:bg-emerald-950/20 text-xs text-emerald-700 dark:text-emerald-300 overflow-auto max-h-40 font-mono whitespace-pre-wrap">
{JSON.stringify(parsed.after, null, 2)}
</pre>
</div>
)}
</div>
);
} catch {
return <p className="text-xs text-gray-500">{t('audit.no_changes')}</p>;
}
})()}
</div>
</>
)}
</DialogContent>
</Dialog>
</TabsContent> </TabsContent>
{/* ── Data Management Tab ─────────────────────────────── */} {/* ── Data Management Tab ─────────────────────────────── */}
@@ -2523,6 +2850,364 @@ export default function SettingsView() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* ── District Tab (SUPER_ADMIN only) ─────────────────────────── */}
{isSuperAdmin && (
<TabsContent value="district">
<div className="space-y-6">
{/* District Management Card */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
<div className="flex items-center justify-between">
<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-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
<Building2 className="h-4 w-4" />
</div>
{t('district.title')}
</CardTitle>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<Button
className="bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 text-white shadow-md shadow-violet-300/20 rounded-xl min-h-[44px]"
onClick={() => { resetDistrictForm(); setEditingDistrict(null); setDistrictDialogOpen(true); }}
>
<Plus className="h-4 w-4 mr-1.5" />
{t('district.create')}
</Button>
</motion.div>
</div>
</CardHeader>
<CardContent>
{districtsLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-xl" />)}
</div>
) : districts.length === 0 ? (
<div className="text-center py-12">
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-violet-100 to-purple-100 dark:from-violet-900/30 dark:to-purple-900/30 mx-auto mb-5 shadow-md shadow-violet-200/40 dark:shadow-violet-900/20">
<Building2 className="h-10 w-10 text-violet-500 dark:text-violet-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('district.no_districts')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{t('district.no_districts_desc')}</p>
<Button className="mt-5 bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 text-white rounded-xl min-h-[44px]" onClick={() => { resetDistrictForm(); setEditingDistrict(null); setDistrictDialogOpen(true); }}>
<Plus className="h-4 w-4 mr-1" />
{t('district.create')}
</Button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{districts.map((district) => (
<motion.div
key={district.id}
whileHover={{ scale: 1.01 }}
className="district-card p-5 bg-white dark:bg-gray-950 border border-gray-100 dark:border-gray-800 cursor-pointer"
onClick={() => loadDistrictSchoolsData(district.id)}
>
<div className="flex items-center gap-3 mb-3">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-violet-400 to-purple-500 text-white shadow-sm">
<Building2 className="h-5 w-5" />
</div>
<div className="min-w-0">
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm truncate">{district.name}</p>
<div className="flex items-center gap-2 mt-0.5">
{district.code && (
<span className="text-xs text-gray-500 dark:text-gray-400">{district.code}</span>
)}
{district.region && (
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-0.5">
<MapPin className="h-3 w-3" />{district.region}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3 mb-3">
<Badge className="bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 text-[10px] px-1.5 py-0 rounded-xl">
{t('district.school_count')}: {district.schools.length}
</Badge>
{district.isActive ? (
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-[10px] px-1.5 py-0 rounded-xl">
{t('schedules.active')}
</Badge>
) : (
<Badge className="bg-gray-100 text-gray-500 dark:bg-gray-900/30 dark:text-gray-400 text-[10px] px-1.5 py-0 rounded-xl">
{t('schedules.inactive')}
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="min-h-[44px] min-w-[44px] rounded-xl text-gray-400 hover:text-violet-500"
onClick={(e) => { e.stopPropagation(); openEditDistrict(district); }}
aria-label={t('action.edit')}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="min-h-[44px] min-w-[44px] rounded-xl text-gray-400 hover:text-rose-500"
onClick={(e) => { e.stopPropagation(); setDeleteDistrictId(district.id); }}
aria-label={t('action.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</motion.div>
))}
</div>
)}
</CardContent>
</Card>
{/* District Schools Detail */}
{selectedDistrictId && (
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
<School className="h-4 w-4" />
</div>
{t('district.schools')}
{districts.find((d) => d.id === selectedDistrictId) && (
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
· {districts.find((d) => d.id === selectedDistrictId)!.name}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Assign school */}
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1 min-w-[200px]">
<Label className="text-sm font-medium">{t('district.assign_school')}</Label>
<Select value={assignSchoolId} onValueChange={setAssignSchoolId}>
<SelectTrigger className="rounded-xl border-violet-200/50 dark:border-violet-900/30">
<SelectValue placeholder={t('district.select_school')} />
</SelectTrigger>
<SelectContent>
{schools
.filter((s) => !districtSchools.find((ds) => ds.id === s.id))
.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
className="bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 text-white rounded-xl min-h-[44px]"
onClick={handleAssignSchool}
disabled={!assignSchoolId}
>
<Plus className="h-4 w-4 mr-1" />
{t('district.assign_school')}
</Button>
</div>
{/* Schools list */}
{districtSchoolsLoading ? (
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-20 rounded-xl" />)}
</div>
) : districtSchools.length === 0 ? (
<div className="text-center py-8">
<School className="h-8 w-8 text-violet-400 dark:text-violet-500 mx-auto mb-2" />
<p className="text-gray-500 dark:text-gray-400">{t('district.no_schools_assigned')}</p>
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto scrollbar-education">
{districtSchools.map((school) => (
<div key={school.id} className="p-4 rounded-xl bg-gradient-to-r from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border-l-3 border-l-violet-400/40 hover:border-l-violet-500 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
<School className="h-4 w-4" />
</div>
<div>
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{school.name}</p>
<div className="flex items-center gap-3 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
<span>{t('district.total_students')}: {school._count.students}</span>
<span>{t('district.total_classes')}: {school._count.classGroups}</span>
<span>{t('district.total_teachers')}: {school._count.users}</span>
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="min-h-[44px] min-w-[44px] text-gray-400 hover:text-rose-500 rounded-xl"
onClick={() => handleUnassignSchool(school.id)}
aria-label={t('district.unassign_school')}
>
<ArrowRightLeft className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* Cross-School Comparison */}
{districts.length > 0 && (
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-purple-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-purple-50/50 to-transparent dark:from-purple-900/10 dark:to-transparent">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400">
<BarChart3 className="h-4 w-4" />
</div>
{t('district.cross_school_comparison')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="cross-school-comparison">
{districts.map((district) => {
const totalStudents = district.schools.length;
const maxSchools = Math.max(...districts.map((d) => d.schools.length), 1);
const pct = Math.round((totalStudents / maxSchools) * 100);
return (
<div key={district.id} className="p-4 rounded-xl bg-gradient-to-r from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0">
<div className="flex items-center justify-between mb-2">
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{district.name}</p>
<div className="flex items-center gap-2">
<Badge className="bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 text-[10px] px-1.5 py-0 rounded-xl">
{district.schools.length} {t('district.schools')}
</Badge>
{totalStudents === maxSchools && districts.length > 1 && (
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-[10px] px-1.5 py-0 rounded-xl">
<ArrowUpRight className="h-3 w-3 mr-0.5" />
{t('district.top_performing')}
</Badge>
)}
</div>
</div>
<div className="comparison-bar">
<div
className="comparison-bar-fill bg-gradient-to-r from-violet-400 to-purple-500"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
</TabsContent>
)}
{/* ── District Create/Edit Dialog ─────────────────────────────── */}
<Dialog open={districtDialogOpen} onOpenChange={(open) => { if (!open) { setDistrictDialogOpen(false); setEditingDistrict(null); } }}>
<DialogContent className="rounded-xl">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{editingDistrict ? t('district.edit') : t('district.create')}
</DialogTitle>
</DialogHeader>
<div className="space-y-5 pt-2">
<div className="space-y-2">
<Label className="text-sm font-medium">{t('district.name')}</Label>
<Input
value={districtForm.name}
onChange={(e) => setDistrictForm({ ...districtForm, name: e.target.value })}
className="rounded-xl border-violet-200/50 dark:border-violet-900/30"
placeholder={t('district.name')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium">{t('district.code')}</Label>
<Input
value={districtForm.code}
onChange={(e) => setDistrictForm({ ...districtForm, code: e.target.value })}
className="rounded-xl border-violet-200/50 dark:border-violet-900/30"
placeholder="DE-NW-001"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">{t('district.region')}</Label>
<Input
value={districtForm.region}
onChange={(e) => setDistrictForm({ ...districtForm, region: e.target.value })}
className="rounded-xl border-violet-200/50 dark:border-violet-900/30"
placeholder="Nordrhein-Westfalen"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium">{t('district.country')}</Label>
<Input
value={districtForm.country}
onChange={(e) => setDistrictForm({ ...districtForm, country: e.target.value })}
className="rounded-xl border-violet-200/50 dark:border-violet-900/30"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">{t('district.admin_email')}</Label>
<Input
type="email"
value={districtForm.adminEmail}
onChange={(e) => setDistrictForm({ ...districtForm, adminEmail: e.target.value })}
className="rounded-xl border-violet-200/50 dark:border-violet-900/30"
placeholder="admin@bezirk.de"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setDistrictDialogOpen(false); setEditingDistrict(null); }} className="rounded-xl">{t('action.cancel')}</Button>
<Button
className="bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 text-white rounded-xl shadow-md min-h-[44px]"
onClick={editingDistrict ? handleUpdateDistrict : handleCreateDistrict}
disabled={districtSaving || !districtForm.name}
>
{districtSaving ? t('empty.loading') : (editingDistrict ? t('action.save') : t('district.create'))}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── District Delete Confirmation ────────────────────────────── */}
<AlertDialog open={!!deleteDistrictId} onOpenChange={(open) => { if (!open) setDeleteDistrictId(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('district.confirm_delete')}</AlertDialogTitle>
<AlertDialogDescription>{t('district.confirm_delete_desc')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('action.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteDistrictId && handleDeleteDistrict(deleteDistrictId)} className="bg-rose-600 hover:bg-rose-700 text-white">
{t('action.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ── Rate Limit Tab ─────────────────────────────────────────── */}
<TabsContent value="rate-limit">
<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">
<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">
<Shield className="h-4 w-4" />
</div>
{t('rate_limit.status_title')}
</CardTitle>
<CardDescription className="text-xs text-gray-500 dark:text-gray-400">
{t('rate_limit.auth_limit')} · {t('rate_limit.data_limit')} · {t('rate_limit.write_limit')} · {t('rate_limit.heavy_limit')}
</CardDescription>
</CardHeader>
<CardContent>
<RateLimitStatus />
</CardContent>
</Card>
</TabsContent>
</motion.div> </motion.div>
); );
} }
+279 -2
View File
@@ -10,11 +10,27 @@ async function apiRequest<T>(url: string, options?: RequestInit): Promise<T> {
}); });
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
// Handle 429 Too Many Requests (rate limiting)
if (res.status === 429) {
const retryAfter = body.retryAfter || parseInt(res.headers.get('Retry-After') || '60', 10);
const rateLimitError = new Error(body.error || 'Too many requests');
(rateLimitError as RateLimitError).isRateLimit = true;
(rateLimitError as RateLimitError).retryAfter = retryAfter;
(rateLimitError as RateLimitError).limit = body.limit || 0;
throw rateLimitError;
}
throw new Error(body.error || `API error ${res.status}`); throw new Error(body.error || `API error ${res.status}`);
} }
return res.json(); return res.json();
} }
// Rate limit error type
export interface RateLimitError extends Error {
isRateLimit: boolean;
retryAfter: number;
limit: number;
}
export function apiGet<T>(url: string): Promise<T> { export function apiGet<T>(url: string): Promise<T> {
return apiRequest<T>(url); return apiRequest<T>(url);
} }
@@ -703,23 +719,143 @@ export interface AuditLogEntry {
action: string; action: string;
entityType: string; entityType: string;
entityId: string | null; entityId: string | null;
changes: string | null;
ipAddress: string | null;
userAgent: string | null;
timestamp: string; timestamp: string;
metadata: string | null; metadata: string | null;
user: { id: string; firstName: string; lastName: string } | null; user: { id: string; firstName: string; lastName: string } | null;
} }
export interface AuditLogPaginatedResponse {
entries: AuditLogEntry[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
export function fetchAuditLog(params?: { export function fetchAuditLog(params?: {
schoolId?: string; schoolId?: string;
action?: string; action?: string;
entityType?: string;
userId?: string;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
}): Promise<AuditLogEntry[]> { page?: number;
pageSize?: number;
}): Promise<AuditLogPaginatedResponse> {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
if (params?.schoolId) sp.set('schoolId', params.schoolId); if (params?.schoolId) sp.set('schoolId', params.schoolId);
if (params?.action) sp.set('action', params.action); if (params?.action) sp.set('action', params.action);
if (params?.entityType) sp.set('entityType', params.entityType);
if (params?.userId) sp.set('userId', params.userId);
if (params?.startDate) sp.set('startDate', params.startDate); if (params?.startDate) sp.set('startDate', params.startDate);
if (params?.endDate) sp.set('endDate', params.endDate); if (params?.endDate) sp.set('endDate', params.endDate);
return apiGet<AuditLogEntry[]>(`/api/audit-log?${sp.toString()}`); if (params?.page) sp.set('page', String(params.page));
if (params?.pageSize) sp.set('pageSize', String(params.pageSize));
return apiGet<AuditLogPaginatedResponse>(`/api/audit-log?${sp.toString()}`);
}
export function exportAuditLogCsv(params?: {
schoolId?: string;
action?: string;
entityType?: string;
userId?: string;
startDate?: string;
endDate?: string;
}): void {
const sp = new URLSearchParams();
sp.set('export', 'csv');
if (params?.schoolId) sp.set('schoolId', params.schoolId);
if (params?.action) sp.set('action', params.action);
if (params?.entityType) sp.set('entityType', params.entityType);
if (params?.userId) sp.set('userId', params.userId);
if (params?.startDate) sp.set('startDate', params.startDate);
if (params?.endDate) sp.set('endDate', params.endDate);
const url = `/api/audit-log?${sp.toString()}`;
const a = document.createElement('a');
a.href = url;
a.download = 'audit-log.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
/* ── Behavior Interventions ─────────────────────────────────────────── */
export interface BehaviorInterventionType {
id: string;
schoolId: string;
studentId: string;
incidentId: string | null;
type: string;
description: string;
status: string;
assignedTo: string | null;
startDate: string | null;
endDate: string | null;
outcome: string | null;
isDemo: boolean;
createdAt: string;
updatedAt: string;
student: { id: string; firstName: string; lastName: string };
incident: { id: string; description: string; date: string; severity: string } | null;
assignedUser: { id: string; firstName: string; lastName: string } | null;
}
export function fetchBehaviorInterventions(params: {
schoolId: string;
studentId?: string;
incidentId?: string;
type?: string;
status?: string;
}): Promise<BehaviorInterventionType[]> {
const sp = new URLSearchParams();
sp.set('schoolId', params.schoolId);
if (params.studentId) sp.set('studentId', params.studentId);
if (params.incidentId) sp.set('incidentId', params.incidentId);
if (params.type) sp.set('type', params.type);
if (params.status) sp.set('status', params.status);
return apiGet<BehaviorInterventionType[]>(`/api/behavior-interventions?${sp.toString()}`);
}
export function createBehaviorIntervention(data: {
schoolId: string;
studentId: string;
incidentId?: string;
type: string;
description: string;
status?: string;
assignedTo?: string;
startDate?: string;
endDate?: string;
outcome?: string;
isDemo?: boolean;
}): Promise<BehaviorInterventionType> {
return apiPost<BehaviorInterventionType>('/api/behavior-interventions', data);
}
export function updateBehaviorIntervention(
id: string,
data: {
type?: string;
description?: string;
status?: string;
assignedTo?: string;
startDate?: string;
endDate?: string;
outcome?: string;
incidentId?: string;
}
): Promise<BehaviorInterventionType> {
return apiPut<BehaviorInterventionType>(`/api/behavior-interventions/${id}`, data);
}
export function deleteBehaviorIntervention(id: string): Promise<{ success: boolean }> {
return apiDelete<{ success: boolean }>(`/api/behavior-interventions/${id}`);
} }
/* ── Data Export (CSV) ────────────────────────────────────────────── */ /* ── Data Export (CSV) ────────────────────────────────────────────── */
@@ -2447,3 +2583,144 @@ export function bulkCreateStudentAccounts(data: {
}): Promise<{ created: Array<Record<string, unknown>>; count: number }> { }): Promise<{ created: Array<Record<string, unknown>>; count: number }> {
return apiPost<{ created: Array<Record<string, unknown>>; count: number }>('/api/users', { ...data, action: 'bulkCreateStudents' }); return apiPost<{ created: Array<Record<string, unknown>>; count: number }>('/api/users', { ...data, action: 'bulkCreateStudents' });
} }
/* ── Report Schedules ──────────────────────────────────────────────── */
export interface ReportScheduleData {
id: string;
schoolId: string;
classGroupId: string | null;
template: string;
frequency: string;
dayOfWeek: number | null;
dayOfMonth: number | null;
monthOfYear: number | null;
recipients: string | null;
includeStudents: boolean;
includeGrades: boolean;
includeAttendance: boolean;
includeBehavior: boolean;
includeCompetencies: boolean;
isActive: boolean;
lastRunAt: string | null;
nextRunAt: string | null;
isDemo: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
classGroup?: { id: string; name: string; gradeLevel: number } | null;
}
export function fetchReportSchedules(schoolId: string): Promise<ReportScheduleData[]> {
return apiGet<ReportScheduleData[]>(`/api/report-schedules?schoolId=${schoolId}`);
}
export function createReportSchedule(data: {
schoolId: string;
classGroupId?: string;
template: string;
frequency: string;
dayOfWeek?: number | null;
dayOfMonth?: number | null;
monthOfYear?: number | null;
recipients?: string[];
includeStudents?: boolean;
includeGrades?: boolean;
includeAttendance?: boolean;
includeBehavior?: boolean;
includeCompetencies?: boolean;
}): Promise<ReportScheduleData> {
return apiPost<ReportScheduleData>('/api/report-schedules', data);
}
export function updateReportSchedule(id: string, data: {
classGroupId?: string;
template?: string;
frequency?: string;
dayOfWeek?: number | null;
dayOfMonth?: number | null;
monthOfYear?: number | null;
recipients?: string[];
includeStudents?: boolean;
includeGrades?: boolean;
includeAttendance?: boolean;
includeBehavior?: boolean;
includeCompetencies?: boolean;
isActive?: boolean;
}): Promise<ReportScheduleData> {
return apiPut<ReportScheduleData>(`/api/report-schedules/${id}`, data);
}
export function deleteReportSchedule(id: string): Promise<{ success: boolean }> {
return apiDelete<{ success: boolean }>(`/api/report-schedules/${id}`);
}
export function runReportSchedule(id: string): Promise<{
success: boolean;
lastRunAt: string;
nextRunAt: string;
recipientsNotified: number;
}> {
return apiPost(`/api/report-schedules/${id}/run`, {});
}
/* ── School Districts ──────────────────────────────────────────────── */
export interface SchoolDistrictData {
id: string;
name: string;
code: string | null;
region: string | null;
country: string;
adminEmail: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
schools: Array<{ id: string; name: string; schoolType: string; country: string }>;
schoolCount?: number;
totalStudents?: number;
}
export function fetchDistricts(): Promise<SchoolDistrictData[]> {
return apiGet<SchoolDistrictData[]>('/api/districts');
}
export function createDistrict(data: {
name: string;
code?: string;
region?: string;
country?: string;
adminEmail?: string;
}): Promise<SchoolDistrictData> {
return apiPost<SchoolDistrictData>('/api/districts', data);
}
export function updateDistrict(id: string, data: {
name?: string;
code?: string;
region?: string;
country?: string;
adminEmail?: string;
isActive?: boolean;
}): Promise<SchoolDistrictData> {
return apiPut<SchoolDistrictData>(`/api/districts/${id}`, data);
}
export function deleteDistrict(id: string): Promise<{ success: boolean }> {
return apiDelete<{ success: boolean }>(`/api/districts/${id}`);
}
export function fetchDistrictSchools(districtId: string): Promise<Array<{
id: string;
name: string;
schoolType: string;
country: string;
_count: { students: number; classGroups: number; users: number };
}>> {
return apiGet(`/api/districts/${districtId}/schools`);
}
export function assignSchoolToDistrict(districtId: string, schoolId: string): Promise<{ success: boolean }> {
return apiPost<{ success: boolean }>(`/api/districts/${districtId}/schools`, { schoolId });
}
+140
View File
@@ -0,0 +1,140 @@
// CompetenceTrack — Audit Logging Middleware
// Provides logAudit() and withAuditLog() helpers for recording all data modifications
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export interface AuditLogParams {
userId?: string;
schoolId?: string;
action: string; // CREATE, UPDATE, DELETE
entityType: string; // e.g., 'BehaviorIncident', 'Student', 'User'
entityId?: string;
changes?: {
before?: Record<string, unknown>;
after?: Record<string, unknown>;
};
ipAddress?: string;
userAgent?: string;
metadata?: Record<string, unknown>;
}
/**
* Log an audit entry to the database.
* Automatically resolves userId from session if not provided.
*/
export async function logAudit(params: AuditLogParams): Promise<void> {
try {
let userId = params.userId;
if (!userId) {
const session = await getSession();
userId = session?.user?.id ?? undefined;
}
await db.auditLog.create({
data: {
userId: userId ?? null,
schoolId: params.schoolId ?? null,
action: params.action,
entityType: params.entityType,
entityId: params.entityId ?? null,
changes: params.changes ? JSON.stringify(params.changes) : null,
ipAddress: params.ipAddress ?? null,
userAgent: params.userAgent ?? null,
metadata: params.metadata ? JSON.stringify(params.metadata) : null,
},
});
} catch (error) {
// Audit logging should never block the main operation
console.error('Audit log error:', error);
}
}
/**
* Extract client info from a Next.js Request object.
*/
export function extractClientInfo(request: Request): {
ipAddress?: string;
userAgent?: string;
} {
const forwarded = request.headers.get('x-forwarded-for');
const ipAddress = forwarded?.split(',')[0]?.trim() ?? request.headers.get('x-real-ip') ?? undefined;
const userAgent = request.headers.get('user-agent') ?? undefined;
return { ipAddress, userAgent };
}
/**
* Wrapper that adds audit logging to API route handlers.
* Automatically logs CREATE, UPDATE, DELETE actions with before/after data.
*
* Usage:
* ```ts
* export const POST = withAuditLog(async (request) => {
* // ... handler logic ...
* return NextResponse.json(result);
* }, 'CREATE', 'BehaviorIncident');
* ```
*/
export function withAuditLog(
handler: (request: Request, context?: { params?: Record<string, string | string[]> }) => Promise<Response>,
action: string,
entityType: string,
) {
return async (request: Request, context?: { params?: Record<string, string | string[]> }) => {
const clientInfo = extractClientInfo(request);
let session: { user?: { id?: string; schoolId?: string } } | null = null;
try {
session = await getSession();
} catch {
// Session may not be available for some endpoints
}
// Execute the handler
const response = await handler(request, context);
// If the handler was successful (2xx), log the audit entry
if (response.status >= 200 && response.status < 300) {
try {
// Try to extract entity ID from response body
let entityId: string | undefined;
let changes: AuditLogParams['changes'];
// Clone the response so we can read the body without consuming it
const clonedResponse = response.clone();
try {
const body = await clonedResponse.json();
entityId = body?.id ?? body?.data?.id ?? undefined;
if (action === 'UPDATE' && body?.data) {
changes = { after: body.data };
} else if (action === 'CREATE' && body?.data) {
changes = { after: body.data };
}
} catch {
// Response body may not be JSON
}
// Try to extract entity ID from URL params
if (!entityId && context?.params) {
const params = context.params;
entityId = params?.id as string | undefined;
}
await logAudit({
userId: session?.user?.id,
schoolId: session?.user?.schoolId,
action,
entityType,
entityId,
changes,
...clientInfo,
});
} catch (auditError) {
// Audit logging should never block the response
console.error('withAuditLog error:', auditError);
}
}
return response;
};
}
+396
View File
@@ -1388,6 +1388,93 @@ const de: TranslationDict = {
'behavior.duplicate_category': 'Eine Kategorie mit diesem Namen existiert bereits', 'behavior.duplicate_category': 'Eine Kategorie mit diesem Namen existiert bereits',
'behavior.confirm_close': 'Möchten Sie den Dialog wirklich schließen? Nicht gespeicherte Änderungen gehen verloren.', 'behavior.confirm_close': 'Möchten Sie den Dialog wirklich schließen? Nicht gespeicherte Änderungen gehen verloren.',
// Behavior Analytics
'behavior.tab_analytics': 'Analyse',
'behavior.tab_interventions': 'Massnahmen',
'behavior.analytics.trend': 'Verlauf',
'behavior.analytics.trend_desc': 'Positive vs. negative Vorfälle im Zeitverlauf',
'behavior.analytics.category_dist': 'Kategorienverteilung',
'behavior.analytics.category_dist_desc': 'Aufschlüsselung nach Verhaltenskategorie',
'behavior.analytics.risk_profile': 'Risikoprofil',
'behavior.analytics.risk_profile_desc': 'Schüler:innen mit häufigen negativen Vorfällen',
'behavior.analytics.class_comparison': 'Klassenvergleich',
'behavior.analytics.class_comparison_desc': 'Verhaltensmuster im Klassenvergleich',
'behavior.analytics.time_of_day': 'Tageszeit-Analyse',
'behavior.analytics.time_of_day_desc': 'Wann Vorfälle am häufigsten auftreten',
'behavior.analytics.positive_trend': 'Positive Entwicklung',
'behavior.analytics.positive_trend_desc': 'Schüler:innen mit positiver Verhaltensentwicklung',
'behavior.analytics.positive_change': 'Positive Veränderung',
'behavior.analytics.negative_change': 'Negative Veränderung',
'behavior.analytics.period': 'Zeitraum',
'behavior.analytics.last_30': 'Letzte 30 Tage',
'behavior.analytics.last_90': 'Letzte 90 Tage',
'behavior.analytics.this_year': 'Dieses Schuljahr',
'behavior.analytics.risk_high': 'Hohes Risiko',
'behavior.analytics.risk_medium': 'Mittleres Risiko',
'behavior.analytics.risk_low': 'Niedriges Risiko',
'behavior.analytics.incidents_count_short': 'Vorfälle',
// Behavior Interventions
'behavior.intervention.title': 'Verhaltensmassnahmen',
'behavior.intervention.subtitle': 'Massnahmen erfassen und deren Wirksamkeit verfolgen',
'behavior.intervention.add': 'Massnahme hinzufügen',
'behavior.intervention.edit': 'Massnahme bearbeiten',
'behavior.intervention.type': 'Typ',
'behavior.intervention.type.warning': 'Verwarnung',
'behavior.intervention.type.meeting': 'Gespräch',
'behavior.intervention.type.action_plan': 'Aktionsplan',
'behavior.intervention.type.support': 'Unterstützung',
'behavior.intervention.type.parent_contact': 'Elternkontakt',
'behavior.intervention.description': 'Beschreibung',
'behavior.intervention.description_placeholder': 'Beschreibung der Massnahme…',
'behavior.intervention.status': 'Status',
'behavior.intervention.status.planned': 'Geplant',
'behavior.intervention.status.in_progress': 'In Bearbeitung',
'behavior.intervention.status.completed': 'Abgeschlossen',
'behavior.intervention.status.cancelled': 'Abgebrochen',
'behavior.intervention.assigned_to': 'Zugewiesen an',
'behavior.intervention.assigned_to_placeholder': 'Lehrkraft auswählen',
'behavior.intervention.start_date': 'Startdatum',
'behavior.intervention.end_date': 'Enddatum',
'behavior.intervention.outcome': 'Ergebnis',
'behavior.intervention.outcome_placeholder': 'Ergebnis der Massnahme…',
'behavior.intervention.linked_incident': 'Verknüpfter Vorfall',
'behavior.intervention.no_incident': 'Kein Vorfall verknüpft',
'behavior.intervention.effectiveness': 'Wirksamkeit',
'behavior.intervention.progress': 'Fortschritt',
'behavior.intervention.no_interventions': 'Keine Massnahmen vorhanden',
'behavior.intervention.no_interventions_desc': 'Erstellen Sie eine Massnahme, um das Verhalten zu unterstützen.',
'behavior.intervention.saved': 'Massnahme gespeichert',
'behavior.intervention.deleted': 'Massnahme gelöscht',
'behavior.intervention.error_save': 'Fehler beim Speichern der Massnahme',
'behavior.intervention.error_delete': 'Fehler beim Löschen der Massnahme',
'behavior.intervention.required_type': 'Bitte wählen Sie einen Typ aus',
'behavior.intervention.required_description': 'Bitte geben Sie eine Beschreibung ein',
'behavior.intervention.filter_status': 'Status filtern',
'behavior.intervention.all_statuses': 'Alle Status',
'behavior.intervention.filter_type': 'Typ filtern',
'behavior.intervention.all_types': 'Alle Typen',
// Audit Log Enhancement
'audit.entity_type': 'Entitätstyp',
'audit.entity_type_filter': 'Nach Typ filtern',
'audit.all_entity_types': 'Alle Typen',
'audit.user_filter': 'Benutzer filtern',
'audit.all_users': 'Alle Benutzer',
'audit.changes': 'Änderungen',
'audit.before': 'Vorher',
'audit.after': 'Nachher',
'audit.detail': 'Detailansicht',
'audit.export_csv': 'Als CSV exportieren',
'audit.page': 'Seite',
'audit.of': 'von',
'audit.entries': 'Einträge',
'audit.previous': 'Zurück',
'audit.next': 'Weiter',
'audit.no_changes': 'Keine Änderungsdetails verfügbar',
'audit.ip_address': 'IP-Adresse',
'audit.user_agent': 'Benutzer-Agent',
// Curriculum Coverage — Kompetenzabdeckung // Curriculum Coverage — Kompetenzabdeckung
'nav.coverage': 'Abdeckung', 'nav.coverage': 'Abdeckung',
'coverage.title': 'Kompetenzabdeckung', 'coverage.title': 'Kompetenzabdeckung',
@@ -2327,6 +2414,117 @@ const de: TranslationDict = {
'performance.title': 'Leistung', 'performance.title': 'Leistung',
'performance.fps': 'Bilder pro Sekunde', 'performance.fps': 'Bilder pro Sekunde',
'mobile.optimized': 'Für mobile Geräte optimiert', 'mobile.optimized': 'Für mobile Geräte optimiert',
// ── Report Scheduling (Task 13) ──
'schedules.title': 'Berichtsplanung',
'schedules.frequency': 'Häufigkeit',
'schedules.weekly': 'Wöchentlich',
'schedules.monthly': 'Monatlich',
'schedules.quarterly': 'Vierteljährlich',
'schedules.end_of_term': 'Ende des Halbjahres',
'schedules.next_run': 'Nächste Ausführung',
'schedules.last_run': 'Letzte Ausführung',
'schedules.manual_run': 'Manuell ausführen',
'schedules.recipients': 'Empfänger',
'schedules.include_students': 'Schüler einbeziehen',
'schedules.include_grades': 'Noten einbeziehen',
'schedules.include_attendance': 'Anwesenheit einbeziehen',
'schedules.include_behavior': 'Verhalten einbeziehen',
'schedules.include_competencies': 'Kompetenzen einbeziehen',
'schedules.create_schedule': 'Planung erstellen',
'schedules.edit_schedule': 'Planung bearbeiten',
'schedules.delete_schedule': 'Planung löschen',
'schedules.active': 'Aktiv',
'schedules.inactive': 'Inaktiv',
'schedules.no_schedules': 'Keine Berichtsplanungen vorhanden',
'schedules.no_schedules_desc': 'Erstellen Sie Ihre erste Berichtsplanung.',
'schedules.running': 'Wird ausgeführt...',
'schedules.run_success': 'Bericht erfolgreich generiert',
'schedules.run_error': 'Fehler bei der Berichtsgenerierung',
'schedules.day_of_week': 'Wochentag',
'schedules.day_of_month': 'Tag des Monats',
'schedules.month_of_year': 'Monat',
'schedules.template': 'Vorlage',
'schedules.all_classes': 'Alle Klassen',
'schedules.timeline': 'Zeitplan',
'schedules.confirm_delete': 'Planung löschen?',
'schedules.confirm_delete_desc': 'Diese Berichtsplanung wird unwiderruflich gelöscht.',
'schedules.confirm_run': 'Manuell ausführen?',
'schedules.confirm_run_desc': 'Der Bericht wird sofort generiert und an die Empfänger gesendet.',
'schedules.toggle_active': 'Aktivieren/Deaktivieren',
// ── School District (Task 13) ──
'district.title': 'Schulbezirk',
'district.schools': 'Schulen',
'district.comparison': 'Schulvergleich',
'district.analytics': 'Bezirksanalyse',
'district.create': 'Bezirk erstellen',
'district.edit': 'Bezirk bearbeiten',
'district.delete': 'Bezirk löschen',
'district.assign_school': 'Schule zuordnen',
'district.unassign_school': 'Schule entfernen',
'district.name': 'Name',
'district.code': 'Bezirk-Code',
'district.region': 'Region',
'district.country': 'Land',
'district.admin_email': 'Administrator-E-Mail',
'district.no_districts': 'Keine Schulbezirke vorhanden',
'district.no_districts_desc': 'Erstellen Sie Ihren ersten Schulbezirk.',
'district.school_count': 'Anzahl Schulen',
'district.student_count': 'Anzahl Schüler',
'district.confirm_delete': 'Bezirk löschen?',
'district.confirm_delete_desc': 'Dieser Schulbezirk wird gelöscht. Die zugeordneten Schulen bleiben erhalten.',
'district.top_performing': 'Beste Schule',
'district.needs_attention': 'Schulung empfohlen',
'district.avg_attendance': 'Durchschnittliche Anwesenheit',
'district.competency_coverage': 'Kompetenzabdeckung',
'district.no_schools_assigned': 'Keine Schulen zugeordnet',
'district.select_school': 'Schule auswählen',
'district.cross_school_comparison': 'Schulvergleich',
'district.total_students': 'Gesamtschüler',
'district.total_classes': 'Gesamtklassen',
'district.total_teachers': 'Gesamtlehrer',
// PWA / Offline
'pwa.offline': 'Sie sind offline',
'pwa.offline_desc': 'Ihre Daten werden aus dem Cache geladen.',
'pwa.back_online': 'Wieder online',
'pwa.back_online_desc': 'Ihre Verbindung wurde wiederhergestellt.',
'pwa.cached_data': 'Cache-Daten',
'pwa.install_app': 'App installieren',
'pwa.install_app_desc': 'CompetenceTrack als App auf Ihrem Gerät installieren.',
'pwa.install': 'Installieren',
'pwa.installed': 'App installiert',
'pwa.installed_desc': 'CompetenceTrack wurde erfolgreich als App installiert.',
'pwa.install_accepted': 'Installation gestartet',
'pwa.update_available': 'Update verfügbar',
'pwa.update_available_desc': 'Eine neue Version ist verfügbar. Laden Sie die Seite neu.',
'pwa.reload': 'Neu laden',
'pwa.offline_mode': 'Offline-Modus',
'pwa.pending_sync': '{count} ausstehende Synchronisationen',
'pwa.sync_complete': 'Synchronisation abgeschlossen',
'pwa.sync_complete_desc': '{count} Änderungen wurden erfolgreich synchronisiert.',
'pwa.notebook_cached': 'Offline verfügbar',
'pwa.notebook_cached_desc': 'Dieses Notizbuch ist für die Offline-Nutzung gespeichert.',
'pwa.cache_notebook': 'Offline speichern',
'pwa.cache_notebook_desc': 'Notizbuch für Offline-Zugriff speichern.',
'pwa.cached_at': 'Gespeichert am',
'pwa.queued_edits': 'Ausstehende Änderungen',
'pwa.queued_edits_desc': '{count} Änderungen werden synchronisiert, wenn Sie wieder online sind.',
// Rate Limiting
'rate_limit.title': 'Zu viele Anfragen',
'rate_limit.description': 'Bitte warten Sie {retryAfter} bevor Sie es erneut versuchen.',
'rate_limit.status_title': 'Rate-Limit-Status',
'rate_limit.active_entries': 'Aktive Einträge',
'rate_limit.top_consumers': 'Top-Verbraucher',
'rate_limit.requests': 'Anfragen',
'rate_limit.no_active': 'Keine aktiven Rate-Limits',
'rate_limit.unavailable': 'Rate-Limit-Status nicht verfügbar',
'rate_limit.auth_limit': 'Auth-Limit: 5/Min',
'rate_limit.data_limit': 'Daten-Limit: 60/Min',
'rate_limit.write_limit': 'Schreib-Limit: 30/Min',
'rate_limit.heavy_limit': 'Schwer-Limit: 10/5Min',
}; };
const en: TranslationDict = { const en: TranslationDict = {
@@ -3714,6 +3912,93 @@ const en: TranslationDict = {
'behavior.duplicate_category': 'A category with this name already exists', 'behavior.duplicate_category': 'A category with this name already exists',
'behavior.confirm_close': 'Are you sure you want to close? Unsaved changes will be lost.', 'behavior.confirm_close': 'Are you sure you want to close? Unsaved changes will be lost.',
// Behavior Analytics
'behavior.tab_analytics': 'Analytics',
'behavior.tab_interventions': 'Interventions',
'behavior.analytics.trend': 'Trend',
'behavior.analytics.trend_desc': 'Positive vs. negative incidents over time',
'behavior.analytics.category_dist': 'Category Distribution',
'behavior.analytics.category_dist_desc': 'Breakdown by behavior category',
'behavior.analytics.risk_profile': 'Risk Profile',
'behavior.analytics.risk_profile_desc': 'Students with frequent negative incidents',
'behavior.analytics.class_comparison': 'Class Comparison',
'behavior.analytics.class_comparison_desc': 'Compare behavior patterns across classes',
'behavior.analytics.time_of_day': 'Time-of-Day Analysis',
'behavior.analytics.time_of_day_desc': 'When incidents most frequently occur',
'behavior.analytics.positive_trend': 'Positive Reinforcement',
'behavior.analytics.positive_trend_desc': 'Students with positive behavior trends',
'behavior.analytics.positive_change': 'Positive change',
'behavior.analytics.negative_change': 'Negative change',
'behavior.analytics.period': 'Period',
'behavior.analytics.last_30': 'Last 30 days',
'behavior.analytics.last_90': 'Last 90 days',
'behavior.analytics.this_year': 'This school year',
'behavior.analytics.risk_high': 'High risk',
'behavior.analytics.risk_medium': 'Medium risk',
'behavior.analytics.risk_low': 'Low risk',
'behavior.analytics.incidents_count_short': 'Incidents',
// Behavior Interventions
'behavior.intervention.title': 'Behavior Interventions',
'behavior.intervention.subtitle': 'Track interventions and their effectiveness',
'behavior.intervention.add': 'Add Intervention',
'behavior.intervention.edit': 'Edit Intervention',
'behavior.intervention.type': 'Type',
'behavior.intervention.type.warning': 'Warning',
'behavior.intervention.type.meeting': 'Meeting',
'behavior.intervention.type.action_plan': 'Action Plan',
'behavior.intervention.type.support': 'Support',
'behavior.intervention.type.parent_contact': 'Parent Contact',
'behavior.intervention.description': 'Description',
'behavior.intervention.description_placeholder': 'Describe the intervention…',
'behavior.intervention.status': 'Status',
'behavior.intervention.status.planned': 'Planned',
'behavior.intervention.status.in_progress': 'In Progress',
'behavior.intervention.status.completed': 'Completed',
'behavior.intervention.status.cancelled': 'Cancelled',
'behavior.intervention.assigned_to': 'Assigned To',
'behavior.intervention.assigned_to_placeholder': 'Select a teacher',
'behavior.intervention.start_date': 'Start Date',
'behavior.intervention.end_date': 'End Date',
'behavior.intervention.outcome': 'Outcome',
'behavior.intervention.outcome_placeholder': 'Outcome of the intervention…',
'behavior.intervention.linked_incident': 'Linked Incident',
'behavior.intervention.no_incident': 'No incident linked',
'behavior.intervention.effectiveness': 'Effectiveness',
'behavior.intervention.progress': 'Progress',
'behavior.intervention.no_interventions': 'No interventions yet',
'behavior.intervention.no_interventions_desc': 'Create an intervention to support behavior improvement.',
'behavior.intervention.saved': 'Intervention saved',
'behavior.intervention.deleted': 'Intervention deleted',
'behavior.intervention.error_save': 'Error saving intervention',
'behavior.intervention.error_delete': 'Error deleting intervention',
'behavior.intervention.required_type': 'Please select a type',
'behavior.intervention.required_description': 'Please enter a description',
'behavior.intervention.filter_status': 'Filter by status',
'behavior.intervention.all_statuses': 'All statuses',
'behavior.intervention.filter_type': 'Filter by type',
'behavior.intervention.all_types': 'All types',
// Audit Log Enhancement
'audit.entity_type': 'Entity Type',
'audit.entity_type_filter': 'Filter by type',
'audit.all_entity_types': 'All types',
'audit.user_filter': 'Filter by user',
'audit.all_users': 'All users',
'audit.changes': 'Changes',
'audit.before': 'Before',
'audit.after': 'After',
'audit.detail': 'Detail View',
'audit.export_csv': 'Export as CSV',
'audit.page': 'Page',
'audit.of': 'of',
'audit.entries': 'entries',
'audit.previous': 'Previous',
'audit.next': 'Next',
'audit.no_changes': 'No change details available',
'audit.ip_address': 'IP Address',
'audit.user_agent': 'User Agent',
// Curriculum Coverage // Curriculum Coverage
'nav.coverage': 'Coverage', 'nav.coverage': 'Coverage',
'coverage.title': 'Curriculum Coverage', 'coverage.title': 'Curriculum Coverage',
@@ -4653,6 +4938,117 @@ const en: TranslationDict = {
'performance.title': 'Leistung', 'performance.title': 'Leistung',
'performance.fps': 'Bilder pro Sekunde', 'performance.fps': 'Bilder pro Sekunde',
'mobile.optimized': 'Für mobile Geräte optimiert', 'mobile.optimized': 'Für mobile Geräte optimiert',
// ── Report Scheduling (Task 13) ──
'schedules.title': 'Report Scheduling',
'schedules.frequency': 'Frequency',
'schedules.weekly': 'Weekly',
'schedules.monthly': 'Monthly',
'schedules.quarterly': 'Quarterly',
'schedules.end_of_term': 'End of Term',
'schedules.next_run': 'Next Run',
'schedules.last_run': 'Last Run',
'schedules.manual_run': 'Run Manually',
'schedules.recipients': 'Recipients',
'schedules.include_students': 'Include Students',
'schedules.include_grades': 'Include Grades',
'schedules.include_attendance': 'Include Attendance',
'schedules.include_behavior': 'Include Behavior',
'schedules.include_competencies': 'Include Competencies',
'schedules.create_schedule': 'Create Schedule',
'schedules.edit_schedule': 'Edit Schedule',
'schedules.delete_schedule': 'Delete Schedule',
'schedules.active': 'Active',
'schedules.inactive': 'Inactive',
'schedules.no_schedules': 'No report schedules found',
'schedules.no_schedules_desc': 'Create your first report schedule.',
'schedules.running': 'Running...',
'schedules.run_success': 'Report generated successfully',
'schedules.run_error': 'Error generating report',
'schedules.day_of_week': 'Day of Week',
'schedules.day_of_month': 'Day of Month',
'schedules.month_of_year': 'Month',
'schedules.template': 'Template',
'schedules.all_classes': 'All Classes',
'schedules.timeline': 'Timeline',
'schedules.confirm_delete': 'Delete schedule?',
'schedules.confirm_delete_desc': 'This report schedule will be permanently deleted.',
'schedules.confirm_run': 'Run manually?',
'schedules.confirm_run_desc': 'The report will be generated immediately and sent to recipients.',
'schedules.toggle_active': 'Activate/Deactivate',
// ── School District (Task 13) ──
'district.title': 'School District',
'district.schools': 'Schools',
'district.comparison': 'School Comparison',
'district.analytics': 'District Analytics',
'district.create': 'Create District',
'district.edit': 'Edit District',
'district.delete': 'Delete District',
'district.assign_school': 'Assign School',
'district.unassign_school': 'Remove School',
'district.name': 'Name',
'district.code': 'District Code',
'district.region': 'Region',
'district.country': 'Country',
'district.admin_email': 'Admin Email',
'district.no_districts': 'No school districts found',
'district.no_districts_desc': 'Create your first school district.',
'district.school_count': 'School Count',
'district.student_count': 'Student Count',
'district.confirm_delete': 'Delete district?',
'district.confirm_delete_desc': 'This district will be deleted. Associated schools will remain.',
'district.top_performing': 'Top Performing',
'district.needs_attention': 'Needs Attention',
'district.avg_attendance': 'Average Attendance',
'district.competency_coverage': 'Competency Coverage',
'district.no_schools_assigned': 'No schools assigned',
'district.select_school': 'Select School',
'district.cross_school_comparison': 'Cross-School Comparison',
'district.total_students': 'Total Students',
'district.total_classes': 'Total Classes',
'district.total_teachers': 'Total Teachers',
// PWA / Offline
'pwa.offline': 'You are offline',
'pwa.offline_desc': 'Your data is being loaded from cache.',
'pwa.back_online': 'Back online',
'pwa.back_online_desc': 'Your connection has been restored.',
'pwa.cached_data': 'Cached data',
'pwa.install_app': 'Install App',
'pwa.install_app_desc': 'Install CompetenceTrack as an app on your device.',
'pwa.install': 'Install',
'pwa.installed': 'App installed',
'pwa.installed_desc': 'CompetenceTrack has been successfully installed as an app.',
'pwa.install_accepted': 'Installation started',
'pwa.update_available': 'Update available',
'pwa.update_available_desc': 'A new version is available. Reload the page to update.',
'pwa.reload': 'Reload',
'pwa.offline_mode': 'Offline Mode',
'pwa.pending_sync': '{count} pending synchronizations',
'pwa.sync_complete': 'Sync complete',
'pwa.sync_complete_desc': '{count} changes were successfully synchronized.',
'pwa.notebook_cached': 'Available offline',
'pwa.notebook_cached_desc': 'This notebook is saved for offline use.',
'pwa.cache_notebook': 'Save offline',
'pwa.cache_notebook_desc': 'Save notebook for offline access.',
'pwa.cached_at': 'Cached at',
'pwa.queued_edits': 'Pending changes',
'pwa.queued_edits_desc': '{count} changes will be synced when you are back online.',
// Rate Limiting
'rate_limit.title': 'Too many requests',
'rate_limit.description': 'Please wait {retryAfter} before trying again.',
'rate_limit.status_title': 'Rate Limit Status',
'rate_limit.active_entries': 'Active entries',
'rate_limit.top_consumers': 'Top consumers',
'rate_limit.requests': 'requests',
'rate_limit.no_active': 'No active rate limits',
'rate_limit.unavailable': 'Rate limit status unavailable',
'rate_limit.auth_limit': 'Auth limit: 5/min',
'rate_limit.data_limit': 'Data limit: 60/min',
'rate_limit.write_limit': 'Write limit: 30/min',
'rate_limit.heavy_limit': 'Heavy limit: 10/5min',
}; };
const translations: Record<string, TranslationDict> = { de, en }; const translations: Record<string, TranslationDict> = { de, en };
+257
View File
@@ -0,0 +1,257 @@
// CompetenceTrack — Offline Cache for Notebook Data
// Stores notebook data in localStorage for offline access
const NOTEBOOK_CACHE_KEY = 'ct_notebook_cache';
const NOTEBOOK_CACHE_TIMESTAMP = 'ct_notebook_cache_ts';
const NOTEBOOK_EDITS_QUEUE = 'ct_notebook_edits_queue';
interface CachedNotebook {
id: string;
title: string;
notebookType: string;
coverColor: string;
schoolId: string;
ownerId: string;
isArchived: boolean;
pages: CachedPage[];
cachedAt: number;
}
interface CachedPage {
id: string;
title: string;
textContent: string;
drawingData: string | null;
background: string;
pageNumber: number;
}
interface QueuedEdit {
id: string;
notebookId: string;
pageId: string;
field: string;
value: string;
timestamp: number;
retries: number;
}
// ─── Save notebook data to localStorage ───────────────────────────────
export function cacheNotebook(notebook: {
id: string;
title: string;
notebookType: string;
coverColor: string;
schoolId: string;
ownerId: string;
isArchived: boolean;
pages: Array<{
id: string;
title: string;
textContent: string;
drawingData: string | null;
background: string;
pageNumber: number;
}>;
}): void {
try {
const cache: Record<string, CachedNotebook> = JSON.parse(
localStorage.getItem(NOTEBOOK_CACHE_KEY) || '{}'
);
cache[notebook.id] = {
...notebook,
cachedAt: Date.now(),
};
localStorage.setItem(NOTEBOOK_CACHE_KEY, JSON.stringify(cache));
localStorage.setItem(NOTEBOOK_CACHE_TIMESTAMP, String(Date.now()));
} catch (error) {
console.warn('[OfflineCache] Failed to cache notebook:', error);
}
}
// ─── Retrieve cached notebook ──────────────────────────────────────────
export function getCachedNotebook(id: string): CachedNotebook | null {
try {
const cache: Record<string, CachedNotebook> = JSON.parse(
localStorage.getItem(NOTEBOOK_CACHE_KEY) || '{}'
);
return cache[id] || null;
} catch {
return null;
}
}
// ─── Get all cached notebooks ──────────────────────────────────────────
export function getAllCachedNotebooks(): CachedNotebook[] {
try {
const cache: Record<string, CachedNotebook> = JSON.parse(
localStorage.getItem(NOTEBOOK_CACHE_KEY) || '{}'
);
return Object.values(cache).sort((a, b) => b.cachedAt - a.cachedAt);
} catch {
return [];
}
}
// ─── Remove a cached notebook ──────────────────────────────────────────
export function removeCachedNotebook(id: string): void {
try {
const cache: Record<string, CachedNotebook> = JSON.parse(
localStorage.getItem(NOTEBOOK_CACHE_KEY) || '{}'
);
delete cache[id];
localStorage.setItem(NOTEBOOK_CACHE_KEY, JSON.stringify(cache));
} catch {
// ignore
}
}
// ─── Check if a notebook is cached ──────────────────────────────────────
export function isNotebookCached(id: string): boolean {
try {
const cache: Record<string, CachedNotebook> = JSON.parse(
localStorage.getItem(NOTEBOOK_CACHE_KEY) || '{}'
);
return !!cache[id];
} catch {
return false;
}
}
// ─── Get cache timestamp ──────────────────────────────────────────────
export function getCacheTimestamp(): number {
try {
return Number(localStorage.getItem(NOTEBOOK_CACHE_TIMESTAMP) || '0');
} catch {
return 0;
}
}
// ─── Queue an edit for sync when back online ────────────────────────────
export function queueNotebookEdit(
notebookId: string,
pageId: string,
field: string,
value: string
): void {
try {
const queue: QueuedEdit[] = JSON.parse(
localStorage.getItem(NOTEBOOK_EDITS_QUEUE) || '[]'
);
queue.push({
id: `edit_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
notebookId,
pageId,
field,
value,
timestamp: Date.now(),
retries: 0,
});
localStorage.setItem(NOTEBOOK_EDITS_QUEUE, JSON.stringify(queue));
// Also update the cached notebook locally
const cache: Record<string, CachedNotebook> = JSON.parse(
localStorage.getItem(NOTEBOOK_CACHE_KEY) || '{}'
);
const cached = cache[notebookId];
if (cached) {
const page = cached.pages.find((p) => p.id === pageId);
if (page) {
(page as Record<string, unknown>)[field] = value;
localStorage.setItem(NOTEBOOK_CACHE_KEY, JSON.stringify(cache));
}
}
} catch (error) {
console.warn('[OfflineCache] Failed to queue edit:', error);
}
}
// ─── Get queued edits ──────────────────────────────────────────────────
export function getQueuedEdits(): QueuedEdit[] {
try {
return JSON.parse(localStorage.getItem(NOTEBOOK_EDITS_QUEUE) || '[]');
} catch {
return [];
}
}
// ─── Replay queued edits when back online ───────────────────────────────
export async function replayQueuedEdits(): Promise<{ success: number; failed: number }> {
const queue = getQueuedEdits();
let success = 0;
let failed = 0;
for (const edit of queue) {
try {
const res = await fetch(`/api/notebooks/${edit.notebookId}/pages/${edit.pageId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [edit.field]: edit.value }),
});
if (res.ok) {
removeQueuedEdit(edit.id);
success++;
} else {
failed++;
}
} catch {
failed++;
}
}
return { success, failed };
}
// ─── Remove a queued edit ──────────────────────────────────────────────
export function removeQueuedEdit(id: string): void {
try {
const queue: QueuedEdit[] = JSON.parse(
localStorage.getItem(NOTEBOOK_EDITS_QUEUE) || '[]'
);
const filtered = queue.filter((e) => e.id !== id);
localStorage.setItem(NOTEBOOK_EDITS_QUEUE, JSON.stringify(filtered));
} catch {
// ignore
}
}
// ─── Clear all queued edits ────────────────────────────────────────────
export function clearQueuedEdits(): void {
try {
localStorage.removeItem(NOTEBOOK_EDITS_QUEUE);
} catch {
// ignore
}
}
// ─── Clear all notebook cache ──────────────────────────────────────────
export function clearNotebookCache(): void {
try {
localStorage.removeItem(NOTEBOOK_CACHE_KEY);
localStorage.removeItem(NOTEBOOK_CACHE_TIMESTAMP);
} catch {
// ignore
}
}
// ─── Get offline status ──────────────────────────────────────────────
export function isOffline(): boolean {
return typeof navigator !== 'undefined' && !navigator.onLine;
}
+218
View File
@@ -0,0 +1,218 @@
// CompetenceTrack — API Rate Limiting Middleware
// In-memory rate limiting with configurable limits per endpoint
interface RateLimitEntry {
count: number;
resetTime: number;
}
// ─── Rate Limit Store ──────────────────────────────────────────────────
const rateLimitStore = new Map<string, RateLimitEntry>();
// Cleanup expired entries every 60 seconds
setInterval(() => {
const now = Date.now();
for (const [key, entry] of rateLimitStore.entries()) {
if (now > entry.resetTime) {
rateLimitStore.delete(key);
}
}
}, 60 * 1000);
// ─── Preset Limits ────────────────────────────────────────────────────
export const RATE_LIMITS = {
// Auth endpoints: 5 requests per minute (strict)
auth: { limit: 5, windowMs: 60 * 1000 },
// Data read endpoints: 60 requests per minute (generous)
dataRead: { limit: 60, windowMs: 60 * 1000 },
// Data write endpoints (POST, PUT, DELETE): 30 requests per minute
dataWrite: { limit: 30, windowMs: 60 * 1000 },
// Heavy operations (export, PDF generation): 10 requests per 5 minutes
heavy: { limit: 10, windowMs: 5 * 60 * 1000 },
// Default: 30 requests per minute
default: { limit: 30, windowMs: 60 * 1000 },
} as const;
// ─── Rate Limit Check ─────────────────────────────────────────────────
interface RateLimitResult {
allowed: boolean;
limit: number;
remaining: number;
resetTime: number;
retryAfterMs?: number;
}
export function checkRateLimit(
key: string,
limit: number,
windowMs: number
): RateLimitResult {
const now = Date.now();
const entry = rateLimitStore.get(key);
// If no entry or expired window, create new entry
if (!entry || now > entry.resetTime) {
const resetTime = now + windowMs;
rateLimitStore.set(key, { count: 1, resetTime });
return {
allowed: true,
limit,
remaining: limit - 1,
resetTime,
};
}
// If within window, increment count
if (entry.count < limit) {
entry.count += 1;
return {
allowed: true,
limit,
remaining: limit - entry.count,
resetTime: entry.resetTime,
};
}
// Rate limit exceeded
const retryAfterMs = entry.resetTime - now;
return {
allowed: false,
limit,
remaining: 0,
resetTime: entry.resetTime,
retryAfterMs,
};
}
// ─── Get Rate Limit Key ────────────────────────────────────────────────
export function getRateLimitKey(
request: Request,
userId?: string
): string {
// Get IP from headers or fallback
const forwarded = request.headers.get('x-forwarded-for');
const ip = forwarded?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
'unknown';
// Combine IP + userId for unique key
if (userId) {
return `${ip}:${userId}`;
}
return ip;
}
// ─── Rate Limit Headers ───────────────────────────────────────────────
export function getRateLimitHeaders(result: RateLimitResult): Headers {
const headers = new Headers();
headers.set('X-RateLimit-Limit', String(result.limit));
headers.set('X-RateLimit-Remaining', String(result.remaining));
headers.set('X-RateLimit-Reset', String(Math.ceil(result.resetTime / 1000)));
if (result.retryAfterMs) {
headers.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
}
return headers;
}
// ─── Rate Limit Response ──────────────────────────────────────────────
export function createRateLimitResponse(result: RateLimitResult): {
status: number;
body: { error: string; retryAfter: number; limit: number };
headers: Headers;
} {
return {
status: 429,
body: {
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((result.retryAfterMs || 0) / 1000),
limit: result.limit,
},
headers: getRateLimitHeaders(result),
};
}
// ─── withRateLimit Wrapper ────────────────────────────────────────────
import { NextResponse } from 'next/server';
type RouteHandler = (request: Request, context?: { params: Promise<Record<string, string>> }) => Promise<NextResponse>;
export function withRateLimit(
handler: RouteHandler,
limitPreset: keyof typeof RATE_LIMITS | { limit: number; windowMs: number }
): RouteHandler {
return async (request: Request, context?: { params: Promise<Record<string, string>> }) => {
const { limit, windowMs } = typeof limitPreset === 'string'
? RATE_LIMITS[limitPreset]
: limitPreset;
// Get user ID from session if available (for auth routes, we skip this)
const key = getRateLimitKey(request);
const result = checkRateLimit(key, limit, windowMs);
if (!result.allowed) {
const rateLimitResponse = createRateLimitResponse(result);
return NextResponse.json(rateLimitResponse.body, {
status: rateLimitResponse.status,
headers: rateLimitResponse.headers,
});
}
// Execute the handler
const response = await handler(request, context);
// Add rate limit headers to successful responses
const rateLimitHeaders = getRateLimitHeaders(result);
for (const [headerKey, headerValue] of rateLimitHeaders.entries()) {
response.headers.set(headerKey, headerValue);
}
return response;
};
}
// ─── Rate Limit Stats (for admin monitoring) ──────────────────────────
export interface RateLimitStats {
totalEntries: number;
activeKeys: string[];
topConsumers: Array<{ key: string; count: number; resetTime: number }>;
}
export function getRateLimitStats(): RateLimitStats {
const entries = Array.from(rateLimitStore.entries());
const now = Date.now();
// Filter out expired entries
const activeEntries = entries.filter(([, entry]) => now <= entry.resetTime);
// Sort by count (most active first)
const sorted = activeEntries.sort((a, b) => b[1].count - a[1].count);
return {
totalEntries: activeEntries.length,
activeKeys: sorted.map(([key]) => key),
topConsumers: sorted.slice(0, 20).map(([key, entry]) => ({
key,
count: entry.count,
resetTime: entry.resetTime,
})),
};
}
// ─── Clear all rate limit entries ─────────────────────────────────────
export function clearRateLimitEntries(): void {
rateLimitStore.clear();
}
+85
View File
@@ -1,5 +1,62 @@
# CompetenceTrack — Project Worklog # CompetenceTrack — Project Worklog
---
Task ID: 12
Agent: behavior-audit
Task: Behavior Tracking Enhancement + Audit Logging
Work Log:
- Added BehaviorIntervention Prisma model with fields: type, description, status, assignedTo, startDate, endDate, outcome, incidentId, isDemo, deletedAt
- Added relations to School, Student, BehaviorIncident, and User models
- Enhanced AuditLog model with new fields: changes (JSON before/after), ipAddress, userAgent
- Ran db:push to sync schema changes
- Created audit logging middleware (src/lib/audit.ts) with logAudit() and withAuditLog() helper functions
- Created Intervention API routes: GET/POST /api/behavior-interventions, GET/PUT/DELETE /api/behavior-interventions/[id]
- Enhanced Audit Log API with pagination, entityType filter, userId filter, and CSV export endpoint
- Added API client functions: fetchBehaviorInterventions, createBehaviorIntervention, updateBehaviorIntervention, deleteBehaviorIntervention, exportAuditLogCsv
- Enhanced AuditLogEntry type with changes, ipAddress, userAgent fields
- Added fetchAuditLog to return paginated response (AuditLogPaginatedResponse)
- Added "Analytics" tab to behavior-tracking-view.tsx with:
- Line chart: trend of positive vs negative incidents over time (by week)
- Pie chart: category distribution breakdown
- Bar chart: time-of-day analysis showing when incidents most frequently occur
- Horizontal stacked bar chart: class comparison across classes
- Risk profile: students with frequent negative incidents (high/medium/low risk)
- Positive reinforcement: students with positive behavior trends
- Period selector: last 30 days, last 90 days, this school year
- Added "Interventions" tab to behavior-tracking-view.tsx with:
- Intervention CRUD: create, edit, delete interventions
- Status tracking with progress indicators (planned/in_progress/completed/cancelled)
- Type filter and status filter
- Quick status change buttons (planned -> in_progress -> completed)
- Effectiveness outcome display for completed interventions
- Linked incident display
- Assigned user display
- Intervention form dialog with all fields
- Enhanced settings-view.tsx audit tab with:
- Entity type filter chips
- Pagination (previous/next page controls)
- CSV export button
- Click-to-view detail dialog showing before/after changes
- IP address display in timeline entries
- User agent display in detail dialog
- Added 150+ new i18n keys (DE + EN) for behavior analytics, interventions, and audit log enhancement
- Lint passes with 0 errors
Stage Summary:
- New Prisma model: BehaviorIntervention (behavior_interventions table)
- Enhanced AuditLog model with changes, ipAddress, userAgent fields
- New file: src/lib/audit.ts (audit logging middleware)
- New files: src/app/api/behavior-interventions/route.ts, src/app/api/behavior-interventions/[id]/route.ts
- Modified: src/app/api/audit-log/route.ts (pagination, filters, CSV export)
- Modified: src/lib/api.ts (intervention API functions, enhanced audit log types)
- Modified: src/lib/i18n.ts (150+ new DE/EN keys)
- Modified: src/components/behavior-tracking-view.tsx (analytics tab + interventions tab)
- Modified: src/components/settings-view.tsx (enhanced audit tab with pagination, filters, export, detail view)
- Modified: prisma/schema.prisma (BehaviorIntervention model + AuditLog enhancements)
---
## Current Project Status (Round 14 — Lint Cleanup, ESLint Config Fix, Hook Violation Fix, WS Service Restart) ## Current Project Status (Round 14 — Lint Cleanup, ESLint Config Fix, Hook Violation Fix, WS Service Restart)
**Status**: Stable, all features working, lint passes with 0 errors **Status**: Stable, all features working, lint passes with 0 errors
@@ -458,3 +515,31 @@ See git history for details on earlier rounds.
- App running on localhost:3000 (200 OK) - App running on localhost:3000 (200 OK)
- Backup API responding correctly - Backup API responding correctly
- Prisma schema pushed successfully - Prisma schema pushed successfully
---
Task ID: 11
Agent: offline-pwa-rate-limit
Task: Offline Mode (PWA) + API Rate Limiting
Work Log:
- Created Service Worker at public/sw.js with NetworkFirst (API), CacheFirst (static), navigation fallback, background sync, IndexedDB for pending requests, push notification support
- Created Web App Manifest at public/manifest.json with emerald theme, standalone display, German description
- Generated PWA icons (icon-192.png, icon-512.png) using sharp from SVG with emerald-teal gradient and book icon
- Updated src/app/layout.tsx with manifest metadata, appleWebApp config, Viewport export with themeColor, apple-touch-icon link
- Created src/components/offline-indicator.tsx with OfflineIndicator bar, PWAInstallPrompt card, useServiceWorker hook, OfflineBadge, OfflineSyncManager, showRateLimitToast, RateLimitStatus component
- Created src/lib/offline-cache.ts with localStorage notebook caching (cacheNotebook, getCachedNotebook, queueNotebookEdit, replayQueuedEdits)
- Integrated offline components in src/components/app-layout.tsx (SW registration, OfflineIndicator, PWAInstallPrompt, OfflineSyncManager)
- Created src/lib/rate-limit.ts with in-memory rate limiting (Map-based), configurable presets (auth:5/min, dataRead:60/min, dataWrite:30/min, heavy:10/5min), withRateLimit wrapper, rate limit headers, 429 response, stats endpoint
- Applied rate limiting to /api/auth (auth preset), /api/data-export/csv (heavy), /api/reports/pdf (heavy), /api/data-import (heavy), /api/backup (heavy)
- Created /api/rate-limit-stats API endpoint for admin monitoring
- Updated src/lib/api.ts with 429 handling and RateLimitError interface
- Added RateLimitStatus tab in settings-view.tsx with Shield icon and rose color scheme
- Added 40 i18n keys (32 PWA/offline + 8 rate limit) in DE and EN
- Added offline/PWA/rate-limit CSS styles in globals.css
Stage Summary:
- Full PWA support with offline mode, service worker, install prompt, and offline indicator
- Notebook data cached in localStorage for offline access with queued edits for sync
- API rate limiting with configurable presets protecting auth, data, and heavy operation endpoints
- Rate limit admin monitoring in settings view
- 8 new files, 11 modified files, 0 lint errors