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
+6327 -509

No files matched your search

+10
View File
@@ -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
+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';
File diff suppressed because it is too large. Load diff
+700 -15
View File
@@ -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>
);
}