Files
Lageplan/src/components/admin/symbol-manager.tsx
Pepe Ziberi 0d0d9a7257
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 14m56s
fix(symbol-manager): Select.Item empty string replaced with '__none__' placeholder
2026-05-21 08:16:20 +02:00

763 lines
28 KiB
TypeScript

'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<TenantCategory[]>([])
const [symbolGroups, setSymbolGroups] = useState<SymbolGroup[]>([])
const [flatSymbols, setFlatSymbols] = useState<TenantSymbol[]>([])
/* -- UI state -- */
const [search, setSearch] = useState('')
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set())
const [activeTab, setActiveTab] = useState<'symbols' | 'categories' | 'library'>('library')
/* -- Symbol editing -- */
const [editingSymbolId, setEditingSymbolId] = useState<string | null>(null)
const [editSymbolName, setEditSymbolName] = useState('')
/* -- Category editing -- */
const [newCatName, setNewCatName] = useState('')
const [editingCatId, setEditingCatId] = useState<string | null>(null)
const [editCatName, setEditCatName] = useState('')
/* -- Upload dialog -- */
const [uploadOpen, setUploadOpen] = useState(false)
const [uploadCatId, setUploadCatId] = useState<string>('')
const [uploadDragging, setUploadDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
/* -- Library -- */
const [libraryIcons, setLibraryIcons] = useState<any[]>([])
const [librarySearch, setLibrarySearch] = useState('')
const [libraryLoading, setLibraryLoading] = useState(false)
const [addingIconId, setAddingIconId] = useState<string | null>(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<TenantSymbol>) => {
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 (
<div className="flex items-center gap-2 py-12 justify-center text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" /> Symbole laden...
</div>
)
}
return (
<div className="space-y-4">
{/* Tabs */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex gap-1 bg-muted p-1 rounded-lg">
{([
{ key: 'symbols', label: 'Meine Symbole', icon: LayoutGrid },
{ key: 'categories', label: 'Kategorien', icon: FolderOpen },
{ key: 'library', label: 'Bibliothek', icon: Library },
] as const).map(t => (
<button
key={t.key}
onClick={() => setActiveTab(t.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
activeTab === t.key ? 'bg-background shadow-sm font-medium' : 'text-muted-foreground hover:text-foreground'
}`}
>
<t.icon className="w-4 h-4" />
{t.label}
</button>
))}
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => setUploadOpen(true)}>
<Upload className="w-4 h-4 mr-1.5" /> Upload
</Button>
</div>
</div>
{/* ===== TAB: Meine Symbole ===== */}
{activeTab === 'symbols' && (
<div className="space-y-4">
{/* Search */}
<div className="relative max-w-sm">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Symbole suchen..."
value={search}
onChange={e => setSearch(e.target.value)}
className="pl-9"
/>
</div>
{/* Symbol grid grouped by category */}
<div className="space-y-3">
{filteredGroups.map(g => {
const catId = g.category?.id ?? '__none__'
const isExpanded = expandedCats.has(catId)
return (
<div key={catId} className="border rounded-lg">
<button
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/30 transition-colors"
onClick={() => toggleCat(catId)}
>
<span className="font-medium text-sm flex items-center gap-2">
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
{g.category?.name || 'Ohne Kategorie'}
<span className="text-xs text-muted-foreground font-normal">({g.symbols.length})</span>
</span>
</button>
{isExpanded && (
<div className="border-t px-3 py-3">
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 gap-3">
{g.symbols.map(sym => (
<SymbolCard
key={sym.id}
sym={sym}
categories={categories}
editing={editingSymbolId === sym.id}
editName={editSymbolName}
onStartEdit={() => { 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)}
/>
))}
</div>
</div>
)}
</div>
)
})}
{filteredGroups.length === 0 && (
<div className="text-center py-12 border rounded-lg">
<ImageIcon className="w-10 h-10 mx-auto text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
{search ? 'Keine Symbole gefunden.' : 'Noch keine Symbole. Füge Symbole aus der Bibliothek hinzu oder lade eigene hoch.'}
</p>
<div className="flex justify-center gap-2 mt-3">
<Button size="sm" variant="outline" onClick={() => setActiveTab('library')}>
<Library className="w-4 h-4 mr-1" /> Bibliothek durchsuchen
</Button>
<Button size="sm" variant="outline" onClick={() => setUploadOpen(true)}>
<Upload className="w-4 h-4 mr-1" /> Hochladen
</Button>
</div>
</div>
)}
</div>
</div>
)}
{/* ===== TAB: Kategorien ===== */}
{activeTab === 'categories' && (
<div className="space-y-4 max-w-xl">
{/* New category */}
<div className="flex gap-2">
<Input
placeholder="Neue Kategorie..."
value={newCatName}
onChange={e => setNewCatName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && createCategory()}
/>
<Button onClick={createCategory} disabled={!newCatName.trim()}>
<Plus className="w-4 h-4 mr-1" /> Erstellen
</Button>
</div>
{/* Category list */}
<div className="border rounded-lg divide-y">
{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 (
<div key={cat.id} className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3 min-w-0">
<FolderOpen className="w-4 h-4 text-muted-foreground shrink-0" />
{isEditing ? (
<div className="flex items-center gap-1">
<Input
value={editCatName}
onChange={e => 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
/>
<Button size="sm" variant="ghost" className="h-8 w-8 p-0" onClick={() => updateCategory(cat.id, editCatName)}>
<Check className="w-4 h-4 text-green-600" />
</Button>
<Button size="sm" variant="ghost" className="h-8 w-8 p-0" onClick={() => setEditingCatId(null)}>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<div className="min-w-0">
<p className="text-sm font-medium truncate">{cat.name}</p>
<p className="text-xs text-muted-foreground">{symbolCount} Symbol(e)</p>
</div>
)}
</div>
{!isEditing && (
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
>
<Pencil className="w-3.5 h-3.5 text-muted-foreground" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => deleteCategory(cat.id)}
disabled={symbolCount > 0}
title={symbolCount > 0 ? 'Kategorie ist nicht leer' : 'Löschen'}
>
<Trash2 className={`w-3.5 h-3.5 ${symbolCount > 0 ? 'text-muted-foreground/40' : 'text-destructive'}`} />
</Button>
</div>
)}
</div>
)
})}
{categories.length === 0 && (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
Noch keine Kategorien. Erstelle oben die erste Kategorie.
</div>
)}
</div>
</div>
)}
{/* ===== TAB: Bibliothek ===== */}
{activeTab === 'library' && (
<div className="space-y-4">
<div className="relative max-w-sm">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="In Bibliothek suchen..."
value={librarySearch}
onChange={e => setLibrarySearch(e.target.value)}
className="pl-9"
/>
</div>
{libraryLoading ? (
<div className="flex items-center gap-2 py-12 justify-center text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" /> Bibliothek laden...
</div>
) : (
<div className="space-y-4">
{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 (
<div key={cat.id} className="border rounded-lg">
<div className="px-3 py-2 bg-muted/30 border-b">
<span className="font-medium text-sm">{cat.name}</span>
<span className="text-xs text-muted-foreground ml-2">({filtered.length})</span>
</div>
<div className="px-3 py-3">
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-3">
{filtered.map((icon: any) => (
<div key={icon.id} className="group relative border rounded-lg p-2 transition-all hover:shadow-md hover:border-primary/30">
<div className="aspect-square flex items-center justify-center mb-1.5 bg-muted/50 rounded">
<img
src={icon.url}
alt={icon.name}
className="w-10 h-10 object-contain"
draggable={false}
onError={(e) => { (e.target as HTMLImageElement).src = '/logo.svg' }}
/>
</div>
<p className="text-[11px] text-center truncate" title={icon.name}>{icon.name}</p>
<button
onClick={() => addFromLibrary(icon.id, icon.name)}
disabled={addingIconId === icon.id}
className="absolute top-1 right-1 w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-primary/90 disabled:opacity-50"
title="Zu Meinen Symbolen hinzufügen"
>
{addingIconId === icon.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Plus className="w-3.5 h-3.5" />
)}
</button>
</div>
))}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
)}
{/* ===== UPLOAD DIALOG ===== */}
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Symbole hochladen</DialogTitle>
<DialogDescription>
SVG wird empfohlen (scharf in jeder Grösse). PNG/JPEG sind auch möglich.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium mb-1.5 block">Kategorie (optional)</label>
<Select value={uploadCatId} onValueChange={setUploadCatId}>
<SelectTrigger>
<SelectValue placeholder="Keine Kategorie" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Keine Kategorie</SelectItem>
{categories.map(c => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${
uploadDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-muted-foreground/50'
}`}
onDragOver={e => { e.preventDefault(); setUploadDragging(true) }}
onDragLeave={() => setUploadDragging(false)}
onDrop={e => {
e.preventDefault()
setUploadDragging(false)
handleFiles(e.dataTransfer.files)
}}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-8 h-8 mx-auto text-muted-foreground mb-2" />
<p className="text-sm font-medium">Dateien hierher ziehen oder klicken</p>
<p className="text-xs text-muted-foreground mt-1">SVG, PNG, JPEG</p>
<input
ref={fileInputRef}
type="file"
multiple
accept=".svg,image/svg+xml,image/png,image/jpeg"
className="hidden"
onChange={e => handleFiles(e.target.files)}
/>
</div>
{uploading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground justify-center">
<Loader2 className="w-4 h-4 animate-spin" /> Hochladen...
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
)
}
/* ─── 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 (
<div className="group relative border rounded-lg p-2 transition-all hover:shadow-md hover:border-primary/30">
<div className="aspect-square flex items-center justify-center mb-1.5 bg-muted/50 rounded">
<img
src={imageUrl}
alt={sym.name}
className="w-12 h-12 object-contain"
draggable={false}
onError={(e) => { (e.target as HTMLImageElement).src = '/logo.svg' }}
/>
</div>
{/* Name / Edit */}
{editing ? (
<div className="flex gap-0.5">
<Input
value={editName}
onChange={e => 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
/>
<button onClick={() => onSaveEdit(editName)} className="text-green-600 hover:text-green-700">
<Check className="w-3.5 h-3.5" />
</button>
<button onClick={onCancelEdit} className="text-muted-foreground hover:text-foreground">
<X className="w-3.5 h-3.5" />
</button>
</div>
) : (
<p
className="text-[11px] text-center truncate cursor-pointer hover:text-primary"
title={`${sym.customName || sym.name}${sym.isUploaded ? ' (Eigenes Upload)' : ''}`}
onClick={onStartEdit}
>
{sym.customName || sym.name}
</p>
)}
{/* Category select */}
<Select
value={sym.categoryId || '__none__'}
onValueChange={(val) => onMoveCategory(val === '__none__' ? '' : val)}
>
<SelectTrigger className="h-5 text-[10px] border-0 bg-transparent px-0 mt-0.5 hover:bg-muted/50 rounded">
<SelectValue placeholder="Kategorie" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Ohne Kategorie</SelectItem>
{categories.map(c => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
{/* Hover actions */}
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={onStartEdit}
className="w-5 h-5 rounded bg-background/80 border flex items-center justify-center text-muted-foreground hover:text-primary"
title="Umbenennen"
>
<Pencil className="w-3 h-3" />
</button>
<button
onClick={onDelete}
className="w-5 h-5 rounded bg-background/80 border flex items-center justify-center text-muted-foreground hover:text-destructive"
title="Löschen"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
{/* Uploaded badge */}
{sym.isUploaded && (
<div className="absolute top-1 left-1">
<div className="w-2 h-2 rounded-full bg-blue-500" title="Eigenes Upload" />
</div>
)}
</div>
)
}