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

- 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:
Pepe Ziberi
2026-07-18 23:31:17 +02:00
parent d08b96a42a
commit a081b3ebe4
10 changed files with 1115 additions and 1323 deletions

View 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>
)
}