From 4602de7a385fadb0d83a42cad07c27bd7fff6462 Mon Sep 17 00:00:00 2001 From: Pepe Ziberi Date: Wed, 20 May 2026 21:29:45 +0200 Subject: [PATCH] Phase 1 Sprint C+D: Admin UI + Frontend Sidebar --- src/app/api/icons/route.ts | 90 +- .../api/tenant/symbols/[id]/image/route.ts | 83 ++ src/components/admin/symbol-manager.tsx | 999 +++++++++++++----- src/components/layout/right-sidebar.tsx | 123 ++- 4 files changed, 963 insertions(+), 332 deletions(-) create mode 100644 src/app/api/tenant/symbols/[id]/image/route.ts diff --git a/src/app/api/icons/route.ts b/src/app/api/icons/route.ts index 5d0b60b..386d1a8 100644 --- a/src/app/api/icons/route.ts +++ b/src/app/api/icons/route.ts @@ -5,10 +5,11 @@ import { getSession } from '@/lib/auth' export async function GET() { try { const user = await getSession() + const tenantId = user?.tenantId - // Filter categories: global (tenantId=null) + tenant-specific - const categoryWhere: any = user?.tenantId - ? { OR: [{ tenantId: null }, { tenantId: user.tenantId }] } + /* ─── 1. Global library (legacy IconAsset) ─── */ + const categoryWhere: any = tenantId + ? { OR: [{ tenantId: null }, { tenantId }] } : {} const categories = await (prisma as any).iconCategory.findMany({ @@ -16,19 +17,18 @@ export async function GET() { orderBy: { sortOrder: 'asc' }, include: { icons: { - where: user?.tenantId - ? { isActive: true, OR: [{ tenantId: null }, { tenantId: user.tenantId }] } + where: tenantId + ? { isActive: true, OR: [{ tenantId: null }, { tenantId }] } : { isActive: true, tenantId: null }, orderBy: { name: 'asc' }, }, }, }) - // Get tenant's hidden icon IDs (legacy) let hiddenIconIds: string[] = [] - if (user?.tenantId) { + if (tenantId) { const tenant = await (prisma as any).tenant.findUnique({ - where: { id: user.tenantId }, + where: { id: tenantId }, select: { hiddenIconIds: true }, }) hiddenIconIds = tenant?.hiddenIconIds || [] @@ -44,15 +44,72 @@ export async function GET() { })), })) - // Get tenant's custom symbol collection (with custom names) - let mySymbols: any[] = [] - if (user?.tenantId) { + /* ─── 2. Tenant symbols (Phase 1 architecture) ─── */ + let tenantSymbolGroups: any[] = [] + let flatTenantSymbols: any[] = [] + + if (tenantId) { const tenantSymbols = await (prisma as any).tenantSymbol.findMany({ - where: { tenantId: user.tenantId }, + where: { tenantId }, + include: { category: true }, + orderBy: [{ category: { sortOrder: 'asc' } }, { sortOrder: 'asc' }], + }) + + flatTenantSymbols = tenantSymbols.map((ts: any) => ({ + id: ts.id, + name: ts.customName || ts.name, + customName: ts.customName, + categoryId: ts.categoryId, + categoryName: ts.category?.name || null, + isUploaded: ts.isUploaded, + migratedFromIconId: ts.migratedFromIconId, + imageUrl: ts.isUploaded + ? `/api/tenant/symbols/${ts.id}/image` + : ts.migratedFromIconId + ? `/api/icons/${ts.migratedFromIconId}/image` + : `/api/icons/${ts.id}/image`, + })) + + // Group by category for sidebar display + const groups = new Map() + for (const sym of flatTenantSymbols) { + const key = sym.categoryId || null + if (!groups.has(key)) groups.set(key, []) + groups.get(key)!.push(sym) + } + + // Fetch categories that have symbols but may not be in the symbol list (empty ones are omitted) + const catIds = Array.from(groups.keys()).filter(Boolean) as string[] + const tenantCategories = catIds.length + ? await (prisma as any).tenantCategory.findMany({ + where: { id: { in: catIds }, tenantId }, + orderBy: { sortOrder: 'asc' }, + }) + : [] + + const catMap = new Map(tenantCategories.map((c: any) => [c.id, c])) + + tenantSymbolGroups = Array.from(groups.entries()).map(([catId, symbols]) => { + const cat = catId ? catMap.get(catId) : null + return { + categoryId: catId, + categoryName: cat ? (cat as any).name || 'Kategorie' : 'Ohne Kategorie', + sortOrder: cat ? (cat as any).sortOrder ?? 999 : 999, + symbols, + } + }) + tenantSymbolGroups.sort((a, b) => a.sortOrder - b.sortOrder) + } + + /* ─── 3. Legacy mySymbols (keep for old clients during transition) ─── */ + let mySymbolsLegacy: any[] = [] + if (tenantId) { + const legacy = await (prisma as any).tenantSymbol.findMany({ + where: { tenantId, iconId: { not: null } }, include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true } } }, orderBy: { sortOrder: 'asc' }, }) - mySymbols = tenantSymbols.map((ts: any) => ({ + mySymbolsLegacy = legacy.map((ts: any) => ({ id: ts.icon.id, tenantSymbolId: ts.id, name: ts.customName || ts.icon.name, @@ -63,7 +120,12 @@ export async function GET() { })) } - return NextResponse.json({ categories: categoriesWithUrls, mySymbols }) + return NextResponse.json({ + categories: categoriesWithUrls, + mySymbols: mySymbolsLegacy, // legacy shape for old clients + tenantSymbols: flatTenantSymbols, // new flat list + tenantSymbolGroups, // grouped by TenantCategory + }) } catch (error) { console.error('Error fetching icons:', error) return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) diff --git a/src/app/api/tenant/symbols/[id]/image/route.ts b/src/app/api/tenant/symbols/[id]/image/route.ts new file mode 100644 index 0000000..d3824f4 --- /dev/null +++ b/src/app/api/tenant/symbols/[id]/image/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { getFileStream } from '@/lib/minio' +import { readFile } from 'fs/promises' +import { join } from 'path' + +async function getTenantId() { + const { headers } = await import('next/headers') + const h = await headers() + return h.get('x-tenant-id') || 'default' +} + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const tenantId = await getTenantId() + + const symbol = await (prisma as any).tenantSymbol.findFirst({ + where: { id, tenantId }, + include: { category: true }, + }) + + if (!symbol) { + return new NextResponse('Not found', { status: 404 }) + } + + // 1. Uploaded file → MinIO + if (symbol.isUploaded && symbol.svgPath) { + try { + const { stream, contentType } = await getFileStream(symbol.svgPath) + const chunks: Buffer[] = [] + for await (const chunk of stream as any) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + const buffer = Buffer.concat(chunks) + return new NextResponse(buffer, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=86400', + }, + }) + } catch (err) { + console.error('MinIO stream error:', err) + return new NextResponse('File not available', { status: 502 }) + } + } + + // 2. Migrated from IconAsset → serve from public/signaturen + if (symbol.migratedFromIconId) { + const icon = await (prisma as any).iconAsset.findUnique({ + where: { id: symbol.migratedFromIconId }, + }) + if (icon?.fileKey) { + const filePath = join(process.cwd(), 'public', icon.fileKey) + try { + const buffer = await readFile(filePath) + const ext = icon.fileKey.split('.').pop()?.toLowerCase() + const mimeType = + ext === 'svg' ? 'image/svg+xml' : + ext === 'png' ? 'image/png' : + ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : + 'application/octet-stream' + return new NextResponse(buffer, { + headers: { + 'Content-Type': mimeType, + 'Cache-Control': 'public, max-age=86400', + }, + }) + } catch { + // fall through to 404 + } + } + } + + return new NextResponse('Not found', { status: 404 }) + } catch (err) { + console.error('Tenant symbol image error:', err) + return new NextResponse('Internal error', { status: 500 }) + } +} diff --git a/src/components/admin/symbol-manager.tsx b/src/components/admin/symbol-manager.tsx index 506e96d..7c02b6d 100644 --- a/src/components/admin/symbol-manager.tsx +++ b/src/components/admin/symbol-manager.tsx @@ -1,9 +1,23 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' +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, @@ -17,136 +31,283 @@ import { Check, LayoutGrid, ImageIcon, - Info, + FolderOpen, + Download, + AlertCircle, + Package, } from 'lucide-react' -interface LibraryIcon { +/* ─── Types ─── */ +interface TenantCategory { id: string name: string - mimeType: string - iconType: string - categoryId: string - categoryName: string -} - -interface MySymbol { - id: string - iconId: string - name: string - customName: string | null - baseName: string - mimeType: string - iconType: string - categoryName: 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[] +} + +interface TemplatePackage { + packageId: string + packageName: string + categoryCount: number + symbolCount: number + previewSymbols: { name: string; svgPath: string }[] +} + +/* ─── Component ─── */ export function SymbolManager() { const { toast } = useToast() + + /* -- Data -- */ const [loading, setLoading] = useState(true) - const [library, setLibrary] = useState([]) - const [mySymbols, setMySymbols] = useState([]) + const [categories, setCategories] = useState([]) + const [symbolGroups, setSymbolGroups] = useState([]) + const [flatSymbols, setFlatSymbols] = useState([]) + const [templates, setTemplates] = useState([]) - // Library UI state - const [librarySearch, setLibrarySearch] = useState('') - const [expandedCategories, setExpandedCategories] = useState>(new Set()) - const [libraryCollapsed, setLibraryCollapsed] = useState(false) + /* -- UI state -- */ + const [search, setSearch] = useState('') + const [expandedCats, setExpandedCats] = useState>(new Set()) + const [activeTab, setActiveTab] = useState<'symbols' | 'categories' | 'import'>('symbols') - // My Symbols UI state - const [editingId, setEditingId] = useState(null) - const [editName, setEditName] = useState('') + /* -- 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) + + /* -- Import dialog -- */ + const [importOpen, setImportOpen] = useState(false) + const [importingPkg, setImportingPkg] = useState(null) + + /* ─── Fetch ─── */ const fetchData = useCallback(async () => { setLoading(true) try { - const res = await fetch('/api/tenant/symbols') - if (res.ok) { - const data = await res.json() - setLibrary(data.library || []) - setMySymbols(data.mySymbols || []) - // Auto-collapse library if tenant has own symbols - if ((data.mySymbols || []).length > 0) { - setLibraryCollapsed(true) - } + const [catRes, symRes, tplRes] = await Promise.all([ + fetch('/api/tenant/categories'), + fetch('/api/tenant/symbols?grouped=true'), + fetch('/api/templates'), + ]) + if (catRes.ok) { + const c = await catRes.json() + setCategories(c.categories || []) } - } catch {} + if (symRes.ok) { + const s = await symRes.json() + setSymbolGroups(s.groups || []) + setFlatSymbols(s.symbols || []) + } + if (tplRes.ok) { + const t = await tplRes.json() + setTemplates(t.packages || []) + } + } catch { + toast({ title: 'Fehler beim Laden', variant: 'destructive' }) + } setLoading(false) - }, []) + }, [toast]) useEffect(() => { fetchData() }, [fetchData]) - // Add symbol from library to my collection - const addSymbol = async (iconId: string, customName?: string) => { + /* ─── 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: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ iconId, customName }), - }) - if (res.ok) { - const symbol = await res.json() - setMySymbols(prev => [...prev, symbol]) - toast({ title: 'Symbol hinzugefügt' }) - } - } catch { - toast({ title: 'Fehler', variant: 'destructive' }) - } - } - - // Rename a symbol - const renameSymbol = async (id: string, customName: string) => { - try { - await fetch('/api/tenant/symbols', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id, customName }), + body: JSON.stringify({ id, ...payload }), }) - setMySymbols(prev => prev.map(s => - s.id === id ? { ...s, name: customName || s.baseName, customName: customName || null } : s - )) - setEditingId(null) - setEditName('') - toast({ title: 'Umbenannt' }) + if (!res.ok) throw new Error() + await fetchData() + toast({ title: 'Gespeichert' }) } catch { - toast({ title: 'Fehler', variant: 'destructive' }) + toast({ title: 'Fehler beim Speichern', variant: 'destructive' }) } } - // Remove a symbol - const removeSymbol = async (id: string) => { + const deleteSymbol = async (id: string) => { + if (!confirm('Symbol wirklich löschen?')) return try { - await fetch('/api/tenant/symbols', { + const res = await fetch('/api/tenant/symbols', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), }) - setMySymbols(prev => prev.filter(s => s.id !== id)) - toast({ title: 'Symbol entfernt' }) + 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' }) } } - // Toggle category expand/collapse - const toggleCategory = (cat: string) => { - setExpandedCategories(prev => { - const next = new Set(prev) - next.has(cat) ? next.delete(cat) : next.add(cat) - return next - }) + 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' }) + } } - // Group library icons by category - const filteredLibrary = library.filter(icon => - !librarySearch || icon.name.toLowerCase().includes(librarySearch.toLowerCase()) - ) - const libraryGrouped = filteredLibrary.reduce>((acc, icon) => { - const key = icon.categoryName - if (!acc[key]) acc[key] = [] - acc[key].push(icon) - return acc - }, {}) + 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` }) + } + + /* ─── Import ─── */ + const importPackage = async (packageId: string) => { + setImportingPkg(packageId) + try { + const res = await fetch('/api/templates/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ packageId }), + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) { + toast({ title: data.error || 'Import fehlgeschlagen', variant: 'destructive' }) + } else { + toast({ title: `${data.imported} Symbole importiert` }) + await fetchData() + setImportOpen(false) + setActiveTab('symbols') + } + } catch { + toast({ title: 'Import fehlgeschlagen', variant: 'destructive' }) + } + setImportingPkg(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 (
@@ -156,217 +317,483 @@ export function SymbolManager() { } return ( -
- {/* ===== MEINE SYMBOLE (always on top, prominent) ===== */} -
-
-

- - Meine Symbole - ({mySymbols.length}) -

+
+ {/* Tabs */} +
+
+ {([ + { key: 'symbols', label: 'Meine Symbole', icon: LayoutGrid }, + { key: 'categories', label: 'Kategorien', icon: FolderOpen }, + { key: 'import', label: 'Vorlagen importieren', icon: Download }, + ] as const).map(t => ( + + ))}
- {mySymbols.length === 0 ? ( -
- -

Noch keine eigenen Symbole definiert.

-

- Füge Symbole aus der Bibliothek unten hinzu oder lade eigene SVGs hoch. -

+
+ + +
+
+ + {/* ===== TAB: Meine Symbole ===== */} + {activeTab === 'symbols' && ( +
+ {/* Search */} +
+ + setSearch(e.target.value)} + className="pl-9" + />
- ) : ( -
-
- {mySymbols.map(sym => ( -
-
- {sym.name} -
- {/* Name / Edit */} - {editingId === sym.id ? ( -
- setEditName(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') renameSymbol(sym.id, editName) - if (e.key === 'Escape') { setEditingId(null); setEditName('') } - }} - className="h-6 text-[10px] px-1" - autoFocus - /> - - -
- ) : ( -

{ setEditingId(sym.id); setEditName(sym.name) }} - > - {sym.name} -

- )} + {/* Symbol grid grouped by category */} +
+ {filteredGroups.map(g => { + const catId = g.category?.id ?? '__none__' + const isExpanded = expandedCats.has(catId) + return ( +
+ - {/* Hover actions */} -
- - -
- - {/* Custom name badge */} - {sym.customName && ( -
-
+ {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)} + /> + ))} +
)}
- ))} -
-
- )} -
+ ) + })} - {/* ===== UPLOAD HINWEIS ===== */} -
- -
- Tipp: Eigene Symbole am besten als SVG hochladen — diese werden in jeder Grösse scharf dargestellt. - PNG/JPEG sind auch möglich, können aber bei Vergrösserung unscharf werden. -
-
- - {/* ===== STANDARD-BIBLIOTHEK (collapsible) ===== */} -
- - - {!libraryCollapsed && ( -
- {/* Search */} -
- - setLibrarySearch(e.target.value)} - className="pl-9" - /> -
- - {/* Categories */} - {Object.entries(libraryGrouped).sort(([a], [b]) => a.localeCompare(b)).map(([catName, icons]) => ( -
- - - - {expandedCategories.has(catName) && ( -
-
- {icons.map(icon => { - const alreadyAdded = mySymbols.some(s => s.iconId === icon.id) - return ( - - ) - })} -
-
- )} -
- ))} - - {Object.keys(libraryGrouped).length === 0 && ( -
- {librarySearch ? 'Keine Symbole gefunden.' : 'Keine Standard-Symbole vorhanden.'} + +
)}
- )} -
+
+ )} + + {/* ===== 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: Import ===== */} + {activeTab === 'import' && ( +
+ {templates.length === 0 ? ( +
+ +

Keine Vorlagen-Pakete verfügbar.

+
+ ) : ( +
+ {templates.map(pkg => ( +
+
+
+

{pkg.packageName}

+

+ {pkg.categoryCount} Kategorien · {pkg.symbolCount} Symbole +

+
+ +
+ + {/* Preview */} +
+ {pkg.previewSymbols.slice(0, 4).map((p, i) => ( +
+ {p.name} { (e.target as HTMLImageElement).style.display = 'none' }} + /> +
+ ))} +
+ + +
+ ))} +
+ )} +
+ )} + + {/* ===== 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... +
+ )} +
+
+
+ + {/* ===== IMPORT DIALOG ===== */} + + + + Aus Vorlagen importieren + + Wähle ein Vorlagen-Paket aus, das als Mandanten-Symbole importiert werden soll. + + + +
+ {templates.map(pkg => ( +
+
+ {pkg.previewSymbols.slice(0, 3).map((p, i) => ( +
+ { (e.target as HTMLImageElement).style.display = 'none' }} + /> +
+ ))} +
+
+

{pkg.packageName}

+

{pkg.symbolCount} Symbole · {pkg.categoryCount} Kategorien

+
+ +
+ ))} + + {templates.length === 0 && ( +
+ Keine Vorlagen verfügbar. +
+ )} +
+
+
+
+ ) +} + +/* ─── 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 && ( +
+
+
+ )}
) } diff --git a/src/components/layout/right-sidebar.tsx b/src/components/layout/right-sidebar.tsx index 2bba2a0..6abc162 100644 --- a/src/components/layout/right-sidebar.tsx +++ b/src/components/layout/right-sidebar.tsx @@ -9,7 +9,7 @@ import { Search, Flame, Droplets, AlertTriangle, Car, Users, Truck, Building, Target, Upload, Loader2, X, LayoutGrid, ChevronLeft, ChevronRight, Map, ClipboardList, PanelRightClose, PanelRightOpen, - Shield, Wrench, Radio, MoreHorizontal, Heart, + Shield, Wrench, Radio, MoreHorizontal, Heart, FolderOpen, } from 'lucide-react' interface DisplaySymbol { @@ -24,6 +24,12 @@ interface DisplayCategory { symbols: DisplaySymbol[] } +interface TenantSymbolGroup { + categoryId: string | null + categoryName: string + symbols: DisplaySymbol[] +} + interface RightSidebarProps { onSymbolDrop: (iconId: string, coordinates: [number, number], imageUrl?: string) => void canEdit: boolean @@ -99,10 +105,11 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa const [searchQuery, setSearchQuery] = useState('') const [activeCategory, setActiveCategory] = useState('') const [categories, setCategories] = useState([]) - const [tenantIcons, setTenantIcons] = useState([]) + const [tenantGroups, setTenantGroups] = useState([]) const [isLoading, setIsLoading] = useState(true) const [showTenantSection, setShowTenantSection] = useState(true) const [showLibrarySection, setShowLibrarySection] = useState(true) + const [expandedTenantCats, setExpandedTenantCats] = useState>(new Set()) useEffect(() => { async function fetchIcons() { @@ -111,6 +118,8 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa const res = await fetch('/api/icons', { cache: 'no-store' }) if (res.ok) { const data = await res.json() + + // ─── Global library ─── const allCats: DisplayCategory[] = (data.categories || []) .filter((cat: any) => cat.icons && cat.icons.length > 0) .map((cat: any) => ({ @@ -123,27 +132,41 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa })), })) - // Separate tenant-specific icons ("Eigene" category) from global library + // Separate tenant-specific legacy "Eigene" category from global library const eigene = allCats.find(c => c.name === 'Eigene') const globalCats = allCats.filter(c => c.name !== 'Eigene') - // Merge: mySymbols (custom collection) + legacy "Eigene" category uploads - const mySymbols: DisplaySymbol[] = (data.mySymbols || []).map((s: any) => ({ - id: s.id, - name: s.name, - imageUrl: s.url || `/api/icons/${s.id}/image`, - })) - const legacyOwn = eigene?.symbols || [] - // Deduplicate: mySymbols takes priority over legacy - const mySymbolIds = new Set(mySymbols.map(s => s.id)) - const mergedTenant = [...mySymbols, ...legacyOwn.filter(s => !mySymbolIds.has(s.id))] - - setTenantIcons(mergedTenant) setCategories(globalCats) - if (globalCats.length > 0) setActiveCategory(globalCats[0].id) + if (globalCats.length > 0 && !activeCategory) { + setActiveCategory(globalCats[0].id) + } - // Auto-collapse library if tenant has own symbols - if (mergedTenant.length > 0) { + // ─── New tenant symbol groups (Phase 1) ─── + const groups: TenantSymbolGroup[] = (data.tenantSymbolGroups || []).map((g: any) => ({ + categoryId: g.categoryId, + categoryName: g.categoryName, + symbols: g.symbols.map((s: any) => ({ + id: s.id, + name: s.name, + imageUrl: s.imageUrl || `/api/icons/${s.id}/image`, + })), + })) + + // Merge legacy "Eigene" into tenant groups if present + if (eigene && eigene.symbols.length > 0) { + const legacyGroup: TenantSymbolGroup = { + categoryId: '__legacy__', + categoryName: 'Eigene', + symbols: eigene.symbols, + } + groups.unshift(legacyGroup) + } + + setTenantGroups(groups) + + // Auto-expand all tenant groups, auto-collapse library if tenant has symbols + if (groups.length > 0 && groups.some(g => g.symbols.length > 0)) { + setExpandedTenantCats(new Set(groups.map(g => g.categoryId || '__none__'))) setShowLibrarySection(false) } } @@ -156,18 +179,31 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa fetchIcons() }, [tenantId]) + const toggleTenantCat = (catId: string | null) => { + const key = catId || '__none__' + setExpandedTenantCats(prev => { + const n = new Set(prev) + n.has(key) ? n.delete(key) : n.add(key) + return n + }) + } + const filteredCategories = categories.map((cat) => ({ ...cat, symbols: cat.symbols.filter((s) => s.name.toLowerCase().includes(searchQuery.toLowerCase()) ), })) - const filteredTenantIcons = tenantIcons.filter(s => - s.name.toLowerCase().includes(searchQuery.toLowerCase()) - ) + const filteredTenantGroups = tenantGroups.map(g => ({ + ...g, + symbols: g.symbols.filter(s => + s.name.toLowerCase().includes(searchQuery.toLowerCase()) + ), + })).filter(g => g.symbols.length > 0) const currentCategory = filteredCategories.find((c) => c.id === activeCategory) - const totalSymbols = categories.reduce((sum, c) => sum + c.symbols.length, 0) + tenantIcons.length + const totalSymbols = categories.reduce((sum, c) => sum + c.symbols.length, 0) + + tenantGroups.reduce((sum, g) => sum + g.symbols.length, 0) return ( <> @@ -298,7 +334,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa )} - {/* ─── Section 1: Meine Symbole (Tenant-specific) ─── */} + {/* ─── Section 1: Meine Symbole (Tenant Symbol Groups) ─── */} {tenantId && (
{showTenantSection && ( -
- {filteredTenantIcons.length === 0 ? ( +
+ {filteredTenantGroups.length === 0 ? (
Keine eigenen Symbole vorhanden.
- Symbole können unter Einstellungen → Symbole hochgeladen werden. + Symbole können unter Admin → Symbole verwaltet werden.
) : ( -
- {filteredTenantIcons.map((symbol) => ( - - ))} -
+ filteredTenantGroups.map(g => { + const key = g.categoryId || '__none__' + const expanded = expandedTenantCats.has(key) + return ( +
+ + {expanded && ( +
+
+ {g.symbols.map(symbol => ( + + ))} +
+
+ )} +
+ ) + }) )}
)}