Files
Lageplan/src/app/admin/page.tsx

1365 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
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,
} from 'lucide-react'
import Link from 'next/link'
import { TenantDetailDialog } from '@/components/admin/tenant-detail-dialog'
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
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
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' },
]
export default function AdminPage() {
const { toast } = useToast()
const { user, tenant, loading: authLoading, login: authLogin } = useAuth()
const router = useRouter()
const [categories, setCategories] = useState<IconCategory[]>([])
const [icons, setIcons] = useState<IconAsset[]>([])
const [users, setUsers] = useState<UserRecord[]>([])
const [tenants, setTenants] = useState<TenantRecord[]>([])
const [selectedCategory, setSelectedCategory] = useState<string>('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<IconCategory | null>(null)
const [categoryName, setCategoryName] = useState('')
const [categoryDescription, setCategoryDescription] = useState('')
// Icon Upload Dialog
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false)
const [uploadFiles, setUploadFiles] = useState<FileList | null>(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<IconAsset | null>(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<UserRecord | null>(null)
const [userName, setUserName] = useState('')
const [userEmail, setUserEmail] = useState('')
const [userPassword, setUserPassword] = useState('')
const [userPasswordConfirm, setUserPasswordConfirm] = useState('')
const [userRole, setUserRole] = useState<string>('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<string | null>(null)
const [isTenantDetailOpen, setIsTenantDetailOpen] = useState(false)
// Admin Projects (SERVER_ADMIN)
const [adminProjects, setAdminProjects] = useState<any[]>([])
const [adminProjectsLoading, setAdminProjectsLoading] = useState(false)
const [adminProjectTenantFilter, setAdminProjectTenantFilter] = useState<string>('all')
// Hose Settings (Tenant Admin)
const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false)
// Redirect to login if not authenticated, or to app if not admin
useEffect(() => {
if (authLoading) return
if (!user) {
router.push('/login')
} else if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') {
router.push('/app')
}
}, [authLoading, user, router])
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<Response>[] = [
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)
}
}
// ===== 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 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')) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
)
}
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="h-14 border-b border-border bg-card flex items-center justify-between px-6">
<div className="flex items-center gap-4">
<Link href="/app">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück zur App
</Button>
</Link>
<div className="h-6 w-px bg-border" />
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-primary" />
<h1 className="font-semibold text-lg">Administration</h1>
</div>
</div>
<div className="flex items-center gap-3">
{user && (
<div className="flex items-center gap-2 text-sm">
<Shield className="w-4 h-4 text-primary" />
<span className="font-medium">{user.name}</span>
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">{user.role}</span>
</div>
)}
</div>
</header>
<div className="container mx-auto py-6 px-4 max-w-7xl">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
{user?.role === 'SERVER_ADMIN' ? (
<TabsList className="grid w-full grid-cols-7 max-w-4xl">
<TabsTrigger value="tenants" className="gap-2">
<Shield className="w-4 h-4" />
Mandanten
</TabsTrigger>
<TabsTrigger value="projects" className="gap-2">
<Map className="w-4 h-4" />
Einsätze
</TabsTrigger>
<TabsTrigger value="icons" className="gap-2">
<Image className="w-4 h-4" />
Symbole
</TabsTrigger>
<TabsTrigger value="categories" className="gap-2">
<Layers className="w-4 h-4" />
Kategorien
</TabsTrigger>
<TabsTrigger value="dictionary" className="gap-2">
<BookOpen className="w-4 h-4" />
Wörterbuch
</TabsTrigger>
<TabsTrigger value="users" className="gap-2">
<Users className="w-4 h-4" />
Server Admins
</TabsTrigger>
<TabsTrigger value="settings" className="gap-2">
<Settings className="w-4 h-4" />
System
</TabsTrigger>
</TabsList>
) : user?.role === 'TENANT_ADMIN' ? (
<TabsList className="grid w-full grid-cols-7 max-w-4xl">
<TabsTrigger value="org" className="gap-2">
<Building2 className="w-4 h-4" />
Organisation
</TabsTrigger>
<TabsTrigger value="users" className="gap-2">
<Users className="w-4 h-4" />
Benutzer
</TabsTrigger>
<TabsTrigger value="icons" className="gap-2">
<Image className="w-4 h-4" />
Symbole
</TabsTrigger>
<TabsTrigger value="soma" className="gap-2">
<AlertTriangle className="w-4 h-4" />
SOMA
</TabsTrigger>
<TabsTrigger value="suggestions" className="gap-2">
<ClipboardList className="w-4 h-4" />
Wörterliste
</TabsTrigger>
<TabsTrigger value="hose-types" className="gap-2">
<Settings className="w-4 h-4" />
Schläuche
</TabsTrigger>
<TabsTrigger value="donate" className="gap-2">
<Heart className="w-4 h-4" />
Spenden
</TabsTrigger>
</TabsList>
) : null}
{/* ===== ORGANISATION TAB (TENANT_ADMIN) ===== */}
{user?.role === 'TENANT_ADMIN' && (
<TabsContent value="org" className="space-y-4">
<OrgTab tenantId={tenant?.id} />
</TabsContent>
)}
{/* ===== ICONS TAB ===== */}
<TabsContent value="icons" className="space-y-4">
{user?.role === 'TENANT_ADMIN' ? (
<SymbolManager />
) : (
/* --- SERVER_ADMIN: existing icon management --- */
<>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Kategorie filtern" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle Kategorien</SelectItem>
{categories.map(cat => (
<SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">{filteredIcons.length} Symbol(e)</span>
</div>
<Button onClick={() => setIsUploadDialogOpen(true)}>
<Upload className="w-4 h-4 mr-2" />
Icons hochladen
</Button>
</div>
{filteredIcons.length === 0 ? (
<div className="border-2 border-dashed rounded-lg p-12 text-center">
<Image className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
<h3 className="font-medium text-lg mb-2">Keine Symbole vorhanden</h3>
<p className="text-muted-foreground mb-4">Laden Sie eigene Symbole hoch (PNG, SVG, JPEG)</p>
<Button onClick={() => setIsUploadDialogOpen(true)}>
<Upload className="w-4 h-4 mr-2" />
Jetzt hochladen
</Button>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
{filteredIcons.map(icon => (
<div key={icon.id} className={`relative group border rounded-lg p-3 transition-all hover:shadow-md ${!icon.isActive ? 'opacity-50' : ''}`}>
<div className="aspect-square flex items-center justify-center mb-2 bg-muted rounded">
<img src={`/api/icons/${icon.id}/image`} alt={icon.name} className="w-12 h-12 object-contain" />
</div>
<p className="text-xs text-center truncate font-medium" title={icon.name}>{icon.name}</p>
<p className="text-[10px] text-center text-muted-foreground truncate">{icon.category.name}</p>
{icon.isSystem && <p className="text-[10px] text-center text-blue-500">System</p>}
<div className="absolute inset-0 bg-background/80 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 rounded-lg">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleEditIcon(icon)}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleToggleIconActive(icon)}>
{icon.isActive ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => handleDeleteIcon(icon.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</>
)}
</TabsContent>
{/* ===== CATEGORIES TAB ===== */}
<TabsContent value="categories" className="space-y-4">
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">{categories.length} Kategorie(n)</p>
<Button onClick={() => { setEditingCategory(null); setCategoryName(''); setCategoryDescription(''); setIsCategoryDialogOpen(true) }}>
<FolderPlus className="w-4 h-4 mr-2" />
Neue Kategorie
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categories.map(cat => (
<div key={cat.id} className="border rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div>
<h3 className="font-medium">{cat.name}</h3>
{cat.description && <p className="text-sm text-muted-foreground mt-1">{cat.description}</p>}
<p className="text-xs text-muted-foreground mt-2">{cat._count?.icons || 0} Symbol(e) · Reihenfolge: {cat.sortOrder}</p>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => {
setEditingCategory(cat); setCategoryName(cat.name); setCategoryDescription(cat.description || ''); setIsCategoryDialogOpen(true)
}}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => handleDeleteCategory(cat.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
))}
</div>
</TabsContent>
{/* ===== USERS TAB ===== */}
<TabsContent value="users" className="space-y-4">
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">
{user?.role === 'SERVER_ADMIN'
? `${users.filter(u => u.role === 'SERVER_ADMIN').length} Server-Admin(s)`
: `${users.length} Benutzer`
}
</p>
<Button onClick={() => {
setEditingUser(null); setUserName(''); setUserEmail(''); setUserPassword(''); setUserPasswordConfirm(''); setUserRole('OPERATOR'); setIsUserDialogOpen(true)
}}>
<UserPlus className="w-4 h-4 mr-2" />
Neuer Benutzer
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Name</th>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">E-Mail</th>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Rolle</th>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Projekte</th>
<th className="text-right text-xs font-medium text-muted-foreground px-4 py-3">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y">
{(user?.role === 'SERVER_ADMIN' ? users.filter(u => u.role === 'SERVER_ADMIN') : users).map(u => (
<tr key={u.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white ${
u.role === 'SERVER_ADMIN' ? 'bg-red-500' : u.role === 'TENANT_ADMIN' ? 'bg-orange-500' : u.role === 'OPERATOR' ? 'bg-blue-500' : 'bg-gray-400'
}`}>
{u.name.charAt(0).toUpperCase()}
</div>
<span className="font-medium text-sm">{u.name}</span>
</div>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
{u.email}
{u.emailVerified === false && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-medium">unverifiziert</span>
)}
</div>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
u.role === 'SERVER_ADMIN' ? 'bg-red-100 text-red-700' :
u.role === 'TENANT_ADMIN' ? 'bg-orange-100 text-orange-700' :
u.role === 'OPERATOR' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-700'
}`}>
{ROLES.find(r => r.value === u.role)?.label || u.role}
</span>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{u._count?.projects || 0}</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1">
{u.emailVerified === false && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-amber-600" title="E-Mail manuell verifizieren"
onClick={() => handleToggleUserVerified(u)}>
<ShieldCheck className="w-4 h-4" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-8 w-8" title="Passwort zurücksetzen"
onClick={() => handleResetUserPassword(u)}>
<KeyRound className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => {
setEditingUser(u); setUserName(u.name); setUserEmail(u.email); setUserPassword(''); setUserPasswordConfirm(''); setUserRole(u.role); setIsUserDialogOpen(true)
}}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => handleDeleteUser(u.id)}
disabled={u.email === 'admin@lageplan.local'}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</TabsContent>
{/* ===== TENANTS TAB (SERVER_ADMIN only) ===== */}
{user?.role === 'SERVER_ADMIN' && (
<TabsContent value="tenants" className="space-y-4">
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">{tenants.length} Mandant(en)</p>
{user?.role === 'SERVER_ADMIN' && (
<Button onClick={() => { setTenantName(''); setTenantSlug(''); setTenantDescription(''); setIsTenantDialogOpen(true) }}>
<Plus className="w-4 h-4 mr-2" />
Neuer Mandant
</Button>
)}
</div>
{tenants.length === 0 ? (
<div className="border-2 border-dashed rounded-lg p-12 text-center">
<Shield className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
<h3 className="font-medium text-lg mb-2">Keine Mandanten</h3>
<p className="text-muted-foreground mb-4">Erstellen Sie einen Mandanten (Organisation/Feuerwehr)</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{tenants.map(t => (
<div key={t.id} className="border rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer" onClick={() => { setSelectedTenantId(t.id); setIsTenantDetailOpen(true) }}>
<div className="flex items-start justify-between">
<div>
<h3 className="font-medium">{t.name}</h3>
<p className="text-xs text-muted-foreground font-mono mt-0.5">{t.slug}</p>
{t.description && <p className="text-sm text-muted-foreground mt-1">{t.description}</p>}
<div className="flex gap-3 mt-2 text-xs text-muted-foreground">
<span>{t._count?.memberships || 0} Benutzer</span>
<span>{t._count?.projects || 0} Projekte</span>
</div>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full ${t.isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{t.isActive ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
</div>
))}
</div>
)}
</TabsContent>
)}
{/* ===== PROJECTS TAB (SERVER_ADMIN — Einsätze verwalten) ===== */}
{user?.role === 'SERVER_ADMIN' && (
<TabsContent value="projects" className="space-y-4">
<div className="flex items-center justify-between gap-4 flex-wrap">
<p className="text-sm text-muted-foreground">
{adminProjects.length} Einsatz/Einsätze
</p>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Feuerwehr:</span>
<Select value={adminProjectTenantFilter} onValueChange={(val) => { setAdminProjectTenantFilter(val); fetchAdminProjects(val) }}>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="Alle Mandanten" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle Mandanten</SelectItem>
{tenants.map(t => (
<SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{adminProjectsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : adminProjects.length === 0 ? (
<p className="text-center text-muted-foreground py-8">Keine Einsätze gefunden.</p>
) : (
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-2.5 font-medium">Einsatz-Nr</th>
<th className="text-left px-4 py-2.5 font-medium">Titel</th>
<th className="text-left px-4 py-2.5 font-medium">Ort</th>
<th className="text-left px-4 py-2.5 font-medium">Erstellt von</th>
<th className="text-left px-4 py-2.5 font-medium">Feuerwehr</th>
<th className="text-left px-4 py-2.5 font-medium">Elemente</th>
<th className="text-left px-4 py-2.5 font-medium">Geändert</th>
<th className="text-left px-4 py-2.5 font-medium">Aktion</th>
</tr>
</thead>
<tbody className="divide-y">
{adminProjects.map((p: any) => (
<tr key={p.id} className="hover:bg-muted/30">
<td className="px-4 py-2.5 font-mono text-xs">{p.einsatzNr || '—'}</td>
<td className="px-4 py-2.5 font-semibold">{p.title}</td>
<td className="px-4 py-2.5 text-muted-foreground truncate max-w-[200px]">{p.location || '—'}</td>
<td className="px-4 py-2.5">
<span className="text-xs">{p.owner?.name || p.owner?.email || '—'}</span>
</td>
<td className="px-4 py-2.5">
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">{p.tenant?.name || '—'}</span>
</td>
<td className="px-4 py-2.5 text-center">{p._count?.features || 0}</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">{new Date(p.updatedAt).toLocaleString('de-CH')}</td>
<td className="px-4 py-2.5">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => window.open(`/app?project=${p.id}`, '_blank')}>
<Eye className="w-3 h-3 mr-1" />
Öffnen
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
)}
{/* ===== HOSE TYPES TAB (Schlauchtypen) ===== */}
<TabsContent value="hose-types" className="space-y-4">
<div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-2 flex items-center gap-2">
<Settings className="w-5 h-5" />
Schlauchtypen verwalten
</h3>
<p className="text-sm text-muted-foreground mb-4">
Konfiguriere die Schlauchtypen für die Druckberechnung im Messwerkzeug. Der Standard-Schlauch wird automatisch für neue Berechnungen verwendet.
</p>
<Button variant="outline" onClick={() => setIsHoseSettingsOpen(true)}>
<Settings className="w-4 h-4 mr-2" />
Schlauchtypen bearbeiten
</Button>
</div>
<HoseSettingsDialog open={isHoseSettingsOpen} onOpenChange={setIsHoseSettingsOpen} />
</TabsContent>
{/* ===== SUGGESTIONS TAB (Word Library) ===== */}
<TabsContent value="suggestions" className="space-y-4">
<SuggestionsTab tenantId={tenant?.id} />
</TabsContent>
{/* ===== DICTIONARY TAB (SERVER_ADMIN — Global Word Library) ===== */}
{user?.role === 'SERVER_ADMIN' && (
<TabsContent value="dictionary" className="space-y-4">
<DictionaryTab />
</TabsContent>
)}
{/* ===== SETTINGS TAB (SERVER_ADMIN only) ===== */}
{user?.role === 'SERVER_ADMIN' && (
<TabsContent value="settings" className="space-y-6">
<SettingsTab
usersCount={users.length}
tenantsCount={tenants.length}
iconsCount={icons.length}
onNavigateTab={setActiveTab}
/>
</TabsContent>
)}
{/* ===== SPENDEN TAB (TENANT_ADMIN) ===== */}
{user?.role === 'TENANT_ADMIN' && tenant && (
<TabsContent value="donate" className="space-y-6">
<div className="border rounded-lg p-6">
<div className="flex items-start gap-4 mb-6">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-red-500 to-red-700 flex items-center justify-center shrink-0">
<Heart className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="font-semibold text-lg">Lageplan unterstützen</h3>
<p className="text-sm text-muted-foreground mt-1">
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.
</p>
</div>
</div>
<div className="bg-muted/50 rounded-lg p-4 mb-6">
<p className="text-sm font-medium mb-2">Wohin fliesst deine Spende?</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li className="flex items-center gap-2"><Shield className="w-3.5 h-3.5 text-primary shrink-0" /> Server-Hosting in der Schweiz (monatliche Kosten)</li>
<li className="flex items-center gap-2"><Settings className="w-3.5 h-3.5 text-primary shrink-0" /> Entwicklung neuer Features und Bugfixes</li>
<li className="flex items-center gap-2"><ShieldCheck className="w-3.5 h-3.5 text-primary shrink-0" /> Domain, SSL-Zertifikate und Infrastruktur</li>
</ul>
</div>
<div className="flex flex-wrap gap-3">
<Button asChild>
<a href="/spenden" target="_blank" rel="noopener noreferrer">
<Heart className="w-4 h-4 mr-2" />
Jetzt spenden
</a>
</Button>
<Button variant="outline" asChild>
<a href="/" target="_blank" rel="noopener noreferrer">
Mehr über Lageplan erfahren
</a>
</Button>
</div>
</div>
<div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-4">Dein Mandant</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground">Organisation</p>
<p className="font-semibold">{tenant.name}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Benutzer</p>
<p className="font-semibold">Unbegrenzt</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Projekte</p>
<p className="font-semibold">Unbegrenzt</p>
</div>
</div>
</div>
</TabsContent>
)}
{/* ===== SOMA TAB (TENANT_ADMIN) ===== */}
{user?.role === 'TENANT_ADMIN' && (
<TabsContent value="soma" className="space-y-6">
<SomaTab />
</TabsContent>
)}
{/* Upgrades tab removed — plan management simplified */}
</Tabs>
</div>
{/* ===== DIALOGS ===== */}
{/* Category Dialog */}
<Dialog open={isCategoryDialogOpen} onOpenChange={setIsCategoryDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingCategory ? 'Kategorie bearbeiten' : 'Neue Kategorie'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div><Label>Name</Label><Input value={categoryName} onChange={e => setCategoryName(e.target.value)} placeholder="z.B. Fahrzeuge" /></div>
<div><Label>Beschreibung (optional)</Label><Input value={categoryDescription} onChange={e => setCategoryDescription(e.target.value)} placeholder="Kurze Beschreibung" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCategoryDialogOpen(false)}>Abbrechen</Button>
<Button onClick={handleSaveCategory} disabled={!categoryName.trim()}>Speichern</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Upload Dialog */}
<Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Icons hochladen</DialogTitle></DialogHeader>
<div className="space-y-4">
<div>
<Label>Dateien (PNG, SVG, JPEG)</Label>
<Input type="file" accept=".png,.svg,.jpg,.jpeg,.webp" multiple onChange={e => setUploadFiles(e.target.files)} />
</div>
<div>
<Label>Symbolname (optional, sonst Dateiname)</Label>
<Input value={uploadIconName} onChange={e => setUploadIconName(e.target.value)} placeholder="z.B. Feuerwehrauto TLF" />
</div>
<div>
<Label>Kategorie</Label>
<Select value={uploadCategory} onValueChange={setUploadCategory}>
<SelectTrigger><SelectValue placeholder="Kategorie wählen" /></SelectTrigger>
<SelectContent>{categories.map(cat => <SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label>Symbol-Typ</Label>
<Select value={uploadIconType} onValueChange={setUploadIconType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{ICON_TYPES.map(t => <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsUploadDialogOpen(false)}>Abbrechen</Button>
<Button onClick={handleUploadIcons} disabled={!uploadFiles || !uploadCategory || isUploading}>
{isUploading ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Hochladen...</> : 'Hochladen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Icon Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Icon bearbeiten</DialogTitle></DialogHeader>
<div className="space-y-4">
{editingIcon && (
<div className="flex justify-center"><img src={`/api/icons/${editingIcon.id}/image`} alt={editingIcon.name} className="w-16 h-16 object-contain bg-muted rounded-lg p-2" /></div>
)}
<div><Label>Name</Label><Input value={editIconName} onChange={e => setEditIconName(e.target.value)} /></div>
<div>
<Label>Kategorie</Label>
<Select value={editIconCategory} onValueChange={setEditIconCategory}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{categories.map(cat => <SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label>Symbol-Typ</Label>
<Select value={editIconType} onValueChange={setEditIconType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{ICON_TYPES.map(t => <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>)}</SelectContent>
</Select>
</div>
<div><Label>Tags (kommagetrennt)</Label><Input value={editIconTags} onChange={e => setEditIconTags(e.target.value)} placeholder="z.B. feuerwehr, fahrzeug" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>Abbrechen</Button>
<Button onClick={handleSaveIcon} disabled={!editIconName.trim()}>Speichern</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* User Dialog */}
<Dialog open={isUserDialogOpen} onOpenChange={setIsUserDialogOpen}>
<DialogContent>
<DialogHeader><DialogTitle>{editingUser ? 'Benutzer bearbeiten' : 'Neuer Benutzer'}</DialogTitle></DialogHeader>
<div className="space-y-4">
<div><Label>Name</Label><Input value={userName} onChange={e => setUserName(e.target.value)} placeholder="Vor- und Nachname" /></div>
<div><Label>E-Mail</Label><Input type="email" value={userEmail} onChange={e => setUserEmail(e.target.value)} placeholder="name@example.com" /></div>
<div>
<Label>{editingUser ? 'Neues Passwort (leer = unverändert)' : 'Passwort'}</Label>
<Input type="password" value={userPassword} onChange={e => setUserPassword(e.target.value)} placeholder={editingUser ? '••••••••' : 'Min. 6 Zeichen'} />
</div>
<div>
<Label>Passwort bestätigen</Label>
<Input type="password" value={userPasswordConfirm} onChange={e => setUserPasswordConfirm(e.target.value)} placeholder="Passwort wiederholen" />
{userPassword && userPasswordConfirm && userPassword !== userPasswordConfirm && (
<p className="text-xs text-destructive mt-1">Passwörter stimmen nicht überein</p>
)}
</div>
<div>
<Label>Rolle</Label>
<Select value={userRole} onValueChange={setUserRole}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ROLES.filter(r => user?.role === 'SERVER_ADMIN' || r.value !== 'SERVER_ADMIN').map(r => (
<SelectItem key={r.value} value={r.value}>
<div>
<span className="font-medium">{r.label}</span>
<span className="text-xs text-muted-foreground ml-2"> {r.desc}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsUserDialogOpen(false)}>Abbrechen</Button>
<Button onClick={handleSaveUser} disabled={!userName.trim() || !userEmail.trim() || (!editingUser && !userPassword) || (!!userPassword && userPassword !== userPasswordConfirm)}>
Speichern
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Tenant Dialog */}
<Dialog open={isTenantDialogOpen} onOpenChange={setIsTenantDialogOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Neuer Mandant</DialogTitle></DialogHeader>
<div className="space-y-4">
<div><Label>Name</Label><Input value={tenantName} onChange={e => { setTenantName(e.target.value); setTenantSlug(e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')) }} placeholder="z.B. Feuerwehr Wohlen" /></div>
<div><Label>Slug (URL-freundlich)</Label><Input value={tenantSlug} onChange={e => setTenantSlug(e.target.value)} placeholder="z.B. feuerwehr-wohlen" className="font-mono" /></div>
<div><Label>Beschreibung (optional)</Label><Input value={tenantDescription} onChange={e => setTenantDescription(e.target.value)} placeholder="Kurze Beschreibung" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsTenantDialogOpen(false)}>Abbrechen</Button>
<Button onClick={handleSaveTenant} disabled={!tenantName.trim() || !tenantSlug.trim()}>Erstellen</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Tenant Detail Dialog */}
<TenantDetailDialog
tenantId={selectedTenantId}
open={isTenantDetailOpen}
onOpenChange={setIsTenantDetailOpen}
onUpdated={fetchData}
/>
</div>
)
}
// ─── Upgrade Requests Tab (SERVER_ADMIN) ──────────────────────
function UpgradeRequestsTab({ toast }: { toast: any }) {
const [requests, setRequests] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [processingId, setProcessingId] = useState<string | null>(null)
const [adminNote, setAdminNote] = useState('')
const [showNoteFor, setShowNoteFor] = useState<string | null>(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<string, string> = { FREE: 'Free', PRO: 'Pro' }
const statusLabels: Record<string, { label: string; color: string }> = {
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 <div className="flex items-center justify-center py-12"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
}
return (
<div className="space-y-6">
{/* Pending requests */}
<div>
<h3 className="font-semibold text-lg mb-3">
Offene Anfragen
{pendingRequests.length > 0 && (
<span className="ml-2 text-xs bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full">{pendingRequests.length}</span>
)}
</h3>
{pendingRequests.length === 0 ? (
<div className="border rounded-lg p-8 text-center text-muted-foreground text-sm">
Keine offenen Upgrade-Anfragen
</div>
) : (
<div className="space-y-3">
{pendingRequests.map((r: any) => (
<div key={r.id} className="border rounded-lg p-5 bg-yellow-50/50">
<div className="flex items-start justify-between mb-3">
<div>
<p className="font-semibold">{r.tenant?.name}</p>
<p className="text-sm text-muted-foreground">
{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' })}
</p>
</div>
<div className="text-right">
<p className="text-sm">
{planLabels[r.currentPlan]} <strong className="text-blue-600">{planLabels[r.requestedPlan]}</strong>
</p>
</div>
</div>
{r.message && (
<div className="bg-white rounded-md p-3 mb-3 text-sm border">
<p className="text-xs text-muted-foreground mb-1">Nachricht:</p>
{r.message}
</div>
)}
{showNoteFor === r.id && (
<div className="mb-3">
<textarea
value={adminNote}
onChange={e => setAdminNote(e.target.value)}
placeholder="Optionaler Hinweis an den Mandanten..."
className="w-full rounded-lg border px-3 py-2 text-sm resize-none h-16 focus:outline-none focus:ring-2 focus:ring-blue-500"
maxLength={500}
/>
</div>
)}
<div className="flex gap-2">
<Button
size="sm"
onClick={() => handleProcess(r.id, 'approve')}
disabled={processingId === r.id}
className="bg-green-600 hover:bg-green-700"
>
{processingId === r.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <><CheckCircle className="w-4 h-4 mr-1" /> Bestätigen</>}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleProcess(r.id, 'reject')}
disabled={processingId === r.id}
>
<Ban className="w-4 h-4 mr-1" /> Ablehnen
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setShowNoteFor(showNoteFor === r.id ? null : r.id)}
>
Hinweis {showNoteFor === r.id ? 'ausblenden' : 'hinzufügen'}
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* Processed requests */}
{processedRequests.length > 0 && (
<div>
<h3 className="font-semibold text-lg mb-3">Bearbeitete Anfragen</h3>
<div className="border rounded-lg divide-y">
{processedRequests.map((r: any) => (
<div key={r.id} className="p-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium">{r.tenant?.name}: {planLabels[r.currentPlan]} {planLabels[r.requestedPlan]}</p>
<p className="text-xs text-muted-foreground">
{r.requestedBy?.name} {new Date(r.createdAt).toLocaleDateString('de-CH')}
{r.processedAt && <> · Bearbeitet: {new Date(r.processedAt).toLocaleDateString('de-CH')}</>}
</p>
{r.adminNote && <p className="text-xs text-muted-foreground mt-0.5">Hinweis: {r.adminNote}</p>}
</div>
<span className={`text-xs px-2 py-1 rounded-full font-medium ${statusLabels[r.status]?.color || ''}`}>
{statusLabels[r.status]?.label || r.status}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}