refactor(admin): 1365-Zeilen-Seite in saubere Bereichs-Komponenten entwirrt
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 30m12s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 30m12s
- admin/page.tsx: 1365 -> 224 Zeilen (nur noch Guard + Header + Tabs -> Komponenten) - Neue in sich geschlossene Komponenten (eigener State + Daten-Laden): tenants-tab, users-tab, projects-tab, icons-tab, categories-tab - Gemeinsame Typen/Konstanten in admin-constants.ts zentralisiert - SettingsTab lädt Zähler selbst (von der Hülle entkoppelt) - Toten Code entfernt (UpgradeRequestsTab) - Ergänzt: Benutzer-Suche + Mandanten-Auswahl beim Anlegen (verhindert verwaiste User) - Version 1.4.7 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
62
src/components/admin/admin-constants.ts
Normal file
62
src/components/admin/admin-constants.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Gemeinsame Typen & Konstanten für den Admin-Bereich.
|
||||
// Zentral, damit die einzelnen Tab-Komponenten konsistent bleiben.
|
||||
|
||||
export interface IconCategory {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
sortOrder: number
|
||||
_count?: { icons: number }
|
||||
}
|
||||
|
||||
export interface IconAsset {
|
||||
id: string
|
||||
name: string
|
||||
fileKey: string
|
||||
mimeType: string
|
||||
isSystem: boolean
|
||||
isActive: boolean
|
||||
iconType: string
|
||||
tags: string[]
|
||||
category: IconCategory
|
||||
}
|
||||
|
||||
export interface UserRecord {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: 'SERVER_ADMIN' | 'TENANT_ADMIN' | 'OPERATOR' | 'VIEWER'
|
||||
emailVerified?: boolean
|
||||
isActive?: boolean
|
||||
lastLoginAt?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
memberships?: { tenant: { id: string; name: string; slug: string } }[]
|
||||
_count?: { projects: number }
|
||||
}
|
||||
|
||||
export interface TenantRecord {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
_count?: { memberships: number; projects: number }
|
||||
}
|
||||
|
||||
export 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' },
|
||||
]
|
||||
|
||||
export 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' },
|
||||
]
|
||||
111
src/components/admin/categories-tab.tsx
Normal file
111
src/components/admin/categories-tab.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
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 { useToast } from '@/components/ui/use-toast'
|
||||
import { FolderPlus, Pencil, Trash2 } from 'lucide-react'
|
||||
import type { IconCategory } from './admin-constants'
|
||||
|
||||
export function CategoriesTab() {
|
||||
const { toast } = useToast()
|
||||
const [categories, setCategories] = useState<IconCategory[]>([])
|
||||
|
||||
const [isCategoryDialogOpen, setIsCategoryDialogOpen] = useState(false)
|
||||
const [editingCategory, setEditingCategory] = useState<IconCategory | null>(null)
|
||||
const [categoryName, setCategoryName] = useState('')
|
||||
const [categoryDescription, setCategoryDescription] = useState('')
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/categories')
|
||||
if (res.ok) setCategories((await res.json()).categories || [])
|
||||
} catch (e) {
|
||||
console.error('Error fetching categories:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchCategories() }, [fetchCategories])
|
||||
|
||||
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('')
|
||||
fetchCategories()
|
||||
} 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' }); fetchCategories() }
|
||||
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div 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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
235
src/components/admin/icons-tab.tsx
Normal file
235
src/components/admin/icons-tab.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
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 { useToast } from '@/components/ui/use-toast'
|
||||
import { Upload, Pencil, Trash2, Eye, EyeOff, Image as ImageIcon, Loader2 } from 'lucide-react'
|
||||
import { ICON_TYPES, type IconAsset, type IconCategory } from './admin-constants'
|
||||
|
||||
export function IconsTab() {
|
||||
const { toast } = useToast()
|
||||
const [icons, setIcons] = useState<IconAsset[]>([])
|
||||
const [categories, setCategories] = useState<IconCategory[]>([])
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all')
|
||||
|
||||
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)
|
||||
|
||||
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('')
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [iconRes, catRes] = await Promise.all([
|
||||
fetch('/api/admin/icons'),
|
||||
fetch('/api/admin/categories'),
|
||||
])
|
||||
if (iconRes.ok) setIcons((await iconRes.json()).icons || [])
|
||||
if (catRes.ok) setCategories((await catRes.json()).categories || [])
|
||||
} catch (e) {
|
||||
console.error('Error fetching icons:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
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 {
|
||||
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' }) }
|
||||
} 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' }) }
|
||||
}
|
||||
|
||||
const filteredIcons = selectedCategory === 'all' ? icons : icons.filter(i => i.category.id === selectedCategory)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
<ImageIcon 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
93
src/components/admin/projects-tab.tsx
Normal file
93
src/components/admin/projects-tab.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Eye, Loader2 } from 'lucide-react'
|
||||
import type { TenantRecord } from './admin-constants'
|
||||
|
||||
export function ProjectsTab() {
|
||||
const [projects, setProjects] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [tenants, setTenants] = useState<TenantRecord[]>([])
|
||||
const [tenantFilter, setTenantFilter] = useState<string>('all')
|
||||
|
||||
const fetchProjects = useCallback(async (filter?: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = filter && filter !== 'all'
|
||||
? `/api/admin/projects?tenantId=${filter}`
|
||||
: '/api/admin/projects'
|
||||
const res = await fetch(url)
|
||||
if (res.ok) setProjects((await res.json()).projects || [])
|
||||
} catch {}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects()
|
||||
fetch('/api/admin/tenants').then(r => r.ok ? r.json() : { tenants: [] }).then(d => setTenants(d.tenants || [])).catch(() => {})
|
||||
}, [fetchProjects])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<p className="text-sm text-muted-foreground">{projects.length} Einsatz/Einsätze</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Feuerwehr:</span>
|
||||
<Select value={tenantFilter} onValueChange={(val) => { setTenantFilter(val); fetchProjects(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>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||
) : projects.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">Keine Einsätze gefunden.</p>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<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">
|
||||
{projects.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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,15 +12,26 @@ import {
|
||||
} from 'lucide-react'
|
||||
|
||||
interface SettingsTabProps {
|
||||
usersCount: number
|
||||
tenantsCount: number
|
||||
iconsCount: number
|
||||
onNavigateTab: (tab: string) => void
|
||||
}
|
||||
|
||||
export function SettingsTab({ usersCount, tenantsCount, iconsCount, onNavigateTab }: SettingsTabProps) {
|
||||
export function SettingsTab({ onNavigateTab }: SettingsTabProps) {
|
||||
const { toast } = useToast()
|
||||
|
||||
// Übersichts-Zähler selbst laden (entkoppelt von der Hülle)
|
||||
const [counts, setCounts] = useState({ users: 0, tenants: 0, icons: 0 })
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch('/api/admin/users').then(r => r.ok ? r.json() : { users: [] }).catch(() => ({ users: [] })),
|
||||
fetch('/api/admin/tenants').then(r => r.ok ? r.json() : { tenants: [] }).catch(() => ({ tenants: [] })),
|
||||
fetch('/api/admin/icons').then(r => r.ok ? r.json() : { icons: [] }).catch(() => ({ icons: [] })),
|
||||
]).then(([u, t, i]) => setCounts({
|
||||
users: (u.users || []).length,
|
||||
tenants: (t.tenants || []).length,
|
||||
icons: (i.icons || []).length,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
// SMTP Settings
|
||||
const [smtpHost, setSmtpHost] = useState('')
|
||||
const [smtpPort, setSmtpPort] = useState('587')
|
||||
@@ -416,9 +427,9 @@ export function SettingsTab({ usersCount, tenantsCount, iconsCount, onNavigateTa
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Version</span><span className="font-medium">1.0.0</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Framework</span><span className="font-medium">Next.js 14.1</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Datenbank</span><span className="font-medium">PostgreSQL 16</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Benutzer</span><span className="font-medium">{usersCount}</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Mandanten</span><span className="font-medium">{tenantsCount}</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Symbole</span><span className="font-medium">{iconsCount}</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Benutzer</span><span className="font-medium">{counts.users}</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Mandanten</span><span className="font-medium">{counts.tenants}</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Symbole</span><span className="font-medium">{counts.icons}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
126
src/components/admin/tenants-tab.tsx
Normal file
126
src/components/admin/tenants-tab.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
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 { useToast } from '@/components/ui/use-toast'
|
||||
import { Plus, Shield, Loader2 } from 'lucide-react'
|
||||
import { TenantDetailDialog } from './tenant-detail-dialog'
|
||||
import type { TenantRecord } from './admin-constants'
|
||||
|
||||
export function TenantsTab() {
|
||||
const { toast } = useToast()
|
||||
const [tenants, setTenants] = useState<TenantRecord[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const [isTenantDialogOpen, setIsTenantDialogOpen] = useState(false)
|
||||
const [tenantName, setTenantName] = useState('')
|
||||
const [tenantSlug, setTenantSlug] = useState('')
|
||||
const [tenantDescription, setTenantDescription] = useState('')
|
||||
|
||||
const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null)
|
||||
const [isTenantDetailOpen, setIsTenantDetailOpen] = useState(false)
|
||||
|
||||
const fetchTenants = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin/tenants')
|
||||
if (res.ok) setTenants((await res.json()).tenants || [])
|
||||
} catch (e) {
|
||||
console.error('Error fetching tenants:', e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchTenants() }, [fetchTenants])
|
||||
|
||||
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('')
|
||||
fetchTenants()
|
||||
} 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' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-muted-foreground">{tenants.length} Mandant(en)</p>
|
||||
<Button onClick={() => { setTenantName(''); setTenantSlug(''); setTenantDescription(''); setIsTenantDialogOpen(true) }}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Neuer Mandant
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></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>
|
||||
)}
|
||||
|
||||
{/* 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={fetchTenants}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
336
src/components/admin/users-tab.tsx
Normal file
336
src/components/admin/users-tab.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
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 { useToast } from '@/components/ui/use-toast'
|
||||
import { useAuth } from '@/components/providers/auth-provider'
|
||||
import { UserPlus, Pencil, Trash2, KeyRound, ShieldCheck, CheckCircle, Ban, Search, Loader2 } from 'lucide-react'
|
||||
import { formatDateTime } from '@/lib/utils'
|
||||
import { ROLES, type UserRecord, type TenantRecord } from './admin-constants'
|
||||
|
||||
export function UsersTab() {
|
||||
const { toast } = useToast()
|
||||
const { user } = useAuth()
|
||||
const isServerAdmin = user?.role === 'SERVER_ADMIN'
|
||||
|
||||
const [users, setUsers] = useState<UserRecord[]>([])
|
||||
const [tenants, setTenants] = useState<TenantRecord[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [search, setSearch] = 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')
|
||||
const [userTenantId, setUserTenantId] = useState<string>('')
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin/users')
|
||||
if (res.ok) setUsers((await res.json()).users || [])
|
||||
} catch (e) {
|
||||
console.error('Error fetching users:', e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchTenants = useCallback(async () => {
|
||||
if (!isServerAdmin) return
|
||||
try {
|
||||
const res = await fetch('/api/admin/tenants')
|
||||
if (res.ok) setTenants((await res.json()).tenants || [])
|
||||
} catch {}
|
||||
}, [isServerAdmin])
|
||||
|
||||
useEffect(() => { fetchUsers(); fetchTenants() }, [fetchUsers, fetchTenants])
|
||||
|
||||
const openNewUser = () => {
|
||||
setEditingUser(null); setUserName(''); setUserEmail(''); setUserPassword('')
|
||||
setUserPasswordConfirm(''); setUserRole('OPERATOR'); setUserTenantId('')
|
||||
setIsUserDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditUser = (u: UserRecord) => {
|
||||
setEditingUser(u); setUserName(u.name); setUserEmail(u.email); setUserPassword('')
|
||||
setUserPasswordConfirm(''); setUserRole(u.role)
|
||||
setUserTenantId(u.memberships?.[0]?.tenant?.id || '')
|
||||
setIsUserDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveUser = async () => {
|
||||
try {
|
||||
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
|
||||
// Mandanten-Zuordnung nur beim Anlegen durch den Super-Admin (verhindert verwaiste User)
|
||||
if (!editingUser && isServerAdmin && userRole !== 'SERVER_ADMIN' && userTenantId) {
|
||||
body.tenantId = userTenantId
|
||||
}
|
||||
|
||||
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)
|
||||
fetchUsers()
|
||||
} 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' }); fetchUsers() }
|
||||
} 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' : 'Verifizierung entfernt' }); fetchUsers() }
|
||||
else { const err = await res.json(); toast({ title: 'Fehler', description: err.error, variant: 'destructive' }) }
|
||||
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
|
||||
}
|
||||
|
||||
const handleToggleUserActive = async (targetUser: UserRecord) => {
|
||||
const willDeactivate = targetUser.isActive !== false
|
||||
if (willDeactivate && !confirm(`"${targetUser.name}" sperren? Der Benutzer kann sich dann nicht mehr anmelden.`)) return
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${targetUser.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isActive: !willDeactivate }),
|
||||
})
|
||||
if (res.ok) { toast({ title: willDeactivate ? 'Benutzer gesperrt' : 'Benutzer entsperrt' }); fetchUsers() }
|
||||
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) {
|
||||
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' }) }
|
||||
}
|
||||
|
||||
const q = search.trim().toLowerCase()
|
||||
const filteredUsers = q
|
||||
? users.filter(u =>
|
||||
u.name.toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
(u.memberships || []).some(m => m.tenant?.name?.toLowerCase().includes(q)))
|
||||
: users
|
||||
|
||||
const lockedCount = users.filter(u => u.isActive === false).length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 w-4 h-4 text-muted-foreground" />
|
||||
<Input value={search} onChange={e => setSearch(e.target.value)} placeholder="Suche Name, E-Mail, Mandant…" className="pl-8 w-[260px]" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredUsers.length}{q ? ` / ${users.length}` : ''} Benutzer
|
||||
{lockedCount > 0 && ` · ${lockedCount} gesperrt`}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openNewUser}>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Neuer Benutzer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<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">Mandant</th>
|
||||
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Letzte Anmeldung</th>
|
||||
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Status</th>
|
||||
<th className="text-right text-xs font-medium text-muted-foreground px-4 py-3">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{filteredUsers.map(u => {
|
||||
const inactive = u.isActive === false
|
||||
const tenantNames = (u.memberships || []).map(m => m.tenant?.name).filter(Boolean)
|
||||
return (
|
||||
<tr key={u.id} className={`hover:bg-muted/30 transition-colors ${inactive ? 'opacity-50' : ''}`}>
|
||||
<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">{u.email}</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">
|
||||
{tenantNames.length > 0 ? tenantNames.join(', ') : <span className="text-muted-foreground/50">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{u.lastLoginAt ? (
|
||||
<span className="text-foreground/80">{formatDateTime(u.lastLoginAt)}</span>
|
||||
) : (
|
||||
<span className="text-amber-600 text-xs">Nie eingeloggt</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{inactive ? (
|
||||
<span className="text-xs px-2 py-1 rounded-full font-medium bg-red-100 text-red-700">Gesperrt</span>
|
||||
) : u.emailVerified === false ? (
|
||||
<span className="text-xs px-2 py-1 rounded-full font-medium bg-amber-100 text-amber-700">Unverifiziert</span>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-1 rounded-full font-medium bg-green-100 text-green-700">Aktiv</span>
|
||||
)}
|
||||
</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" title="Bearbeiten" onClick={() => openEditUser(u)}>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className={`h-8 w-8 ${inactive ? 'text-green-600' : 'text-amber-600'}`}
|
||||
title={inactive ? 'Entsperren' : 'Sperren'} onClick={() => handleToggleUserActive(u)}
|
||||
disabled={u.id === user?.id || u.email === 'admin@lageplan.local'}>
|
||||
{inactive ? <CheckCircle className="w-4 h-4" /> : <Ban className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" title="Löschen" onClick={() => handleDeleteUser(u.id)}
|
||||
disabled={u.email === 'admin@lageplan.local'}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)})}
|
||||
{filteredUsers.length === 0 && (
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-sm text-muted-foreground">Keine Benutzer gefunden.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 => isServerAdmin || 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>
|
||||
{/* Mandanten-Auswahl: nur beim Anlegen durch Super-Admin für Nicht-Server-Admins */}
|
||||
{!editingUser && isServerAdmin && userRole !== 'SERVER_ADMIN' && (
|
||||
<div>
|
||||
<Label>Mandant</Label>
|
||||
<Select value={userTenantId} onValueChange={setUserTenantId}>
|
||||
<SelectTrigger><SelectValue placeholder="Mandant wählen (empfohlen)" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{tenants.map(t => <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">Ohne Mandant hat der Benutzer keine Organisation.</p>
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user