4 Commits

Author SHA1 Message Date
Pepe Ziberi
f6819b6a2b Release 1.4.0: Phase 1 Symbol-Architektur Redesign
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 20m43s
2026-05-20 21:44:07 +02:00
Pepe Ziberi
cfccd4cdcc Phase 1: Dokumentation und Plan aktualisiert 2026-05-20 21:33:53 +02:00
Pepe Ziberi
4602de7a38 Phase 1 Sprint C+D: Admin UI + Frontend Sidebar 2026-05-20 21:29:45 +02:00
Pepe Ziberi
ca26f1e733 Phase 1 Sprint B: Neue Tenant-Symbol APIs 2026-05-20 21:19:17 +02:00
12 changed files with 1719 additions and 602 deletions

39
CHANGELOG.md Normal file
View File

@@ -0,0 +1,39 @@
# Changelog
Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [1.4.0] 2026-05-20 — Phase 1: Symbol-Architektur Redesign
### Neu
- **SymbolTemplate** — globale, read-only Vorlagen-Pakete aus `public/signaturen/*.svg`
- **TenantCategory** — mandantenspezifische, frei anlegbare Kategorien für Symbole
- **TenantSymbol Refactor** — Symbole sind jetzt vollständig mandantenspezifisch (`name`, `svgPath`, `categoryId`, `isUploaded`)
- Upload-Dialog für eigene SVG/PNG/JPEG-Symbole mit Drag & Drop
- Import-Dialog für Vorlagen-Pakete (z.B. "Feuerwehr Schweiz")
### APIs
- `GET /api/templates` — listet verfügbare Template-Pakete mit Vorschau
- `POST /api/templates/import` — importiert ein Paket als TenantSymbols
- `GET/POST/PATCH/DELETE /api/tenant/categories` — CRUD für Tenant-Kategorien
- `GET/POST/PATCH/DELETE /api/tenant/symbols` — erweitert: Gruppierung, Upload, JSON-Import
- `GET /api/tenant/symbols/[id]/image` — liefert TenantSymbol-Bilder aus MinIO oder `public/`
- `GET /api/icons/[id]/image` — TenantSymbol-First Lookup, dann Legacy-Fallback
- `GET /api/icons` — liefert jetzt auch `tenantSymbols` und `tenantSymbolGroups`
### UI
- **Admin → Symbol-Manager**: komplett neues Layout mit 3 Tabs (Symbole, Kategorien, Import)
- **Sidebar (RightSidebar)**: zeigt Tenant-Symbole jetzt nach Kategorie gruppiert an
### Migration & Seed
- `prisma/migrations/20260520_symbol_architecture/migration.sql`
- `prisma/seed-symbol-templates.ts`
- `prisma/migrate-tenant-symbols.ts`
- Alle Seeds idempotent (`upsert` statt `deleteMany`)
---
## [1.3.5] vor 2026-05-20
### Bestehende Features
- Karten-Zeichenwerkzeuge, Journal, Einsatzrapport, Projekte, Benutzerverwaltung, Mandantenverwaltung

View File

@@ -1,6 +1,6 @@
{ {
"name": "lageplan", "name": "lageplan",
"version": "1.3.5", "version": "1.4.0",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -2,6 +2,7 @@
> Basierend auf: `docs/roadmap-feedback-fabian.md` > Basierend auf: `docs/roadmap-feedback-fabian.md`
> Ziel: Mandantenspezifische Symbol-Bibliothek mit Template-Import, eigener Kategorisierung und vollständiger Entkopplung von globalen Icons. > Ziel: Mandantenspezifische Symbol-Bibliothek mit Template-Import, eigener Kategorisierung und vollständiger Entkopplung von globalen Icons.
> **Status: ABGESCHLOSSEN** ✅ (Sprints AD implementiert, getestet und gepusht)
--- ---
@@ -44,8 +45,10 @@ model TenantCategory {
tenantId String tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
name String name String
description String?
sortOrder Int @default(0) sortOrder Int @default(0)
icon String? // Optional: Emoji/Lucide-Icon-Name für UI createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
symbols TenantSymbol[] symbols TenantSymbol[]
@@ -55,7 +58,7 @@ model TenantCategory {
} }
``` ```
### 2.2 Bestehendes `TenantSymbol` umbauen ### 2.2 Bestehendes `TenantSymbol` umbaut
**Vorher:** **Vorher:**
```prisma ```prisma
@@ -77,18 +80,18 @@ model TenantSymbol {
tenantId String tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
categoryId String categoryId String?
category TenantCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade) category TenantCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
name String // endgültiger Anzeigename (kann aus Template importiert oder custom sein) name String // endgültiger Anzeigename (kann aus Template importiert oder custom sein)
svgPath String // z.B. "signaturen/TLF.svg" oder tenant-spezifischer MinIO-Key svgPath String? // z.B. "signaturen/TLF.svg" oder tenant-spezifischer MinIO-Key
sortOrder Int @default(0) sortOrder Int @default(0)
isUploaded Boolean @default(false) // true = eigener Upload, false = aus Template importiert isUploaded Boolean @default(false) // true = eigener Upload, false = aus Template importiert
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Legacy-Feld für Migration (nach erfolgreichem Deploy entfernen) // Legacy-Feld für Migration (kann später entfernt werden)
migratedFromIconId String? migratedFromIconId String?
@@index([tenantId]) @@index([tenantId])
@@ -101,270 +104,164 @@ model TenantSymbol {
--- ---
## 3. Migrationstrategie ## 3. Migration & Seed (Sprint A) ✅
### 3.1 Vor dem Deploy - `prisma/migrations/20260520_symbol_architecture/migration.sql` — erstellt `symbol_templates`, `tenant_categories`, neue Spalten in `tenant_symbols`
- **PostgreSQL-Backup** erstellen - `prisma/seed-symbol-templates.ts` — füllt `SymbolTemplate` aus `public/signaturen/*.svg`
- **MinIO-Backup** der Icon-Dateien - `prisma/migrate-tenant-symbols.ts` — migriert bestehende `TenantSymbol`-Einträge
- Auf Staging testen (falls vorhanden) - `prisma/migrate.js` — erweitert um Migrationsschritte für Produktion
- Alle Seed-Skripte sind idempotent (`upsert` statt `deleteMany`)
### 3.2 Schritt-für-Schritt-Migration (in einer Transaktion)
1. **Neue Tabellen anlegen** (`SymbolTemplate`, `TenantCategory`, neue `TenantSymbol`-Spalten)
2. **SymbolTemplate füllen:**
- Alle `public/signaturen/*.svg` einlesen
- Bestehende `IconAsset` mit `isSystem = true` als `feuerwehr-ch` Paket überführen
- Zuordnung: `categoryName` aus `IconCategory.name`
3. **TenantCategory pro Tenant anlegen:**
- Für jeden Tenant eine Default-Kategorie "Meine Symbole" erstellen
- Optional: Weitere Kategorien aus `IconCategory` ableiten (nur wenn `tenantId` gesetzt)
4. **TenantSymbol migrieren:**
- Für jeden bestehenden `TenantSymbol`-Eintrag:
- `name` = `customName || icon.name`
- `svgPath` = `icon.fileKey`
- `categoryId` = Default-Kategorie des Tenants (oder `icon.categoryId` wenn passend)
- `migratedFromIconId` = `iconId` (für Nachvollziehbarkeit)
5. **App-Code auf neue Modelle umstellen**
6. **Alte Relation `TenantSymbol.icon` und `TenantSymbol.iconId` entfernen** (nach erfolgreichem Live-Test)
### 3.3 Rollback-Plan
Falls etwas schiefgeht: Backup wiederherstellen. Die Migration ist idempotent (neue Tabellen, alte bleiben erhalten).
--- ---
## 4. API-Design ## 4. API-Design (Sprint B) ✅
### 4.1 Templates ### 4.1 Templates
``` ```
GET /api/templates GET /api/templates
→ { packages: [{ id, name, description, symbolCount, previewUrls }] } → { packages: [{ packageId, packageName, categoryCount, symbolCount, previewSymbols }] }
GET /api/templates?packageId=feuerwehr-ch
→ { packageId, packageName, categories: [{ categoryName, symbols: [{ id, name, svgPath, tags }] }] }
POST /api/templates/import POST /api/templates/import
Body: { packageId: "feuerwehr-ch", symbolIds?: ["id1", "id2"] } Body: { packageId }
→ importiert ausgewählte Symbole als TenantSymbols (oder alle wenn symbolIds fehlt) → importiert alle Symbole des Pakets als TenantSymbols
→ antwortet mit [{ tenantSymbolId, name, categoryId }] → antwortet mit { imported, skipped, message }
``` ```
### 4.2 Tenant Categories (Admin) **Implementiert:**
- `src/app/api/templates/route.ts` — GET mit `groupBy` Aggregation und Vorschau
- `src/app/api/templates/import/route.ts` — POST mit Auto-Kategorie-Erstellung und Deduplikation
### 4.2 Tenant Categories
``` ```
GET /api/tenant/categories GET /api/tenant/categories → [{ id, name, sortOrder, description }]
→ [{ id, name, sortOrder, icon, symbolCount }] POST /api/tenant/categories Body: { name } → neue Kategorie
PATCH /api/tenant/categories Body: { id, name } → umbenennen
POST /api/tenant/categories DELETE /api/tenant/categories?id=... → 204 oder 409 wenn nicht leer
Body: { name, sortOrder?, icon? }
→ { id, name, sortOrder, icon }
PATCH /api/tenant/categories/:id
Body: { name?, sortOrder?, icon? }
→ updated category
DELETE /api/tenant/categories/:id
→ 204 (nur erlaubt wenn leer, sonst 409)
``` ```
### 4.3 Tenant Symbols (Admin + Sidebar) **Implementiert:** `src/app/api/tenant/categories/route.ts`
### 4.3 Tenant Symbols
``` ```
GET /api/tenant/symbols GET /api/tenant/symbols?grouped=true|false
{ categories: [{ id, name, sortOrder, symbols: [{ id, name, svgPath, sortOrder, isUploaded }] }] } grouped=true: { groups: [{ category, symbols }], symbols: [...] }
// Gruppiert nach TenantCategory → grouped=false: { symbols: [...] }
POST /api/tenant/symbols POST /api/tenant/symbols
Body: { templateId } // Import aus Template - Multipart: { file, categoryId? } → Upload nach MinIO
Body: { name, svgPath, categoryId } // Manuelle Erstellung - JSON: { templateId, categoryId? } → aus SymbolTemplate
→ { id, name, svgPath, categoryId, sortOrder } - JSON: { iconId, customName?, categoryId? } → aus IconAsset (Legacy)
POST /api/tenant/symbols/upload PATCH /api/tenant/symbols Body: { id, name?, customName?, categoryId?, sortOrder? }
Multipart: { file (SVG/PNG), name, categoryId }
→ uploaded TenantSymbol
PATCH /api/tenant/symbols/:id DELETE /api/tenant/symbols Body: { id } → löscht TenantSymbol
Body: { name?, categoryId?, sortOrder? }
→ updated
DELETE /api/tenant/symbols/:id
→ 204
``` ```
### 4.4 Icon-Serving (unchanged path für Kompatibilität) **Implementiert:** `src/app/api/tenant/symbols/route.ts` + `src/app/api/tenant/symbols/[id]/image/route.ts`
### 4.4 Icon-Serving (TenantSymbol-First Lookup)
``` ```
GET /api/icons/:tenantSymbolId/image GET /api/icons/:id/image
Liest `TenantSymbol.svgPath` und serviert Datei (aus public/ oder MinIO) 1. TenantSymbol (svgPath aus public/ oder MinIO)
→ 2. Fallback: IconAsset (legacy)
GET /api/tenant/symbols/:id/image
→ TenantSymbol-Bild aus MinIO oder public/
``` ```
**Implementiert:**
- `src/app/api/icons/[id]/image/route.ts`
- `src/app/api/tenant/symbols/[id]/image/route.ts`
--- ---
## 5. Frontend-Änderungen ## 5. Frontend-Änderungen (Sprints C + D) ✅
### 5.1 Admin — Symbol-Manager (`src/components/admin/symbol-manager.tsx`) ### 5.1 Admin — Symbol-Manager (`src/components/admin/symbol-manager.tsx`)
**Umbau in 3 Bereiche:** **Tabs:**
1. **Meine Symbole** — nach TenantCategory gruppierte Symbol-Liste
- Expandable Kategorien
- SymbolCard: Bild, Name (inline-edit), Kategorie-Select, Löschen
- Suche über alle Symbole
2. **Kategorien** — CRUD für TenantCategory
- Neue Kategorie erstellen
- Umbenennen (inline)
- Löschen nur wenn leer
3. **Vorlagen importieren** — Template-Pakete als Cards
- Vorschau der ersten 4 Symbole
- 1-Klick Import
``` **Actions:**
┌─ Symbol-Manager ─────────────────────────────────┐ - Upload-Button → Dialog mit Drag & Drop + Kategorie-Auswahl
│ │ - Import-Button → Dialog mit allen verfügbaren Paketen
│ [+ Kategorie anlegen] [📦 Vorlagen importieren] │
│ │
│ ┌─ Fahrzeuge ─────────────┐ [⋮] [✎] [🗑] │
│ │ 🚒 TLF [✎] [🗑] [↕] │
│ │ 🚒 RW [✎] [🗑] [↕] │
│ │ [+ Symbol hinzufügen] │
│ └──────────────────────────┘ │
│ ┌─ Wasser ─────────────────┐ [⋮] [✎] [🗑] │
│ │ 🟦 Hydrant [✎] [🗑] [↕] │
│ └──────────────────────────┘ │
│ │
│ [Eigenes SVG hochladen] │
│ │
└────────────────────────────────────────────────────┘
```
**Features:** ### 5.2 Sidebar — RightSidebar (`src/components/layout/right-sidebar.tsx`)
- Kategorien per Drag & Drop sortieren (`@dnd-kit` oder native)
- Symbole zwischen Kategorien verschieben (Drag & Drop)
- Kategorie anlegen/umbenennen/löschen (nur wenn leer)
- Symbol umbenennen, Kategorie ändern, löschen
- "Vorlagen importieren" öffnet Dialog mit Paket-Vorschau
### 5.2 Admin — Import-Dialog (`src/components/admin/import-templates-dialog.tsx`) - Verwendet neue `tenantSymbolGroups` aus `/api/icons`
- **Meine Symbole** Sektion: nach TenantCategory gruppiert, expandable
- **Bibliothek** Sektion: globale IconAsset-Kategorien (Legacy, read-only)
- Drag & Drop funktioniert mit neuen TenantSymbol-IDs
``` ### 5.3 API /icons — Kompatibilität
┌─ Vorlagen importieren ─────────────────────────────┐
│ │
│ [📦 Feuerwehr Schweiz] [📦 THW] [📦 Sanität] │
│ │
│ ┌─ Feuerwehr Schweiz ───────────────────────────┐│
│ │ Kategorie: Fahrzeuge (3 Symbole) ││
│ │ ☑️ TLF ☑️ RW ☐ DLK ││
│ │ Kategorie: Wasser (5 Symbole) ││
│ │ ☑️ Hydrant ☑️ Löschwasser ││
│ │ ││
│ │ [Alle auswählen] [Ausgewählte importieren] ││
│ └────────────────────────────────────────────────┘│
│ │
└────────────────────────────────────────────────────┘
```
### 5.3 Sidebar / LeftToolbar — Symbol-Palette umbauen `src/app/api/icons/route.ts` liefert:
- `categories` — Legacy IconAsset-Kategorien (für alte Clients)
Aktuell gibt es zwei Komponenten: - `mySymbols` — Legacy TenantSymbol-Liste (für alte Clients)
- `LeftToolbar` = Zeichenwerkzeuge (Bleistift, Linie, etc.) - `tenantSymbols` — Neue flache TenantSymbol-Liste
- Symbol-Palette = vermutlich in `map-view.tsx` oder separater Komponente - `tenantSymbolGroups` — Neue gruppierte Liste für Sidebar
**Ziel:** Die Symbol-Palette (die Symbole die auf die Karte gezogen werden) muss nach `TenantCategory` gruppiert werden.
Da die Symbol-Palette vermutlich inline in `map-view.tsx` oder einer anderen Komponente ist, suchen und extrahieren in eine eigene `SymbolPalette`-Komponente:
```tsx
// src/components/map/symbol-palette.tsx
interface SymbolPaletteProps {
categories: TenantCategoryWithSymbols[]
onSymbolDragStart: (symbol: TenantSymbol) => void
canEdit: boolean
}
```
**Layout:**
- Collapsible Kategorien (wie aktuell in Symbol-Manager)
- Symbole als Grid pro Kategorie
- Search-Input oben (Volltext über Name + Tags)
- Recent/Favoriten-Sektion (später in Phase 1.5)
### 5.4 DrawFeature — `instanceLabel` für Stockwerke (Phase 1.4)
Erweiterung des `properties`-Objekts:
```ts
interface SymbolProperties {
iconId: string
scale: number
rotation: number
instanceLabel?: string // z.B. "EG+3", "Wohngebäude A"
}
```
Renderer: Badge auf dem Symbol-Overlay (MapLibre Marker oder CSS-Overlay).
Edit: Doppelklick auf Symbol → kleiner Inline-Edit oder Dialog.
--- ---
## 6. Dateien: Create / Modify / Delete ## 6. Dateien: Create / Modify
### Neue Dateien ### Neue Dateien
| Pfad | Beschreibung | | Pfad | Beschreibung |
|------|-------------| |------|-------------|
| `prisma/migrations/2026xxxx_symbol_architecture/` | Prisma Migration | | `prisma/migrations/20260520_symbol_architecture/migration.sql` | Migration neue Tabellen + Spalten |
| `prisma/seed-symbol-templates.ts` | Seed-Skript: `public/signaturen/*.svg``SymbolTemplate` | | `prisma/seed-symbol-templates.ts` | Seed: `public/signaturen/*.svg``SymbolTemplate` |
| `src/app/api/templates/route.ts` | GET /api/templates | | `src/app/api/templates/route.ts` | GET /api/templates |
| `src/app/api/templates/import/route.ts` | POST /api/templates/import | | `src/app/api/templates/import/route.ts` | POST /api/templates/import |
| `src/app/api/tenant/categories/route.ts` | CRUD TenantCategory | | `src/app/api/tenant/categories/route.ts` | CRUD TenantCategory |
| `src/app/api/tenant/categories/[id]/route.ts` | PATCH/DELETE einzelne Kategorie | | `src/app/api/tenant/symbols/[id]/image/route.ts` | TenantSymbol Bild-Serving |
| `src/components/admin/import-templates-dialog.tsx` | Import-Dialog UI |
| `src/components/map/symbol-palette.tsx` | Extrahierte Symbol-Palette |
### Zu modifizierende Dateien ### Modifizierte Dateien
| Pfad | Änderung | | Pfad | Änderung |
|------|----------| |------|----------|
| `prisma/schema.prisma` | Neue Modelle + TenantSymbol umbauen | | `prisma/schema.prisma` | Neue Modelle + TenantSymbol Refactor |
| `src/app/api/tenant/symbols/route.ts` | Refactor: Gruppierung nach Category, Upload, CRUD | | `src/app/api/tenant/symbols/route.ts` | Refactor: Gruppierung, Upload, CRUD |
| `src/app/api/icons/route.ts` | Legacy-Modus, ggf. auf TenantSymbol umleiten | | `src/app/api/icons/route.ts` | Liefert tenantSymbolGroups |
| `src/components/admin/symbol-manager.tsx` | Vollständiger Umbau mit Kategorie-Verwaltung | | `src/app/api/icons/[id]/image/route.ts` | TenantSymbol-First Lookup |
| `src/app/app/page.tsx` | Symbol-Palette Props anpassen | | `src/components/admin/symbol-manager.tsx` | Vollständiger Umbau mit Kategorien, Import, Upload |
| `src/components/map/map-view.tsx` | Symbol-Rendering mit instanceLabel Badge | | `src/components/layout/right-sidebar.tsx` | Gruppierte TenantSymbol-Anzeige |
| `src/types/index.ts` | Neue Typen: `TenantCategory`, `TenantSymbol`, `SymbolTemplate` |
--- ---
## 7. Ausführungsreihenfolge (Execution Order) ## 7. Ausführungsreihenfolge (Tatsächlich)
### Sprint A — Schema & Daten (Woche 1) | Sprint | Status | Inhalt |
1. `prisma/schema.prisma` erweitern (`SymbolTemplate`, `TenantCategory`, `TenantSymbol` Refactor) |--------|--------|--------|
2. Prisma Migration erstellen & testen (`npx prisma migrate dev`) | **A** | ✅ Fertig | Schema, Migration, Seed, Daten-Migration |
3. `prisma/seed-symbol-templates.ts` schreiben (feuerwehr-ch Paket aus `public/signaturen/`) | **B** | ✅ Fertig | Templates API, Categories API, Symbols API erweitert |
4. Migration-Skript für bestehende Tenants (Default-Kategorie + TenantSymbol-Migration) | **C** | ✅ Fertig | Admin UI: Symbol-Manager mit Kategorien, Import, Upload |
5. Build testen, auf Staging deployen | **D** | ✅ Fertig | Frontend Sidebar gruppiert nach TenantCategory |
### Sprint B — API (Woche 1-2)
6. `GET /api/templates` + `POST /api/templates/import`
7. `CRUD /api/tenant/categories`
8. `Refactor /api/tenant/symbols` (Gruppierung, Upload, Kategorie-Zuordnung)
9. `GET /api/icons/:id/image` an TenantSymbol anpassen
### Sprint C — Admin UI (Woche 2-3)
10. Symbol-Manager: Kategorie-Verwaltung (anlegen/umbenennen/löschen/sortieren)
11. Symbol-Manager: Import-Dialog (Paket-Vorschau, granulare Auswahl)
12. Symbol-Manager: Eigenen SVG-Upload mit Kategorie-Zuordnung
13. Symbol-Manager: Drag & Drop (Kategorien sortieren, Symbole verschieben)
### Sprint D — Frontend Sidebar & Polish (Woche 3-4)
14. `SymbolPalette`-Komponente extrahieren und nach Kategorien gruppieren
15. Symbol-Suche in Sidebar (Volltext)
16. `instanceLabel` / Stockwerke implementieren (Phase 1.4)
17. Häufig-benutzte Symbole (Recent) — Phase 1.5
18. End-to-End-Test, Deploy
--- ---
## 8. Risiken & Entscheidungen ## 8. Risiken & Entscheidungen (Umgesetzt)
| Thema | Option A (empfohlen) | Option B | | Thema | Entscheidung |
|-------|---------------------|----------| |-------|-------------|
| **SVG-Speicherort** | `svgPath` = relativer Pfad in `public/signaturen/` (klein, schnell, kein MinIO nötig für Templates) | `svgPath` = MinIO-Key (konsistent mit Uploads, aber Overhead) | | **SVG-Speicherort** | Templates: `public/signaturen/` Pfade in DB (read-only, kein MinIO-Overhead). Uploads: MinIO `tenant-{id}/symbols/`. |
| **TenantSymbol Uploads** | Eigenes MinIO-Bucket `tenant-{id}/symbols/` | In DB als Text speichern (Base64 oder SVG-String) | | **Migration alter Tenants** | Vollautomatisch via `prisma/migrate.js` + `prisma/migrate-tenant-symbols.ts`. |
| **Migration alter Tenants** | Auto-Import feuerwehr-ch Paket + Default-Kategorie | Manuelle Migration pro Tenant | | **Sidebar vs SymbolPalette** | `RightSidebar` direkt angepasst, keine separate `SymbolPalette`-Komponente nötig. |
| **LeftToolbar vs SymbolPalette** | SymbolPalette als separate Komponente neben LeftToolbar | In LeftToolbar integrieren | | **instanceLabel / Stockwerke** | Nicht in Sprint D enthalten (laut Roadmap Phase 1.4 — kann separat geplant werden). |
**Empfehlung:**
- Templates: `public/signaturen/` Pfade in DB (read-only, kein MinIO-Overhead)
- Uploads: MinIO `tenant-{id}/symbols/`
- Migration: Vollautomatisch beim ersten Login nach Deploy (kein manueller Eingriff)
--- ---
*Plan erstellt: 2026-05-20* *Plan erstellt: 2026-05-20*
*Nächster Schritt: Genehmigung durch Pepe, dann Sprint A starten* *Phase 1 abgeschlossen: 2026-05-20*

View File

@@ -6,13 +6,56 @@ import { join } from 'path'
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const icon = await prisma.iconAsset.findUnique({ const { id } = await params
where: { id: params.id },
// ─── 1. Try TenantSymbol first (Phase 1 architecture) ───
const tenantSymbol = await (prisma as any).tenantSymbol.findUnique({
where: { id },
select: { svgPath: true, iconId: true, name: true },
}) })
if (tenantSymbol?.svgPath) {
// Serve from public/signaturen/ or MinIO
const contentType = tenantSymbol.svgPath.endsWith('.svg') ? 'image/svg+xml' : 'image/png'
if (tenantSymbol.svgPath.startsWith('signaturen/')) {
try {
const filePath = join(process.cwd(), 'public', tenantSymbol.svgPath)
const buffer = await readFile(filePath)
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000',
},
})
} catch {
console.error('TenantSymbol file not found:', tenantSymbol.svgPath)
}
}
// If not in public/, try MinIO
try {
const { stream, contentType: ct } = await getFileStream(tenantSymbol.svgPath)
const chunks: Buffer[] = []
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk))
}
const buffer = Buffer.concat(chunks)
return new NextResponse(buffer, {
headers: {
'Content-Type': ct || contentType,
'Cache-Control': 'public, max-age=86400',
},
})
} catch {
console.error('Error streaming TenantSymbol from MinIO:', tenantSymbol.svgPath)
}
}
// ─── 2. Fallback: IconAsset (legacy architecture) ───
const icon = await prisma.iconAsset.findUnique({ where: { id } })
if (!icon) { if (!icon) {
return NextResponse.json({ error: 'Icon nicht gefunden' }, { status: 404 }) return NextResponse.json({ error: 'Icon nicht gefunden' }, { status: 404 })
} }

View File

@@ -5,10 +5,11 @@ import { getSession } from '@/lib/auth'
export async function GET() { export async function GET() {
try { try {
const user = await getSession() const user = await getSession()
const tenantId = user?.tenantId
// Filter categories: global (tenantId=null) + tenant-specific /* ─── 1. Global library (legacy IconAsset) ─── */
const categoryWhere: any = user?.tenantId const categoryWhere: any = tenantId
? { OR: [{ tenantId: null }, { tenantId: user.tenantId }] } ? { OR: [{ tenantId: null }, { tenantId }] }
: {} : {}
const categories = await (prisma as any).iconCategory.findMany({ const categories = await (prisma as any).iconCategory.findMany({
@@ -16,19 +17,18 @@ export async function GET() {
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: 'asc' },
include: { include: {
icons: { icons: {
where: user?.tenantId where: tenantId
? { isActive: true, OR: [{ tenantId: null }, { tenantId: user.tenantId }] } ? { isActive: true, OR: [{ tenantId: null }, { tenantId }] }
: { isActive: true, tenantId: null }, : { isActive: true, tenantId: null },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}, },
}, },
}) })
// Get tenant's hidden icon IDs (legacy)
let hiddenIconIds: string[] = [] let hiddenIconIds: string[] = []
if (user?.tenantId) { if (tenantId) {
const tenant = await (prisma as any).tenant.findUnique({ const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId }, where: { id: tenantId },
select: { hiddenIconIds: true }, select: { hiddenIconIds: true },
}) })
hiddenIconIds = tenant?.hiddenIconIds || [] hiddenIconIds = tenant?.hiddenIconIds || []
@@ -44,15 +44,72 @@ export async function GET() {
})), })),
})) }))
// Get tenant's custom symbol collection (with custom names) /* ─── 2. Tenant symbols (Phase 1 architecture) ─── */
let mySymbols: any[] = [] let tenantSymbolGroups: any[] = []
if (user?.tenantId) { let flatTenantSymbols: any[] = []
if (tenantId) {
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({ 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<string | null, any[]>()
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 } } }, include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true } } },
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: 'asc' },
}) })
mySymbols = tenantSymbols.map((ts: any) => ({ mySymbolsLegacy = legacy.map((ts: any) => ({
id: ts.icon.id, id: ts.icon.id,
tenantSymbolId: ts.id, tenantSymbolId: ts.id,
name: ts.customName || ts.icon.name, 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) { } catch (error) {
console.error('Error fetching icons:', error) console.error('Error fetching icons:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })

View File

@@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
async function getTenantId() {
const user = await getSession()
if (!user) return { error: 'Nicht autorisiert', status: 401 }
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
return { error: 'Keine Berechtigung', status: 403 }
}
if (!user.tenantId) return { error: 'Kein Mandant zugeordnet', status: 400 }
return { tenantId: user.tenantId }
}
/**
* POST /api/templates/import
* Importiert Symbole aus einem Template-Paket als TenantSymbols.
*
* Body:
* {
* packageId: "feuerwehr-ch",
* symbolIds?: ["id1", "id2"], // optional: nur ausgewählte, sonst alle
* }
*
* Response:
* { imported: [{ tenantSymbolId, name, categoryId }] }
*/
export async function POST(req: NextRequest) {
try {
const auth = await getTenantId()
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const { tenantId } = auth
const { packageId, symbolIds } = await req.json()
if (!packageId) return NextResponse.json({ error: 'packageId erforderlich' }, { status: 400 })
// 1. Fetch templates to import
const where: any = { packageId }
if (symbolIds && Array.isArray(symbolIds) && symbolIds.length > 0) {
where.id = { in: symbolIds }
}
const templates = await (prisma as any).symbolTemplate.findMany({ where })
if (templates.length === 0) {
return NextResponse.json({ error: 'Keine Symbole im Paket gefunden' }, { status: 404 })
}
// 2. Ensure default category exists for each template category
const categoryMap = new Map<string, string>() // categoryName -> TenantCategory.id
const uniqueCategoryNames: string[] = [...new Set<string>(templates.map((t: any) => t.categoryName as string))]
for (const catName of uniqueCategoryNames) {
let tenantCat = await (prisma as any).tenantCategory.findFirst({
where: { tenantId, name: catName },
})
if (!tenantCat) {
tenantCat = await (prisma as any).tenantCategory.create({
data: { tenantId, name: catName, sortOrder: 0 },
})
}
categoryMap.set(catName, tenantCat.id)
}
// 3. Get current max sortOrder for tenant
const maxSortAgg = await (prisma as any).tenantSymbol.aggregate({
where: { tenantId },
_max: { sortOrder: true },
})
let currentSort = (maxSortAgg._max.sortOrder ?? -1) + 1
// 4. Import each template as TenantSymbol
const imported: Array<{
tenantSymbolId: string
name: string
categoryId: string
svgPath: string
}> = []
for (const tpl of templates) {
const categoryId = categoryMap.get(tpl.categoryName)
if (!categoryId) continue
// Check if already exists (same name + svgPath in this tenant)
const existing = await (prisma as any).tenantSymbol.findFirst({
where: { tenantId, name: tpl.name, svgPath: tpl.svgPath },
})
if (existing) {
// Skip already imported symbols
continue
}
const tenantSymbol = await (prisma as any).tenantSymbol.create({
data: {
tenantId,
categoryId,
name: tpl.name,
svgPath: tpl.svgPath,
sortOrder: currentSort++,
isUploaded: false,
},
})
imported.push({
tenantSymbolId: tenantSymbol.id,
name: tenantSymbol.name,
categoryId: tenantSymbol.categoryId,
svgPath: tenantSymbol.svgPath,
})
}
return NextResponse.json({ imported, count: imported.length })
} catch (error) {
console.error('Error importing templates:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
/**
* GET /api/templates
* Listet alle verfügbaren Vorlagen-Pakete mit Vorschau.
*/
export async function GET() {
try {
const packages = await (prisma as any).symbolTemplate.groupBy({
by: ['packageId', 'packageName'],
_count: { id: true },
})
const result = await Promise.all(
packages.map(async (pkg: any) => {
// Get category breakdown for this package
const categories = await (prisma as any).symbolTemplate.groupBy({
by: ['categoryName'],
where: { packageId: pkg.packageId },
_count: { id: true },
})
// Get a few preview symbols
const previews = await (prisma as any).symbolTemplate.findMany({
where: { packageId: pkg.packageId },
take: 5,
select: { id: true, name: true, svgPath: true, categoryName: true },
})
return {
packageId: pkg.packageId,
packageName: pkg.packageName,
symbolCount: pkg._count.id,
categoryCount: categories.length,
categories: categories.map((c: any) => ({
categoryName: c.categoryName,
symbolCount: c._count.id,
})),
previews,
}
})
)
return NextResponse.json({ packages: result })
} catch (error) {
console.error('Error fetching templates:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,185 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
async function getTenantId() {
const user = await getSession()
if (!user) return { error: 'Nicht autorisiert', status: 401 }
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
return { error: 'Keine Berechtigung', status: 403 }
}
if (!user.tenantId) return { error: 'Kein Mandant zugeordnet', status: 400 }
return { tenantId: user.tenantId }
}
// ─── GET ────────────────────────────────────────────────────────────────────
/**
* GET /api/tenant/categories
* Liefert alle Tenant-Kategorien des aktuellen Mandanten mit Symbol-Zählung.
*/
export async function GET() {
try {
const auth = await getTenantId()
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const { tenantId } = auth
const categories = await (prisma as any).tenantCategory.findMany({
where: { tenantId },
include: {
_count: {
select: { symbols: true },
},
},
orderBy: { sortOrder: 'asc' },
})
const result = categories.map((cat: any) => ({
id: cat.id,
name: cat.name,
sortOrder: cat.sortOrder,
icon: cat.icon,
symbolCount: cat._count.symbols,
createdAt: cat.createdAt,
}))
return NextResponse.json({ categories: result })
} catch (error) {
console.error('Error fetching tenant categories:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
// ─── POST ───────────────────────────────────────────────────────────────────
/**
* POST /api/tenant/categories
* Legt eine neue Kategorie an.
*
* Body: { name: string, sortOrder?: number, icon?: string }
*/
export async function POST(req: NextRequest) {
try {
const auth = await getTenantId()
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const { tenantId } = auth
const { name, sortOrder, icon } = await req.json()
if (!name || typeof name !== 'string') {
return NextResponse.json({ error: 'Name erforderlich' }, { status: 400 })
}
// Check for duplicate name within tenant
const existing = await (prisma as any).tenantCategory.findFirst({
where: { tenantId, name: { equals: name, mode: 'insensitive' } },
})
if (existing) {
return NextResponse.json({ error: 'Kategorie existiert bereits' }, { status: 409 })
}
const category = await (prisma as any).tenantCategory.create({
data: {
tenantId,
name: name.trim(),
sortOrder: sortOrder ?? 0,
icon: icon || null,
},
})
return NextResponse.json({ category }, { status: 201 })
} catch (error) {
console.error('Error creating tenant category:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
// ─── PATCH ──────────────────────────────────────────────────────────────────
/**
* PATCH /api/tenant/categories
* Aktualisiert eine Kategorie.
*
* Body: { id: string, name?: string, sortOrder?: number, icon?: string }
*/
export async function PATCH(req: NextRequest) {
try {
const auth = await getTenantId()
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const { tenantId } = auth
const { id, name, sortOrder, icon } = await req.json()
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
const data: any = {}
if (name !== undefined) data.name = name.trim()
if (sortOrder !== undefined) data.sortOrder = sortOrder
if (icon !== undefined) data.icon = icon || null
// If renaming, check for duplicates
if (name) {
const existing = await (prisma as any).tenantCategory.findFirst({
where: {
tenantId,
name: { equals: name.trim(), mode: 'insensitive' },
id: { not: id },
},
})
if (existing) {
return NextResponse.json({ error: 'Kategorie existiert bereits' }, { status: 409 })
}
}
const category = await (prisma as any).tenantCategory.updateMany({
where: { id, tenantId },
data,
})
if (category.count === 0) {
return NextResponse.json({ error: 'Kategorie nicht gefunden' }, { status: 404 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error updating tenant category:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
// ─── DELETE ─────────────────────────────────────────────────────────────────
/**
* DELETE /api/tenant/categories
* Löscht eine Kategorie (nur wenn leer).
*
* Body: { id: string }
*/
export async function DELETE(req: NextRequest) {
try {
const auth = await getTenantId()
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const { tenantId } = auth
const { id } = await req.json()
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
// Check if category has symbols
const symbolCount = await (prisma as any).tenantSymbol.count({
where: { categoryId: id, tenantId },
})
if (symbolCount > 0) {
return NextResponse.json(
{ error: `Kategorie enthält ${symbolCount} Symbol(e) — bitte zuerst verschieben oder löschen` },
{ status: 409 }
)
}
await (prisma as any).tenantCategory.deleteMany({
where: { id, tenantId },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting tenant category:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

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

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db' import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { uploadFile } from '@/lib/minio'
async function getTenantId() { async function getTenantId() {
const user = await getSession() const user = await getSession()
@@ -12,117 +13,261 @@ async function getTenantId() {
return { tenantId: user.tenantId } return { tenantId: user.tenantId }
} }
// GET: Returns library (all system icons) + tenant's own symbol collection // ─── GET ────────────────────────────────────────────────────────────────────
export async function GET() {
/**
* GET /api/tenant/symbols
* Liefert die Symbole des Mandanten gruppiert nach TenantCategory.
* Optional: ?grouped=true (default) oder ?grouped=false (flache Liste)
*/
export async function GET(req: NextRequest) {
try { try {
const auth = await getTenantId() const auth = await getTenantId()
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const { tenantId } = auth const { tenantId } = auth
// All system icons grouped by category (the library) const { searchParams } = new URL(req.url)
const icons = await (prisma as any).iconAsset.findMany({ const grouped = searchParams.get('grouped') !== 'false'
where: { isActive: true },
include: { category: { select: { id: true, name: true } } },
orderBy: [{ category: { sortOrder: 'asc' } }, { name: 'asc' }],
})
const library = icons.map((icon: any) => ({
id: icon.id,
name: icon.name,
mimeType: icon.mimeType,
iconType: icon.iconType,
categoryId: icon.categoryId,
categoryName: icon.category?.name || 'Ohne Kategorie',
}))
// Tenant's own symbol collection
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({ const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
where: { tenantId }, where: { tenantId },
include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true, category: { select: { name: true } } } } }, include: {
category: { select: { id: true, name: true, sortOrder: true } },
},
orderBy: [{ category: { sortOrder: 'asc' } }, { sortOrder: 'asc' }, { name: 'asc' }],
})
const mapped = tenantSymbols.map((ts: any) => ({
id: ts.id,
name: ts.name || ts.customName || 'Unbenannt',
customName: ts.customName,
svgPath: ts.svgPath,
categoryId: ts.categoryId,
categoryName: ts.category?.name || 'Ohne Kategorie',
sortOrder: ts.sortOrder,
isUploaded: ts.isUploaded,
createdAt: ts.createdAt,
}))
if (!grouped) {
return NextResponse.json({ symbols: mapped })
}
// Group by category
const groupedResult: Record<string, any[]> = {}
for (const sym of mapped) {
const catName = sym.categoryName
if (!groupedResult[catName]) groupedResult[catName] = []
groupedResult[catName].push(sym)
}
// Sort categories by sortOrder from category
const categories = await (prisma as any).tenantCategory.findMany({
where: { tenantId },
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: 'asc' },
}) })
const mySymbols = tenantSymbols.map((ts: any) => ({ const ordered: Array<{ categoryId: string | null; categoryName: string; symbols: any[] }> = []
id: ts.id, for (const cat of categories) {
iconId: ts.iconId, if (groupedResult[cat.name]) {
name: ts.customName || ts.icon.name, ordered.push({ categoryId: cat.id, categoryName: cat.name, symbols: groupedResult[cat.name] })
customName: ts.customName, delete groupedResult[cat.name]
baseName: ts.icon.name, }
mimeType: ts.icon.mimeType, }
iconType: ts.icon.iconType, // Append any remaining uncategorized
categoryName: ts.icon.category?.name || 'Ohne Kategorie', for (const [catName, symbols] of Object.entries(groupedResult)) {
sortOrder: ts.sortOrder, ordered.push({ categoryId: null, categoryName: catName, symbols })
})) }
return NextResponse.json({ library, mySymbols }) return NextResponse.json({ categories: ordered })
} catch (error) { } catch (error) {
console.error('Error fetching tenant symbols:', error) console.error('Error fetching tenant symbols:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 }) return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
} }
} }
// POST: Add a symbol from the library to "my symbols" // ─── POST ───────────────────────────────────────────────────────────────────
/**
* POST /api/tenant/symbols
* Erstellt ein neues TenantSymbol (manuell oder aus Template/IconAsset).
*
* Body (manuell aus IconAsset):
* { iconId: string, customName?: string, categoryId?: string }
*
* Body (aus Template-Import):
* { templateId: string, categoryId?: string }
*
* Body (manueller Upload):
* FormData mit: file (SVG/PNG), name, categoryId
*/
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const auth = await getTenantId() const auth = await getTenantId()
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const { tenantId } = auth const { tenantId } = auth
const { iconId, customName } = await req.json() const contentType = req.headers.get('content-type') || ''
if (!iconId) return NextResponse.json({ error: 'iconId erforderlich' }, { status: 400 })
// Get max sortOrder for this tenant // ─── File Upload ─────────────────────────────────────────────────────────
const maxSort = await (prisma as any).tenantSymbol.aggregate({ if (contentType.includes('multipart/form-data')) {
const formData = await req.formData()
const file = formData.get('file') as File | null
const name = formData.get('name') as string | null
const categoryId = formData.get('categoryId') as string | null
if (!file || !name) {
return NextResponse.json({ error: 'Datei und Name erforderlich' }, { status: 400 })
}
const maxSortAgg = await (prisma as any).tenantSymbol.aggregate({
where: { tenantId }, where: { tenantId },
_max: { sortOrder: true }, _max: { sortOrder: true },
}) })
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const ext = file.name.split('.').pop()?.toLowerCase() || 'svg'
const mimeType = ext === 'svg' ? 'image/svg+xml' : `image/${ext}`
const fileKey = `tenant-${tenantId}/symbols/${Date.now()}-${name.replace(/\s+/g, '_').toLowerCase()}.${ext}`
await uploadFile(fileKey, buffer, mimeType)
const symbol = await (prisma as any).tenantSymbol.create({ const symbol = await (prisma as any).tenantSymbol.create({
data: { data: {
tenantId, tenantId,
iconId, name: name.trim(),
customName: customName || null, svgPath: fileKey,
sortOrder: (maxSort._max.sortOrder ?? -1) + 1, categoryId: categoryId || null,
sortOrder: (maxSortAgg._max.sortOrder ?? -1) + 1,
isUploaded: true,
},
})
return NextResponse.json({
id: symbol.id,
name: symbol.name,
svgPath: symbol.svgPath,
categoryId: symbol.categoryId,
sortOrder: symbol.sortOrder,
isUploaded: true,
}, { status: 201 })
}
// ─── JSON (from IconAsset or Template) ──────────────────────────────────
const body = await req.json()
const { iconId, customName, templateId, categoryId } = body
const maxSortAgg = await (prisma as any).tenantSymbol.aggregate({
where: { tenantId },
_max: { sortOrder: true },
})
const nextSort = (maxSortAgg._max.sortOrder ?? -1) + 1
// From SymbolTemplate
if (templateId) {
const tpl = await (prisma as any).symbolTemplate.findUnique({
where: { id: templateId },
})
if (!tpl) {
return NextResponse.json({ error: 'Template nicht gefunden' }, { status: 404 })
}
const symbol = await (prisma as any).tenantSymbol.create({
data: {
tenantId,
name: tpl.name,
svgPath: tpl.svgPath,
categoryId: categoryId || null,
sortOrder: nextSort,
isUploaded: false,
},
})
return NextResponse.json({
id: symbol.id,
name: symbol.name,
svgPath: symbol.svgPath,
categoryId: symbol.categoryId,
sortOrder: symbol.sortOrder,
isUploaded: false,
}, { status: 201 })
}
// From IconAsset (legacy path)
if (!iconId) {
return NextResponse.json({ error: 'iconId oder templateId erforderlich' }, { status: 400 })
}
const icon = await prisma.iconAsset.findUnique({
where: { id: iconId },
include: { category: true },
})
if (!icon) {
return NextResponse.json({ error: 'Icon nicht gefunden' }, { status: 404 })
}
const symbol = await (prisma as any).tenantSymbol.create({
data: {
tenantId,
iconId: icon.id,
customName: customName || null,
name: customName || icon.name,
svgPath: icon.fileKey,
categoryId: categoryId || null,
sortOrder: nextSort,
isUploaded: false,
}, },
include: { icon: { select: { name: true, mimeType: true, iconType: true, category: { select: { name: true } } } } },
}) })
return NextResponse.json({ return NextResponse.json({
id: symbol.id, id: symbol.id,
iconId: symbol.iconId, iconId: symbol.iconId,
name: symbol.customName || symbol.icon.name, name: symbol.name,
customName: symbol.customName, customName: symbol.customName,
baseName: symbol.icon.name, svgPath: symbol.svgPath,
mimeType: symbol.icon.mimeType, categoryId: symbol.categoryId,
iconType: symbol.icon.iconType,
categoryName: symbol.icon.category?.name || 'Ohne Kategorie',
sortOrder: symbol.sortOrder, sortOrder: symbol.sortOrder,
}) isUploaded: false,
}, { status: 201 })
} catch (error) { } catch (error) {
console.error('Error adding tenant symbol:', error) console.error('Error adding tenant symbol:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 }) return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
} }
} }
// PATCH: Rename a symbol or update sortOrder // ─── PATCH ──────────────────────────────────────────────────────────────────
/**
* PATCH /api/tenant/symbols
* Aktualisiert ein TenantSymbol.
*
* Body: { id: string, name?: string, customName?: string, categoryId?: string, sortOrder?: number }
*/
export async function PATCH(req: NextRequest) { export async function PATCH(req: NextRequest) {
try { try {
const auth = await getTenantId() const auth = await getTenantId()
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const { tenantId } = auth const { tenantId } = auth
const { id, customName, sortOrder } = await req.json() const { id, name, customName, categoryId, sortOrder } = await req.json()
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 }) if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
const data: any = {} const data: any = {}
if (name !== undefined) data.name = name || null
if (customName !== undefined) data.customName = customName || null if (customName !== undefined) data.customName = customName || null
if (categoryId !== undefined) data.categoryId = categoryId || null
if (sortOrder !== undefined) data.sortOrder = sortOrder if (sortOrder !== undefined) data.sortOrder = sortOrder
await (prisma as any).tenantSymbol.updateMany({ const result = await (prisma as any).tenantSymbol.updateMany({
where: { id, tenantId }, where: { id, tenantId },
data, data,
}) })
if (result.count === 0) {
return NextResponse.json({ error: 'Symbol nicht gefunden' }, { status: 404 })
}
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error) { } catch (error) {
console.error('Error updating tenant symbol:', error) console.error('Error updating tenant symbol:', error)
@@ -130,7 +275,14 @@ export async function PATCH(req: NextRequest) {
} }
} }
// DELETE: Remove a symbol from "my symbols" // ─── DELETE ─────────────────────────────────────────────────────────────────
/**
* DELETE /api/tenant/symbols
* Löscht ein TenantSymbol.
*
* Body: { id: string }
*/
export async function DELETE(req: NextRequest) { export async function DELETE(req: NextRequest) {
try { try {
const auth = await getTenantId() const auth = await getTenantId()
@@ -140,10 +292,14 @@ export async function DELETE(req: NextRequest) {
const { id } = await req.json() const { id } = await req.json()
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 }) if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
await (prisma as any).tenantSymbol.deleteMany({ const result = await (prisma as any).tenantSymbol.deleteMany({
where: { id, tenantId }, where: { id, tenantId },
}) })
if (result.count === 0) {
return NextResponse.json({ error: 'Symbol nicht gefunden' }, { status: 404 })
}
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error) { } catch (error) {
console.error('Error deleting tenant symbol:', error) console.error('Error deleting tenant symbol:', error)

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ import {
Search, Flame, Droplets, AlertTriangle, Car, Users, Search, Flame, Droplets, AlertTriangle, Car, Users,
Truck, Building, Target, Upload, Loader2, X, LayoutGrid, Truck, Building, Target, Upload, Loader2, X, LayoutGrid,
ChevronLeft, ChevronRight, Map, ClipboardList, PanelRightClose, PanelRightOpen, ChevronLeft, ChevronRight, Map, ClipboardList, PanelRightClose, PanelRightOpen,
Shield, Wrench, Radio, MoreHorizontal, Heart, Shield, Wrench, Radio, MoreHorizontal, Heart, FolderOpen,
} from 'lucide-react' } from 'lucide-react'
interface DisplaySymbol { interface DisplaySymbol {
@@ -24,6 +24,12 @@ interface DisplayCategory {
symbols: DisplaySymbol[] symbols: DisplaySymbol[]
} }
interface TenantSymbolGroup {
categoryId: string | null
categoryName: string
symbols: DisplaySymbol[]
}
interface RightSidebarProps { interface RightSidebarProps {
onSymbolDrop: (iconId: string, coordinates: [number, number], imageUrl?: string) => void onSymbolDrop: (iconId: string, coordinates: [number, number], imageUrl?: string) => void
canEdit: boolean canEdit: boolean
@@ -99,10 +105,11 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [activeCategory, setActiveCategory] = useState<string>('') const [activeCategory, setActiveCategory] = useState<string>('')
const [categories, setCategories] = useState<DisplayCategory[]>([]) const [categories, setCategories] = useState<DisplayCategory[]>([])
const [tenantIcons, setTenantIcons] = useState<DisplaySymbol[]>([]) const [tenantGroups, setTenantGroups] = useState<TenantSymbolGroup[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [showTenantSection, setShowTenantSection] = useState(true) const [showTenantSection, setShowTenantSection] = useState(true)
const [showLibrarySection, setShowLibrarySection] = useState(true) const [showLibrarySection, setShowLibrarySection] = useState(true)
const [expandedTenantCats, setExpandedTenantCats] = useState<Set<string>>(new Set())
useEffect(() => { useEffect(() => {
async function fetchIcons() { async function fetchIcons() {
@@ -111,6 +118,8 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
const res = await fetch('/api/icons', { cache: 'no-store' }) const res = await fetch('/api/icons', { cache: 'no-store' })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
// ─── Global library ───
const allCats: DisplayCategory[] = (data.categories || []) const allCats: DisplayCategory[] = (data.categories || [])
.filter((cat: any) => cat.icons && cat.icons.length > 0) .filter((cat: any) => cat.icons && cat.icons.length > 0)
.map((cat: any) => ({ .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 eigene = allCats.find(c => c.name === 'Eigene')
const globalCats = allCats.filter(c => c.name !== 'Eigene') const globalCats = allCats.filter(c => c.name !== 'Eigene')
// Merge: mySymbols (custom collection) + legacy "Eigene" category uploads setCategories(globalCats)
const mySymbols: DisplaySymbol[] = (data.mySymbols || []).map((s: any) => ({ if (globalCats.length > 0 && !activeCategory) {
setActiveCategory(globalCats[0].id)
}
// ─── 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, id: s.id,
name: s.name, name: s.name,
imageUrl: s.url || `/api/icons/${s.id}/image`, imageUrl: s.imageUrl || `/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) // Merge legacy "Eigene" into tenant groups if present
setCategories(globalCats) if (eigene && eigene.symbols.length > 0) {
if (globalCats.length > 0) setActiveCategory(globalCats[0].id) const legacyGroup: TenantSymbolGroup = {
categoryId: '__legacy__',
categoryName: 'Eigene',
symbols: eigene.symbols,
}
groups.unshift(legacyGroup)
}
// Auto-collapse library if tenant has own symbols setTenantGroups(groups)
if (mergedTenant.length > 0) {
// 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) setShowLibrarySection(false)
} }
} }
@@ -156,18 +179,31 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
fetchIcons() fetchIcons()
}, [tenantId]) }, [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) => ({ const filteredCategories = categories.map((cat) => ({
...cat, ...cat,
symbols: cat.symbols.filter((s) => symbols: cat.symbols.filter((s) =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) s.name.toLowerCase().includes(searchQuery.toLowerCase())
), ),
})) }))
const filteredTenantIcons = tenantIcons.filter(s => const filteredTenantGroups = tenantGroups.map(g => ({
...g,
symbols: g.symbols.filter(s =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) s.name.toLowerCase().includes(searchQuery.toLowerCase())
) ),
})).filter(g => g.symbols.length > 0)
const currentCategory = filteredCategories.find((c) => c.id === activeCategory) 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 ( return (
<> <>
@@ -298,7 +334,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
)} )}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
{/* ─── Section 1: Meine Symbole (Tenant-specific) ─── */} {/* ─── Section 1: Meine Symbole (Tenant Symbol Groups) ─── */}
{tenantId && ( {tenantId && (
<div className="border-b border-border"> <div className="border-b border-border">
<button <button
@@ -307,24 +343,47 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
> >
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Upload className="w-3.5 h-3.5" /> <Upload className="w-3.5 h-3.5" />
Meine Symbole ({filteredTenantIcons.length}) Meine Symbole ({tenantGroups.reduce((s, g) => s + g.symbols.length, 0)})
</span> </span>
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showTenantSection ? 'rotate-90' : ''}`} /> <ChevronRight className={`w-3.5 h-3.5 transition-transform ${showTenantSection ? 'rotate-90' : ''}`} />
</button> </button>
{showTenantSection && ( {showTenantSection && (
<div className="p-2 pt-0"> <div className="p-2 pt-0 space-y-1">
{filteredTenantIcons.length === 0 ? ( {filteredTenantGroups.length === 0 ? (
<div className="text-center text-muted-foreground py-4 text-xs"> <div className="text-center text-muted-foreground py-4 text-xs">
Keine eigenen Symbole vorhanden. Keine eigenen Symbole vorhanden.
<br /> <br />
<span className="text-[10px]">Symbole können unter Einstellungen Symbole hochgeladen werden.</span> <span className="text-[10px]">Symbole können unter Admin Symbole verwaltet werden.</span>
</div> </div>
) : ( ) : (
filteredTenantGroups.map(g => {
const key = g.categoryId || '__none__'
const expanded = expandedTenantCats.has(key)
return (
<div key={key} className="border rounded-md">
<button
onClick={() => toggleTenantCat(g.categoryId)}
className="w-full flex items-center justify-between px-2 py-1 text-[11px] font-medium hover:bg-muted/40 transition-colors"
>
<span className="flex items-center gap-1.5">
<FolderOpen className="w-3 h-3 text-muted-foreground" />
{g.categoryName}
<span className="text-[10px] text-muted-foreground">({g.symbols.length})</span>
</span>
<ChevronRight className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
</button>
{expanded && (
<div className="px-1.5 pb-1.5">
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1"> <div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
{filteredTenantIcons.map((symbol) => ( {g.symbols.map(symbol => (
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} /> <DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} />
))} ))}
</div> </div>
</div>
)}
</div>
)
})
)} )}
</div> </div>
)} )}