From a081b3ebe437340dd0c6a33d0ed98466d99280d8 Mon Sep 17 00:00:00 2001 From: Pepe Ziberi Date: Sat, 18 Jul 2026 23:31:17 +0200 Subject: [PATCH] refactor(admin): 1365-Zeilen-Seite in saubere Bereichs-Komponenten entwirrt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin/page.tsx: 1365 -> 224 Zeilen (nur noch Guard + Header + Tabs -> Komponenten) - Neue in sich geschlossene Komponenten (eigener State + Daten-Laden): tenants-tab, users-tab, projects-tab, icons-tab, categories-tab - Gemeinsame Typen/Konstanten in admin-constants.ts zentralisiert - SettingsTab lädt Zähler selbst (von der Hülle entkoppelt) - Toten Code entfernt (UpgradeRequestsTab) - Ergänzt: Benutzer-Suche + Mandanten-Auswahl beim Anlegen (verhindert verwaiste User) - Version 1.4.7 Co-Authored-By: Claude Opus 4.8 (1M context) --- ROADMAP.md | 13 +- package.json | 2 +- src/app/admin/page.tsx | 1435 ++--------------------- src/components/admin/admin-constants.ts | 62 + src/components/admin/categories-tab.tsx | 111 ++ src/components/admin/icons-tab.tsx | 235 ++++ src/components/admin/projects-tab.tsx | 93 ++ src/components/admin/settings-tab.tsx | 25 +- src/components/admin/tenants-tab.tsx | 126 ++ src/components/admin/users-tab.tsx | 336 ++++++ 10 files changed, 1115 insertions(+), 1323 deletions(-) create mode 100644 src/components/admin/admin-constants.ts create mode 100644 src/components/admin/categories-tab.tsx create mode 100644 src/components/admin/icons-tab.tsx create mode 100644 src/components/admin/projects-tab.tsx create mode 100644 src/components/admin/tenants-tab.tsx create mode 100644 src/components/admin/users-tab.tsx diff --git a/ROADMAP.md b/ROADMAP.md index cf5e8c2..e451712 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,12 +15,13 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross - [x] **0.2 – Token-Laufzeit verkürzt** 🟢 ✅ - „Angemeldet bleiben" 30 → 14 Tage. Laufzeiten zentralisiert (`SESSION_MAX_AGE_*` in `auth.ts`). - _Offen für später: automatische Verlängerung bei Aktivität + serverseitiger Widerruf (grösserer Umbau, braucht Live-Test)._ -- [~] **0.3 – Benutzer-/Mandanten-Verwaltung** 🟡 _(Teil 1 erledigt)_ - - ✅ Super-Admin sieht jetzt ALLE Benutzer über alle Mandanten in einer Tabelle - (Mandant-Spalte, letzte Anmeldung, Status), kann einzeln sperren/entsperren - (`User.isActive`, Login-Sperre) und Passwort zurücksetzen. - - Offen: die grosse `admin/page.tsx` (1365 Z.) strukturell entwirren (7 Domänen - auf einem Screen) + Rollen-Dualität (User.role vs. Membership.role) vereinheitlichen. +- [x] **0.3 – Benutzer-/Mandanten-Verwaltung** 🟡 ✅ + - Super-Admin sieht ALLE Benutzer über alle Mandanten (Mandant-Spalte, letzte + Anmeldung, Status), sperren/entsperren (`User.isActive`), Passwort-Reset, **Suche**. + - **Anlegen mit Mandanten-Auswahl** (verhindert verwaiste User). + - `admin/page.tsx` von 1365 → ~230 Zeilen entwirrt: je Bereich eine eigene + Komponente (tenants/users/projects/icons/categories-tab), toter Code entfernt. + - Offen (später): Rollen-Dualität (User.role vs. Membership.role) vereinheitlichen. ## Phase 1 – Einsatz vs. Übung diff --git a/package.json b/package.json index 82f38b0..84d42f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.4.6", + "version": "1.4.7", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "private": true, "scripts": { diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 8563ae6..da8a4c4 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2,528 +2,49 @@ import { useState, useEffect } from 'react' import { useRouter } from 'next/navigation' +import Link from 'next/link' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { ScrollArea } from '@/components/ui/scroll-area' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { useToast } from '@/components/ui/use-toast' import { useAuth } from '@/components/providers/auth-provider' import { - Plus, - Upload, - Pencil, - Trash2, - FolderPlus, - ArrowLeft, - Eye, - EyeOff, - Users, - Settings, - Shield, - UserPlus, - Image, - Layers, - Loader2, - MapPin, - CheckCircle, - Ban, - Clock, - KeyRound, - Copy, - Heart, - Map, - ShieldCheck, - ClipboardList, - X, - BookOpen, - AlertTriangle, - LayoutGrid, - Building2, + ArrowLeft, MapPin, Shield, Map, Image as ImageIcon, Layers, BookOpen, + Users, Settings, Building2, AlertTriangle, ClipboardList, Heart, ShieldCheck, Loader2, } from 'lucide-react' -import Link from 'next/link' -import { formatDateTime } from '@/lib/utils' -import { TenantDetailDialog } from '@/components/admin/tenant-detail-dialog' -import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog' +import { TenantsTab } from '@/components/admin/tenants-tab' +import { UsersTab } from '@/components/admin/users-tab' +import { ProjectsTab } from '@/components/admin/projects-tab' +import { IconsTab } from '@/components/admin/icons-tab' +import { CategoriesTab } from '@/components/admin/categories-tab' import { SettingsTab } from '@/components/admin/settings-tab' import { SomaTab } from '@/components/admin/soma-tab' import { SuggestionsTab } from '@/components/admin/suggestions-tab' import { DictionaryTab } from '@/components/admin/dictionary-tab' import { SymbolManager } from '@/components/admin/symbol-manager' import { OrgTab } from '@/components/admin/org-tab' - -// --- Types --- -interface IconCategory { - id: string - name: string - description: string | null - sortOrder: number - _count?: { icons: number } -} - -interface IconAsset { - id: string - name: string - fileKey: string - mimeType: string - isSystem: boolean - isActive: boolean - iconType: string - tags: string[] - category: IconCategory -} - -interface UserRecord { - id: string - email: string - name: string - role: 'SERVER_ADMIN' | 'TENANT_ADMIN' | 'OPERATOR' | 'VIEWER' - emailVerified?: boolean - isActive?: boolean - lastLoginAt?: string | null - createdAt: string - updatedAt: string - memberships?: { tenant: { id: string; name: string; slug: string } }[] - _count?: { projects: number } -} - -interface TenantRecord { - id: string - name: string - slug: string - description: string | null - isActive: boolean - createdAt: string - _count?: { memberships: number; projects: number } -} - -const ICON_TYPES = [ - { value: 'STANDARD', label: 'Standard' }, - { value: 'RETTUNG', label: 'Rettung' }, - { value: 'GEFAHRSTOFF', label: 'Gefahrstoff' }, - { value: 'FEUER', label: 'Feuer' }, - { value: 'WASSER', label: 'Wasser' }, - { value: 'FAHRZEUG', label: 'Fahrzeug' }, -] - -const ROLES = [ - { value: 'SERVER_ADMIN', label: 'Server Admin', desc: 'Systemweiter Vollzugriff, Mandanten verwalten' }, - { value: 'TENANT_ADMIN', label: 'Admin', desc: 'Mandant verwalten, Benutzer anlegen' }, - { value: 'OPERATOR', label: 'Bediener', desc: 'Einsätze erstellen und bearbeiten' }, - { value: 'VIEWER', label: 'Betrachter', desc: 'Nur Ansicht, kein Bearbeiten' }, -] +import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog' export default function AdminPage() { - const { toast } = useToast() - const { user, tenant, loading: authLoading, login: authLogin } = useAuth() + const { user, tenant, loading: authLoading } = useAuth() const router = useRouter() - const [categories, setCategories] = useState([]) - const [icons, setIcons] = useState([]) - const [users, setUsers] = useState([]) - const [tenants, setTenants] = useState([]) - const [selectedCategory, setSelectedCategory] = useState('all') - const [isLoading, setIsLoading] = useState(true) - const [activeTab, setActiveTab] = useState(user?.role === 'SERVER_ADMIN' ? 'tenants' : 'org') - - // Category Dialog - const [isCategoryDialogOpen, setIsCategoryDialogOpen] = useState(false) - const [editingCategory, setEditingCategory] = useState(null) - const [categoryName, setCategoryName] = useState('') - const [categoryDescription, setCategoryDescription] = useState('') - - // Icon Upload Dialog - const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false) - const [uploadFiles, setUploadFiles] = useState(null) - const [uploadCategory, setUploadCategory] = useState('') - const [uploadIconType, setUploadIconType] = useState('STANDARD') - const [uploadIconName, setUploadIconName] = useState('') - const [isUploading, setIsUploading] = useState(false) - - // Icon Edit Dialog - const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) - const [editingIcon, setEditingIcon] = useState(null) - const [editIconName, setEditIconName] = useState('') - const [editIconCategory, setEditIconCategory] = useState('') - const [editIconType, setEditIconType] = useState('STANDARD') - const [editIconTags, setEditIconTags] = useState('') - - // User Dialog - const [isUserDialogOpen, setIsUserDialogOpen] = useState(false) - const [editingUser, setEditingUser] = useState(null) - const [userName, setUserName] = useState('') - const [userEmail, setUserEmail] = useState('') - const [userPassword, setUserPassword] = useState('') - const [userPasswordConfirm, setUserPasswordConfirm] = useState('') - const [userRole, setUserRole] = useState('OPERATOR') - - // Tenant Dialog - const [isTenantDialogOpen, setIsTenantDialogOpen] = useState(false) - const [tenantName, setTenantName] = useState('') - const [tenantSlug, setTenantSlug] = useState('') - const [tenantDescription, setTenantDescription] = useState('') - - // Tenant Detail Dialog - const [selectedTenantId, setSelectedTenantId] = useState(null) - const [isTenantDetailOpen, setIsTenantDetailOpen] = useState(false) - - - - - // Admin Projects (SERVER_ADMIN) - const [adminProjects, setAdminProjects] = useState([]) - const [adminProjectsLoading, setAdminProjectsLoading] = useState(false) - const [adminProjectTenantFilter, setAdminProjectTenantFilter] = useState('all') - - // Hose Settings (Tenant Admin) + const [activeTab, setActiveTab] = useState('') const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false) - - - // Redirect to login if not authenticated, or to app if not admin + // Redirect: nur eingeloggte Admins useEffect(() => { if (authLoading) return - if (!user) { - router.push('/login') - } else if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') { - router.push('/app') - } + if (!user) router.push('/login') + else if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') router.push('/app') }, [authLoading, user, router]) + // Standard-Tab setzen, sobald die Rolle bekannt ist useEffect(() => { - if (user?.role) fetchData() - }, [user?.role]) - - - - - - // Fetch admin projects (SERVER_ADMIN) - const fetchAdminProjects = async (tenantFilter?: string) => { - setAdminProjectsLoading(true) - try { - const url = tenantFilter && tenantFilter !== 'all' - ? `/api/admin/projects?tenantId=${tenantFilter}` - : '/api/admin/projects' - const res = await fetch(url) - if (res.ok) { - const data = await res.json() - setAdminProjects(data.projects || []) - } - } catch {} - setAdminProjectsLoading(false) - } - useEffect(() => { - if (user?.role === 'SERVER_ADMIN') fetchAdminProjects() - }, [user?.role]) - - const fetchData = async () => { - setIsLoading(true) - try { - const isServerAdmin = user?.role === 'SERVER_ADMIN' - - // Common fetches for all admins - const fetches: Promise[] = [ - fetch('/api/admin/categories'), - fetch('/api/admin/users'), - ] - // SERVER_ADMIN-only fetches - if (isServerAdmin) { - fetches.push(fetch('/api/admin/icons')) - fetches.push(fetch('/api/admin/tenants')) - } - - const results = await Promise.all(fetches) - const [catRes, userRes] = results - - if (catRes.ok) setCategories((await catRes.json()).categories || []) - if (userRes.ok) setUsers((await userRes.json()).users || []) - - if (isServerAdmin) { - const iconRes = results[2] - const tenantRes = results[3] - if (iconRes.ok) setIcons((await iconRes.json()).icons || []) - if (tenantRes.ok) setTenants((await tenantRes.json()).tenants || []) - } - - } catch (error) { - console.error('Error fetching data:', error) - } finally { - setIsLoading(false) + if (!activeTab && user?.role) { + setActiveTab(user.role === 'SERVER_ADMIN' ? 'tenants' : 'org') } - } + }, [user?.role, activeTab]) - // ===== CATEGORY FUNCTIONS ===== - const handleSaveCategory = async () => { - try { - const url = editingCategory ? `/api/admin/categories/${editingCategory.id}` : '/api/admin/categories' - const method = editingCategory ? 'PATCH' : 'POST' - const res = await fetch(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: categoryName, description: categoryDescription || null }), - }) - if (res.ok) { - toast({ title: editingCategory ? 'Kategorie aktualisiert' : 'Kategorie erstellt' }) - setIsCategoryDialogOpen(false) - setEditingCategory(null) - setCategoryName('') - setCategoryDescription('') - fetchData() - } else { - const err = await res.json() - throw new Error(err.error) - } - } catch (error) { - toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' }) - } - } - - const handleDeleteCategory = async (id: string) => { - if (!confirm('Kategorie wirklich löschen?')) return - try { - const res = await fetch(`/api/admin/categories/${id}`, { method: 'DELETE' }) - if (res.ok) { toast({ title: 'Kategorie gelöscht' }); fetchData() } - } catch { toast({ title: 'Fehler', variant: 'destructive' }) } - } - - // ===== ICON FUNCTIONS ===== - const handleUploadIcons = async () => { - if (!uploadFiles || !uploadCategory) return - setIsUploading(true) - try { - for (const file of Array.from(uploadFiles)) { - const formData = new FormData() - formData.append('file', file) - formData.append('categoryId', uploadCategory) - formData.append('iconType', uploadIconType) - formData.append('name', uploadIconName.trim() || file.name.replace(/\.(png|svg|jpg|jpeg|webp)$/i, '')) - const res = await fetch('/api/admin/icons/upload', { method: 'POST', body: formData }) - if (!res.ok) { const err = await res.json(); throw new Error(err.error || 'Upload fehlgeschlagen') } - } - toast({ title: `${uploadFiles.length} Icon(s) hochgeladen` }) - setIsUploadDialogOpen(false) - setUploadFiles(null) - setUploadCategory('') - setUploadIconName('') - fetchData() - } catch (error) { - toast({ title: 'Upload-Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' }) - } finally { setIsUploading(false) } - } - - const handleEditIcon = (icon: IconAsset) => { - setEditingIcon(icon) - setEditIconName(icon.name) - setEditIconCategory(icon.category.id) - setEditIconType(icon.iconType) - setEditIconTags(icon.tags?.join(', ') || '') - setIsEditDialogOpen(true) - } - - const handleSaveIcon = async () => { - if (!editingIcon) return - try { - const res = await fetch(`/api/admin/icons/${editingIcon.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: editIconName, - categoryId: editIconCategory, - iconType: editIconType, - tags: editIconTags.split(',').map(t => t.trim()).filter(Boolean), - }), - }) - if (res.ok) { toast({ title: 'Icon aktualisiert' }); setIsEditDialogOpen(false); setEditingIcon(null); fetchData() } - else { const err = await res.json(); throw new Error(err.error) } - } catch (error) { - toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' }) - } - } - - const handleToggleIconActive = async (icon: IconAsset) => { - try { - if (user?.role === 'SERVER_ADMIN') { - // SERVER_ADMIN toggles global isActive flag directly - const res = await fetch(`/api/admin/icons/${icon.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ isActive: !icon.isActive }), - }) - if (res.ok) { - toast({ title: icon.isActive ? 'Symbol deaktiviert' : 'Symbol aktiviert' }) - fetchData() - } else { - const err = await res.json() - toast({ title: 'Fehler', description: err.error, variant: 'destructive' }) - } - } else { - // TENANT_ADMIN uses per-tenant visibility toggle - const res = await fetch(`/api/icons/${icon.id}/toggle-visibility`, { method: 'POST' }) - if (res.ok) { - const data = await res.json() - toast({ title: data.message || (data.isHidden ? 'Symbol ausgeblendet' : 'Symbol eingeblendet') }) - fetchData() - } else { - const err = await res.json() - toast({ title: 'Fehler', description: err.error, variant: 'destructive' }) - } - } - } catch { toast({ title: 'Fehler', variant: 'destructive' }) } - } - - const handleDeleteIcon = async (id: string) => { - if (!confirm('Icon wirklich löschen?')) return - try { - const res = await fetch(`/api/admin/icons/${id}`, { method: 'DELETE' }) - if (res.ok) { toast({ title: 'Icon gelöscht' }); fetchData() } - } catch { toast({ title: 'Fehler', variant: 'destructive' }) } - } - - // ===== USER FUNCTIONS ===== - const handleSaveUser = async () => { - try { - // Validate password confirmation - if (userPassword && userPassword !== userPasswordConfirm) { - toast({ title: 'Fehler', description: 'Passwörter stimmen nicht überein.', variant: 'destructive' }) - return - } - const url = editingUser ? `/api/admin/users/${editingUser.id}` : '/api/admin/users' - const method = editingUser ? 'PATCH' : 'POST' - const body: any = { name: userName, email: userEmail, role: userRole } - if (userPassword) body.password = userPassword - - const res = await fetch(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - if (res.ok) { - toast({ title: editingUser ? 'Benutzer aktualisiert' : 'Benutzer erstellt' }) - setIsUserDialogOpen(false) - setEditingUser(null) - setUserName('') - setUserEmail('') - setUserPassword('') - setUserPasswordConfirm('') - setUserRole('OPERATOR') - fetchData() - } else { - const err = await res.json() - throw new Error(err.error) - } - } catch (error) { - toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' }) - } - } - - const handleDeleteUser = async (id: string) => { - if (!confirm('Benutzer wirklich löschen?')) return - try { - const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' }) - if (res.ok) { toast({ title: 'Benutzer gelöscht' }); fetchData() } - } catch { toast({ title: 'Fehler', variant: 'destructive' }) } - } - - const handleToggleUserVerified = async (targetUser: UserRecord) => { - try { - const newVal = !targetUser.emailVerified - const res = await fetch(`/api/admin/users/${targetUser.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ emailVerified: newVal }), - }) - if (res.ok) { - toast({ title: newVal ? 'Benutzer freigeschaltet' : 'Benutzer gesperrt' }) - fetchData() - } else { - const err = await res.json() - toast({ title: 'Fehler', description: err.error, variant: 'destructive' }) - } - } catch { toast({ title: 'Fehler', variant: 'destructive' }) } - } - - const handleToggleUserActive = async (targetUser: UserRecord) => { - const willDeactivate = targetUser.isActive !== false - if (willDeactivate && !confirm(`"${targetUser.name}" sperren? Der Benutzer kann sich dann nicht mehr anmelden.`)) return - try { - const res = await fetch(`/api/admin/users/${targetUser.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ isActive: !willDeactivate }), - }) - if (res.ok) { - toast({ title: willDeactivate ? 'Benutzer gesperrt' : 'Benutzer entsperrt' }) - fetchData() - } else { - const err = await res.json() - toast({ title: 'Fehler', description: err.error, variant: 'destructive' }) - } - } catch { toast({ title: 'Fehler', variant: 'destructive' }) } - } - - const handleResetUserPassword = async (targetUser: UserRecord) => { - try { - const res = await fetch(`/api/admin/users/${targetUser.id}/reset-password`, { method: 'POST' }) - const data = await res.json() - if (res.ok && data.success) { - if (data.emailSent) { - toast({ title: 'Reset-Link gesendet', description: data.message }) - } else if (data.resetUrl) { - // No SMTP → show/copy link - await navigator.clipboard.writeText(data.resetUrl).catch(() => {}) - toast({ - title: 'Reset-Link generiert', - description: 'Link wurde in die Zwischenablage kopiert. Kein SMTP konfiguriert.', - }) - } - } else { - toast({ title: 'Fehler', description: data.error, variant: 'destructive' }) - } - } catch { toast({ title: 'Fehler', variant: 'destructive' }) } - } - - // ===== TENANT FUNCTIONS ===== - const handleSaveTenant = async () => { - try { - const res = await fetch('/api/admin/tenants', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: tenantName, slug: tenantSlug, description: tenantDescription || undefined }), - }) - if (res.ok) { - toast({ title: 'Mandant erstellt' }) - setIsTenantDialogOpen(false) - setTenantName('') - setTenantSlug('') - setTenantDescription('') - fetchData() - } else { - const err = await res.json() - throw new Error(err.error) - } - } catch (error) { - toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' }) - } - } - - const filteredIcons = selectedCategory === 'all' ? icons : icons.filter(i => i.category.id === selectedCategory) - - if (isLoading || authLoading || !user || (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN')) { + if (authLoading || !user || (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN')) { return (
@@ -531,6 +52,8 @@ export default function AdminPage() { ) } + const isServerAdmin = user.role === 'SERVER_ADMIN' + return (
{/* Header */} @@ -549,418 +72,75 @@ export default function AdminPage() {
- {user && ( -
- - {user.name} - {user.role} -
- )} +
+ + {user.name} + {user.role} +
-
- {user?.role === 'SERVER_ADMIN' ? ( + {isServerAdmin ? ( - - - Mandanten - - - - Einsätze - - - - Symbole - - - - Kategorien - - - - Wörterbuch - - - - Benutzer - - - - System - + Mandanten + Einsätze + Symbole + Kategorien + Wörterbuch + Benutzer + System - ) : user?.role === 'TENANT_ADMIN' ? ( + ) : ( - - - Organisation - - - - Benutzer - - - - Symbole - - - - SOMA - - - - Wörterliste - - - - Schläuche - - - - Spenden - + Organisation + Benutzer + Symbole + SOMA + Wörterliste + Schläuche + Spenden - ) : null} - - {/* ===== ORGANISATION TAB (TENANT_ADMIN) ===== */} - {user?.role === 'TENANT_ADMIN' && ( - - - )} - {/* ===== ICONS TAB ===== */} - - {user?.role === 'TENANT_ADMIN' ? ( - - ) : ( - /* --- SERVER_ADMIN: existing icon management --- */ - <> -
-
- - {filteredIcons.length} Symbol(e) -
- -
- - {filteredIcons.length === 0 ? ( -
- -

Keine Symbole vorhanden

-

Laden Sie eigene Symbole hoch (PNG, SVG, JPEG)

- -
- ) : ( -
- {filteredIcons.map(icon => ( -
-
- {icon.name} -
-

{icon.name}

-

{icon.category.name}

- {icon.isSystem &&

System

} -
- - - -
-
- ))} -
- )} - - )} -
- - {/* ===== CATEGORIES TAB ===== */} - -
-

{categories.length} Kategorie(n)

- -
-
- {categories.map(cat => ( -
-
-
-

{cat.name}

- {cat.description &&

{cat.description}

} -

{cat._count?.icons || 0} Symbol(e) · Reihenfolge: {cat.sortOrder}

-
-
- - -
-
-
- ))} -
-
- - {/* ===== USERS TAB ===== */} - -
-

- {users.length} Benutzer - {users.filter(u => u.isActive === false).length > 0 && - ` · ${users.filter(u => u.isActive === false).length} gesperrt`} -

- -
-
- - - - - - - - - - - - - - {users.map(u => { - const inactive = u.isActive === false - const tenantNames = (u.memberships || []).map(m => m.tenant?.name).filter(Boolean) - return ( - - - - - - - - - - )})} - -
NameE-MailRolleMandantLetzte AnmeldungStatusAktionen
-
-
- {u.name.charAt(0).toUpperCase()} -
- {u.name} -
-
{u.email} - - {ROLES.find(r => r.value === u.role)?.label || u.role} - - - {tenantNames.length > 0 ? tenantNames.join(', ') : } - - {u.lastLoginAt ? ( - {formatDateTime(u.lastLoginAt)} - ) : ( - Nie eingeloggt - )} - - {inactive ? ( - Gesperrt - ) : u.emailVerified === false ? ( - Unverifiziert - ) : ( - Aktiv - )} - -
- {u.emailVerified === false && ( - - )} - - - - -
-
-
-
- - {/* ===== TENANTS TAB (SERVER_ADMIN only) ===== */} - {user?.role === 'SERVER_ADMIN' && ( - -
-

{tenants.length} Mandant(en)

- {user?.role === 'SERVER_ADMIN' && ( - - )} -
- {tenants.length === 0 ? ( -
- -

Keine Mandanten

-

Erstellen Sie einen Mandanten (Organisation/Feuerwehr)

-
- ) : ( -
- {tenants.map(t => ( -
{ setSelectedTenantId(t.id); setIsTenantDetailOpen(true) }}> -
-
-

{t.name}

-

{t.slug}

- {t.description &&

{t.description}

} -
- {t._count?.memberships || 0} Benutzer - {t._count?.projects || 0} Projekte -
-
- - {t.isActive ? 'Aktiv' : 'Inaktiv'} - -
-
- ))} -
- )} -
+ {/* ===== SERVER_ADMIN ===== */} + {isServerAdmin && ( + + )} + {isServerAdmin && ( + + )} + {isServerAdmin && ( + + )} + {isServerAdmin && ( + + )} + {isServerAdmin && ( + + + )} - {/* ===== PROJECTS TAB (SERVER_ADMIN — Einsätze verwalten) ===== */} - {user?.role === 'SERVER_ADMIN' && ( - -
-

- {adminProjects.length} Einsatz/Einsätze -

-
- Feuerwehr: - -
-
+ {/* ===== Geteilt: Benutzer ===== */} + - {adminProjectsLoading ? ( -
- -
- ) : adminProjects.length === 0 ? ( -

Keine Einsätze gefunden.

- ) : ( -
- - - - - - - - - - - - - - - {adminProjects.map((p: any) => ( - - - - - - - - - - - ))} - -
Einsatz-NrTitelOrtErstellt vonFeuerwehrElementeGeändertAktion
{p.einsatzNr || '—'}{p.title}{p.location || '—'} - {p.owner?.name || p.owner?.email || '—'} - - {p.tenant?.name || '—'} - {p._count?.features || 0}{new Date(p.updatedAt).toLocaleString('de-CH')} - -
-
- )} + {/* ===== Symbole: Super-Admin = globale Verwaltung, sonst SymbolManager ===== */} + + {isServerAdmin ? : } + + {/* ===== TENANT_ADMIN ===== */} + {!isServerAdmin && ( + + )} + {!isServerAdmin && ( + )} - {/* ===== HOSE TYPES TAB (Schlauchtypen) ===== */} + +

@@ -978,430 +158,67 @@ export default function AdminPage() { - {/* ===== SUGGESTIONS TAB (Word Library) ===== */} - - - - - {/* ===== DICTIONARY TAB (SERVER_ADMIN — Global Word Library) ===== */} - {user?.role === 'SERVER_ADMIN' && ( - - - - )} - - {/* ===== SETTINGS TAB (SERVER_ADMIN only) ===== */} - {user?.role === 'SERVER_ADMIN' && ( - - - - )} - - {/* ===== SPENDEN TAB (TENANT_ADMIN) ===== */} - {user?.role === 'TENANT_ADMIN' && tenant && ( - -
-
-
- + {!isServerAdmin && tenant && ( + +
+
+
+ +
+
+

Lageplan unterstützen

+

+ Lageplan ist ein kostenloses Projekt — entwickelt von einem aktiven Feuerwehrmann + in seiner Freizeit. Ohne Firma, ohne Investoren. Deine Spende hilft, den Betrieb und die + Weiterentwicklung zu finanzieren. +

+
-
-

Lageplan unterstützen

-

- Lageplan ist ein kostenloses Projekt — entwickelt von einem aktiven Feuerwehrmann - in seiner Freizeit. Ohne Firma, ohne Investoren. Deine Spende hilft, den Betrieb und die - Weiterentwicklung zu finanzieren. -

+ +
+

Wohin fliesst deine Spende?

+
    +
  • Server-Hosting in der Schweiz (monatliche Kosten)
  • +
  • Entwicklung neuer Features und Bugfixes
  • +
  • Domain, SSL-Zertifikate und Infrastruktur
  • +
+
+ +
- -
-

Wohin fliesst deine Spende?

-
    -
  • Server-Hosting in der Schweiz (monatliche Kosten)
  • -
  • Entwicklung neuer Features und Bugfixes
  • -
  • Domain, SSL-Zertifikate und Infrastruktur
  • -
-
- - -
-
-

Dein Mandant

-
-
-

Organisation

-

{tenant.name}

-
-
-

Benutzer

-

Unbegrenzt

-
-
-

Projekte

-

Unbegrenzt

+
+

Dein Mandant

+
+
+

Organisation

+

{tenant.name}

+
+
+

Benutzer

+

Unbegrenzt

+
+
+

Projekte

+

Unbegrenzt

+
-
- + )} - - {/* ===== SOMA TAB (TENANT_ADMIN) ===== */} - {user?.role === 'TENANT_ADMIN' && ( - - - - )} - - {/* Upgrades tab removed — plan management simplified */}
- - {/* ===== DIALOGS ===== */} - - {/* Category Dialog */} - - - - {editingCategory ? 'Kategorie bearbeiten' : 'Neue Kategorie'} - -
-
setCategoryName(e.target.value)} placeholder="z.B. Fahrzeuge" />
-
setCategoryDescription(e.target.value)} placeholder="Kurze Beschreibung" />
-
- - - - -
-
- - {/* Upload Dialog */} - - - Icons hochladen -
-
- - setUploadFiles(e.target.files)} /> -
-
- - setUploadIconName(e.target.value)} placeholder="z.B. Feuerwehrauto TLF" /> -
-
- - -
-
- - -
-
- - - - -
-
- - {/* Edit Icon Dialog */} - - - Icon bearbeiten -
- {editingIcon && ( -
{editingIcon.name}
- )} -
setEditIconName(e.target.value)} />
-
- - -
-
- - -
-
setEditIconTags(e.target.value)} placeholder="z.B. feuerwehr, fahrzeug" />
-
- - - - -
-
- - {/* User Dialog */} - - - {editingUser ? 'Benutzer bearbeiten' : 'Neuer Benutzer'} -
-
setUserName(e.target.value)} placeholder="Vor- und Nachname" />
-
setUserEmail(e.target.value)} placeholder="name@example.com" />
-
- - setUserPassword(e.target.value)} placeholder={editingUser ? '••••••••' : 'Min. 6 Zeichen'} /> -
-
- - setUserPasswordConfirm(e.target.value)} placeholder="Passwort wiederholen" /> - {userPassword && userPasswordConfirm && userPassword !== userPasswordConfirm && ( -

Passwörter stimmen nicht überein

- )} -
-
- - -
-
- - - - -
-
- - {/* Tenant Dialog */} - - - Neuer Mandant -
-
{ setTenantName(e.target.value); setTenantSlug(e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')) }} placeholder="z.B. Feuerwehr Wohlen" />
-
setTenantSlug(e.target.value)} placeholder="z.B. feuerwehr-wohlen" className="font-mono" />
-
setTenantDescription(e.target.value)} placeholder="Kurze Beschreibung" />
-
- - - - -
-
- - {/* Tenant Detail Dialog */} - -
- ) -} - -// ─── Upgrade Requests Tab (SERVER_ADMIN) ────────────────────── - -function UpgradeRequestsTab({ toast }: { toast: any }) { - const [requests, setRequests] = useState([]) - const [loading, setLoading] = useState(true) - const [processingId, setProcessingId] = useState(null) - const [adminNote, setAdminNote] = useState('') - const [showNoteFor, setShowNoteFor] = useState(null) - - useEffect(() => { - fetchRequests() - }, []) - - const fetchRequests = async () => { - try { - const res = await fetch('/api/upgrade-requests') - if (res.ok) { - const data = await res.json() - setRequests(data.requests || []) - } - } catch (e) { - console.error('Failed to fetch upgrade requests:', e) - } finally { - setLoading(false) - } - } - - const handleProcess = async (id: string, action: 'approve' | 'reject') => { - setProcessingId(id) - try { - const res = await fetch(`/api/upgrade-requests/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action, adminNote: adminNote.trim() || undefined }), - }) - const data = await res.json() - if (!res.ok) throw new Error(data.error || 'Fehler') - toast({ - title: action === 'approve' ? 'Upgrade bestätigt' : 'Anfrage abgelehnt', - description: action === 'approve' - ? `Plan wurde aktiviert. Der Mandant wurde per E-Mail informiert.` - : `Die Anfrage wurde abgelehnt. Der Mandant wurde informiert.`, - }) - setAdminNote('') - setShowNoteFor(null) - fetchRequests() - } catch (e: any) { - toast({ title: 'Fehler', description: e.message, variant: 'destructive' }) - } finally { - setProcessingId(null) - } - } - - const planLabels: Record = { FREE: 'Free', PRO: 'Pro' } - const statusLabels: Record = { - PENDING: { label: 'Offen', color: 'bg-yellow-100 text-yellow-800' }, - APPROVED: { label: 'Bestätigt', color: 'bg-green-100 text-green-800' }, - REJECTED: { label: 'Abgelehnt', color: 'bg-red-100 text-red-800' }, - } - - const pendingRequests = requests.filter(r => r.status === 'PENDING') - const processedRequests = requests.filter(r => r.status !== 'PENDING') - - if (loading) { - return
- } - - return ( -
- {/* Pending requests */} -
-

- Offene Anfragen - {pendingRequests.length > 0 && ( - {pendingRequests.length} - )} -

- {pendingRequests.length === 0 ? ( -
- Keine offenen Upgrade-Anfragen -
- ) : ( -
- {pendingRequests.map((r: any) => ( -
-
-
-

{r.tenant?.name}

-

- {r.requestedBy?.name} ({r.requestedBy?.email}) — {new Date(r.createdAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })} -

-
-
-

- {planLabels[r.currentPlan]} → {planLabels[r.requestedPlan]} -

-
-
- {r.message && ( -
-

Nachricht:

- {r.message} -
- )} - {showNoteFor === r.id && ( -
-