1365 lines
60 KiB
TypeScript
1365 lines
60 KiB
TypeScript
'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>
|
||
)
|
||
}
|