'use client' import { useState, useEffect, useCallback, useRef } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { useToast } from '@/components/ui/use-toast' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@/components/ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { ChevronDown, ChevronRight, Plus, Pencil, Trash2, Search, Upload, Loader2, X, Check, LayoutGrid, ImageIcon, FolderOpen, AlertCircle, Library, } from 'lucide-react' /* ─── Types ─── */ interface TenantCategory { id: string name: string description: string | null sortOrder: number } interface TenantSymbol { id: string name: string customName: string | null svgPath: string | null categoryId: string | null sortOrder: number isUploaded: boolean migratedFromIconId: string | null category?: TenantCategory | null } interface SymbolGroup { category: TenantCategory | null symbols: TenantSymbol[] } /* ─── Component ─── */ export function SymbolManager() { const { toast } = useToast() /* -- Data -- */ const [loading, setLoading] = useState(true) const [categories, setCategories] = useState([]) const [symbolGroups, setSymbolGroups] = useState([]) const [flatSymbols, setFlatSymbols] = useState([]) /* -- UI state -- */ const [search, setSearch] = useState('') const [expandedCats, setExpandedCats] = useState>(new Set()) const [activeTab, setActiveTab] = useState<'symbols' | 'categories' | 'library'>('library') /* -- Symbol editing -- */ const [editingSymbolId, setEditingSymbolId] = useState(null) const [editSymbolName, setEditSymbolName] = useState('') /* -- Category editing -- */ const [newCatName, setNewCatName] = useState('') const [editingCatId, setEditingCatId] = useState(null) const [editCatName, setEditCatName] = useState('') /* -- Upload dialog -- */ const [uploadOpen, setUploadOpen] = useState(false) const [uploadCatId, setUploadCatId] = useState('') const [uploadDragging, setUploadDragging] = useState(false) const [uploading, setUploading] = useState(false) const fileInputRef = useRef(null) /* -- Library -- */ const [libraryIcons, setLibraryIcons] = useState([]) const [librarySearch, setLibrarySearch] = useState('') const [libraryLoading, setLibraryLoading] = useState(false) const [addingIconId, setAddingIconId] = useState(null) /* ─── Fetch ─── */ const fetchData = useCallback(async () => { setLoading(true) try { const [catRes, symRes] = await Promise.all([ fetch('/api/tenant/categories'), fetch('/api/tenant/symbols?grouped=true'), ]) if (catRes.ok) { const c = await catRes.json() setCategories(c.categories || []) } if (symRes.ok) { const s = await symRes.json() setSymbolGroups(s.categories || []) // Flatten all symbols from categories for ungrouped / category-management views const all = (s.categories || []).flatMap((c: any) => c.symbols || []) setFlatSymbols(all) } } catch { toast({ title: 'Fehler beim Laden', variant: 'destructive' }) } setLoading(false) }, [toast]) useEffect(() => { fetchData() }, [fetchData]) /* ─── Helpers ─── */ const toggleCat = (id: string) => { setExpandedCats(prev => { const n = new Set(prev) n.has(id) ? n.delete(id) : n.add(id) return n }) } const getSymbolImageUrl = (sym: TenantSymbol) => { if (sym.isUploaded && sym.svgPath) { // MinIO presigned or direct return `/api/tenant/symbols/${sym.id}/image` } if (sym.migratedFromIconId) { return `/api/icons/${sym.migratedFromIconId}/image` } return `/api/icons/${sym.id}/image` } /* ─── Symbol CRUD ─── */ const updateSymbol = async (id: string, payload: Partial) => { try { const res = await fetch('/api/tenant/symbols', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, ...payload }), }) if (!res.ok) throw new Error() await fetchData() toast({ title: 'Gespeichert' }) } catch { toast({ title: 'Fehler beim Speichern', variant: 'destructive' }) } } const deleteSymbol = async (id: string) => { if (!confirm('Symbol wirklich löschen?')) return try { const res = await fetch('/api/tenant/symbols', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), }) if (!res.ok) throw new Error() await fetchData() toast({ title: 'Symbol gelöscht' }) } catch { toast({ title: 'Fehler beim Löschen', variant: 'destructive' }) } } /* ─── Category CRUD ─── */ const createCategory = async () => { const name = newCatName.trim() if (!name) return try { const res = await fetch('/api/tenant/categories', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }) if (!res.ok) { const err = await res.json().catch(() => ({})) toast({ title: err.error || 'Fehler', variant: 'destructive' }) return } setNewCatName('') await fetchData() toast({ title: 'Kategorie erstellt' }) } catch { toast({ title: 'Fehler', variant: 'destructive' }) } } const updateCategory = async (id: string, name: string) => { try { const res = await fetch('/api/tenant/categories', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, name }), }) if (!res.ok) { const err = await res.json().catch(() => ({})) toast({ title: err.error || 'Fehler', variant: 'destructive' }) return } setEditingCatId(null) await fetchData() toast({ title: 'Kategorie umbenannt' }) } catch { toast({ title: 'Fehler', variant: 'destructive' }) } } const deleteCategory = async (id: string) => { const hasSymbols = flatSymbols.some(s => s.categoryId === id) if (hasSymbols) { toast({ title: 'Kategorie ist nicht leer', description: 'Verschiebe oder lösche zuerst die enthaltenen Symbole.', variant: 'destructive' }) return } if (!confirm('Kategorie wirklich löschen?')) return try { const res = await fetch(`/api/tenant/categories?id=${id}`, { method: 'DELETE' }) if (!res.ok) { const err = await res.json().catch(() => ({})) toast({ title: err.error || 'Fehler', variant: 'destructive' }) return } await fetchData() toast({ title: 'Kategorie gelöscht' }) } catch { toast({ title: 'Fehler', variant: 'destructive' }) } } /* ─── Upload ─── */ const handleFiles = async (files: FileList | null) => { if (!files || files.length === 0) return setUploading(true) let success = 0 for (const file of Array.from(files)) { const form = new FormData() form.append('file', file) if (uploadCatId) form.append('categoryId', uploadCatId) try { const res = await fetch('/api/tenant/symbols', { method: 'POST', body: form }) if (res.ok) success++ } catch { /* ignore single failure */ } } setUploading(false) setUploadOpen(false) await fetchData() toast({ title: `${success} Datei(en) hochgeladen` }) } /* ─── Library ─── */ const fetchLibrary = useCallback(async () => { setLibraryLoading(true) try { const res = await fetch('/api/icons') if (res.ok) { const data = await res.json() setLibraryIcons(data.categories || []) } } catch { toast({ title: 'Fehler beim Laden der Bibliothek', variant: 'destructive' }) } setLibraryLoading(false) }, [toast]) useEffect(() => { if (activeTab === 'library') fetchLibrary() }, [activeTab, fetchLibrary]) const addFromLibrary = async (iconId: string, name: string) => { setAddingIconId(iconId) try { const res = await fetch('/api/tenant/symbols', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ iconId }), }) if (!res.ok) { const err = await res.json().catch(() => ({})) toast({ title: err.error || 'Fehler', variant: 'destructive' }) } else { toast({ title: `'${name}' hinzugefügt` }) await fetchData() } } catch { toast({ title: 'Fehler beim Hinzufügen', variant: 'destructive' }) } setAddingIconId(null) } /* ─── Derived data ─── */ const filteredGroups = symbolGroups.map(g => ({ ...g, symbols: g.symbols.filter(s => !search || s.name.toLowerCase().includes(search.toLowerCase()) || (s.customName && s.customName.toLowerCase().includes(search.toLowerCase())) ), })).filter(g => g.symbols.length > 0) const ungroupedSymbols = flatSymbols.filter(s => !s.categoryId) /* ─── Render ─── */ if (loading) { return (
Symbole laden...
) } return (
{/* Tabs */}
{([ { key: 'symbols', label: 'Meine Symbole', icon: LayoutGrid }, { key: 'categories', label: 'Kategorien', icon: FolderOpen }, { key: 'library', label: 'Bibliothek', icon: Library }, ] as const).map(t => ( ))}
{/* ===== TAB: Meine Symbole ===== */} {activeTab === 'symbols' && (
{/* Search */}
setSearch(e.target.value)} className="pl-9" />
{/* Symbol grid grouped by category */}
{filteredGroups.map(g => { const catId = g.category?.id ?? '__none__' const isExpanded = expandedCats.has(catId) return (
{isExpanded && (
{g.symbols.map(sym => ( { setEditingSymbolId(sym.id); setEditSymbolName(sym.customName || sym.name) }} onCancelEdit={() => { setEditingSymbolId(null); setEditSymbolName('') }} onSaveEdit={(name) => { updateSymbol(sym.id, { customName: name }); setEditingSymbolId(null) }} onEditNameChange={setEditSymbolName} onMoveCategory={(catId) => updateSymbol(sym.id, { categoryId: catId || null })} onDelete={() => deleteSymbol(sym.id)} imageUrl={getSymbolImageUrl(sym)} /> ))}
)}
) })} {filteredGroups.length === 0 && (

{search ? 'Keine Symbole gefunden.' : 'Noch keine Symbole. Füge Symbole aus der Bibliothek hinzu oder lade eigene hoch.'}

)}
)} {/* ===== TAB: Kategorien ===== */} {activeTab === 'categories' && (
{/* New category */}
setNewCatName(e.target.value)} onKeyDown={e => e.key === 'Enter' && createCategory()} />
{/* Category list */}
{categories.sort((a, b) => a.sortOrder - b.sortOrder).map(cat => { const symbolCount = flatSymbols.filter(s => s.categoryId === cat.id).length const isEditing = editingCatId === cat.id return (
{isEditing ? (
setEditCatName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') updateCategory(cat.id, editCatName) if (e.key === 'Escape') setEditingCatId(null) }} className="h-8 text-sm" autoFocus />
) : (

{cat.name}

{symbolCount} Symbol(e)

)}
{!isEditing && (
)}
) })} {categories.length === 0 && (
Noch keine Kategorien. Erstelle oben die erste Kategorie.
)}
)} {/* ===== TAB: Bibliothek ===== */} {activeTab === 'library' && (
setLibrarySearch(e.target.value)} className="pl-9" />
{libraryLoading ? (
Bibliothek laden...
) : (
{libraryIcons.map((cat: any) => { const filtered = (cat.icons || []).filter((icon: any) => !librarySearch || (icon.name || '').toLowerCase().includes(librarySearch.toLowerCase()) ) if (filtered.length === 0) return null return (
{cat.name} ({filtered.length})
{filtered.map((icon: any) => (
{icon.name} { (e.target as HTMLImageElement).src = '/logo.svg' }} />

{icon.name}

))}
) })}
)}
)} {/* ===== UPLOAD DIALOG ===== */} Symbole hochladen SVG wird empfohlen (scharf in jeder Grösse). PNG/JPEG sind auch möglich.
{ e.preventDefault(); setUploadDragging(true) }} onDragLeave={() => setUploadDragging(false)} onDrop={e => { e.preventDefault() setUploadDragging(false) handleFiles(e.dataTransfer.files) }} onClick={() => fileInputRef.current?.click()} >

Dateien hierher ziehen oder klicken

SVG, PNG, JPEG

handleFiles(e.target.files)} />
{uploading && (
Hochladen...
)}
) } /* ─── Subcomponent: SymbolCard ─── */ function SymbolCard({ sym, categories, editing, editName, onStartEdit, onCancelEdit, onSaveEdit, onEditNameChange, onMoveCategory, onDelete, imageUrl, }: { sym: TenantSymbol categories: TenantCategory[] editing: boolean editName: string onStartEdit: () => void onCancelEdit: () => void onSaveEdit: (name: string) => void onEditNameChange: (v: string) => void onMoveCategory: (catId: string) => void onDelete: () => void imageUrl: string }) { return (
{sym.name} { (e.target as HTMLImageElement).src = '/logo.svg' }} />
{/* Name / Edit */} {editing ? (
onEditNameChange(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') onSaveEdit(editName) if (e.key === 'Escape') onCancelEdit() }} className="h-6 text-[10px] px-1" autoFocus />
) : (

{sym.customName || sym.name}

)} {/* Category select */} {/* Hover actions */}
{/* Uploaded badge */} {sym.isUploaded && (
)}
) }