Round 13: PWA Offline Mode, Rate Limiting, Behavior Analytics, Interventions, Audit Logging
This commit is contained in:
1 parent
ceea8fb591
commit
ff4dd670d4
33 files changed
+6327
-509
No files matched your search
@@ -115,6 +115,7 @@ import {
|
||||
} from '@/components/ui/command';
|
||||
import { toast } from 'sonner';
|
||||
import { useWebSocket, usePushNotifications, playNotificationSound, getNotificationSoundPref } from '@/lib/websocket';
|
||||
import { OfflineIndicator, PWAInstallPrompt, useServiceWorker, OfflineSyncManager } from '@/components/offline-indicator';
|
||||
|
||||
import DashboardView from './dashboard-view';
|
||||
import ClassesView from './classes-view';
|
||||
@@ -257,6 +258,9 @@ export default function AppLayout() {
|
||||
const [schoolYears, setSchoolYears] = useState<SchoolYear[]>([]);
|
||||
const [selectedYearId, setSelectedYearId] = useState<string>(storeSchoolYearId ?? '');
|
||||
|
||||
// Service Worker registration for PWA
|
||||
useServiceWorker();
|
||||
|
||||
// WebSocket connection for real-time push notifications
|
||||
const { connected: wsConnected } = useWebSocket();
|
||||
const { notification: pushNotification } = usePushNotifications();
|
||||
@@ -636,6 +640,12 @@ export default function AppLayout() {
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
{/* Offline indicator bar */}
|
||||
<OfflineIndicator />
|
||||
{/* PWA install prompt */}
|
||||
<PWAInstallPrompt />
|
||||
{/* Offline sync manager */}
|
||||
<OfflineSyncManager />
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="icon"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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';
|
||||
+1078
-475
File diff suppressed because it is too large.
Load diff
@@ -46,6 +46,11 @@ import {
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Bell,
|
||||
MapPin,
|
||||
Building2,
|
||||
ArrowRightLeft,
|
||||
TrendingDown,
|
||||
ArrowUpRight,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -113,7 +118,16 @@ import {
|
||||
type Subject,
|
||||
type AuditLogEntry,
|
||||
type UserAccount,
|
||||
exportAuditLogCsv,
|
||||
type SchoolDistrictData,
|
||||
fetchDistricts,
|
||||
createDistrict,
|
||||
updateDistrict,
|
||||
deleteDistrict,
|
||||
fetchDistrictSchools,
|
||||
assignSchoolToDistrict,
|
||||
} from '@/lib/api';
|
||||
import { RateLimitStatus } from '@/components/offline-indicator';
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
@@ -204,9 +218,13 @@ export default function SettingsView() {
|
||||
const [auditEntries, setAuditEntries] = useState<AuditLogEntry[]>([]);
|
||||
const [auditLoading, setAuditLoading] = useState(true);
|
||||
const [auditActionFilter, setAuditActionFilter] = useState('all');
|
||||
const [auditEntityTypeFilter, setAuditEntityTypeFilter] = useState('all');
|
||||
const [auditDateFrom, setAuditDateFrom] = useState('');
|
||||
const [auditDateTo, setAuditDateTo] = 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
|
||||
const [showErasureDialog, setShowErasureDialog] = useState(false);
|
||||
@@ -294,6 +312,23 @@ export default function SettingsView() {
|
||||
const [autoBackupEnabled, setAutoBackupEnabled] = useState(false);
|
||||
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 loadSchools = useCallback(async () => {
|
||||
@@ -359,16 +394,20 @@ export default function SettingsView() {
|
||||
const data = await fetchAuditLog({
|
||||
schoolId: currentUser?.schoolId ?? undefined,
|
||||
action: auditActionFilter !== 'all' ? auditActionFilter : undefined,
|
||||
entityType: auditEntityTypeFilter !== 'all' ? auditEntityTypeFilter : undefined,
|
||||
startDate: auditDateFrom || undefined,
|
||||
endDate: auditDateTo || undefined,
|
||||
page: auditPage,
|
||||
pageSize: 50,
|
||||
});
|
||||
setAuditEntries(data);
|
||||
setAuditEntries(data.entries);
|
||||
setAuditTotalPages(data.pagination.totalPages);
|
||||
} catch {
|
||||
toast.error(t('error.generic'));
|
||||
} finally {
|
||||
setAuditLoading(false);
|
||||
}
|
||||
}, [currentUser?.schoolId, auditActionFilter, auditDateFrom, auditDateTo]);
|
||||
}, [currentUser?.schoolId, auditActionFilter, auditEntityTypeFilter, auditDateFrom, auditDateTo, auditPage]);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setUsersLoading(true);
|
||||
@@ -425,7 +464,133 @@ export default function SettingsView() {
|
||||
if (tab === 'backup') {
|
||||
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 () => {
|
||||
if (!currentUser?.schoolId) return;
|
||||
@@ -962,6 +1127,16 @@ export default function SettingsView() {
|
||||
<HardDrive className="h-4 w-4 mr-1.5" />
|
||||
<span className="hidden sm:inline">{t('backup.title')}</span>
|
||||
</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>
|
||||
|
||||
{/* ── School Info Tab ─────────────────────────────────── */}
|
||||
@@ -1692,12 +1867,29 @@ export default function SettingsView() {
|
||||
<TabsContent value="audit">
|
||||
<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">
|
||||
<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">
|
||||
<Shield className="h-4 w-4" />
|
||||
</div>
|
||||
{t('settings.audit_title')}
|
||||
</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
<Shield className="h-4 w-4" />
|
||||
</div>
|
||||
{t('settings.audit_title')}
|
||||
</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>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -1724,8 +1916,8 @@ export default function SettingsView() {
|
||||
].map((chip) => (
|
||||
<button
|
||||
key={chip.value}
|
||||
onClick={() => setAuditActionFilter(chip.value)}
|
||||
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-all ${
|
||||
onClick={() => { setAuditActionFilter(chip.value); setAuditPage(1); }}
|
||||
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-all min-h-[36px] ${
|
||||
auditActionFilter === chip.value
|
||||
? `${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'
|
||||
@@ -1736,6 +1928,30 @@ export default function SettingsView() {
|
||||
))}
|
||||
</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 */}
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="space-y-1">
|
||||
@@ -1743,7 +1959,7 @@ export default function SettingsView() {
|
||||
<Input
|
||||
type="date"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -1752,7 +1968,7 @@ export default function SettingsView() {
|
||||
<Input
|
||||
type="date"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -1760,7 +1976,7 @@ export default function SettingsView() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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" />
|
||||
{t('action.refresh')}
|
||||
@@ -1824,7 +2040,8 @@ export default function SettingsView() {
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
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 */}
|
||||
<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>
|
||||
<span>{actorName}</span>
|
||||
</div>
|
||||
{entry.ipAddress && (
|
||||
<span className="text-gray-400 dark:text-gray-500 ml-2">IP: {entry.ipAddress}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -1872,8 +2092,115 @@ export default function SettingsView() {
|
||||
</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>
|
||||
</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>
|
||||
|
||||
{/* ── Data Management Tab ─────────────────────────────── */}
|
||||
@@ -2523,6 +2850,364 @@ export default function SettingsView() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user