From 2dbb2204f68e492d4fb003c5bc758d568116c81e Mon Sep 17 00:00:00 2001 From: Pepe Ziberi Date: Sun, 19 Jul 2026 18:15:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(cockpit):=20Modul-Baukasten=20=E2=80=93=20?= =?UTF-8?q?Einsatz-Cockpit=20frei=20zusammenstellbar=20(v1.5.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jede Feuerwehr stellt ihr Cockpit selbst zusammen (Wunsch: modular & einfach): M0 – Zerlegt: - SOMA + Pendenzen als eigenständige Modul-Komponenten, gemeinsame Typen (journal/types.ts), einheitliche ModuleCard-Hülle M1 – Registry & Verwaltung: - Modul-Registry src/lib/modules.ts, Konfiguration pro Mandant in tenants.modulesConfig (JSONB, idempotente Migration) - API GET/PUT /api/tenant/modules (normalisiert + validiert via normalizeModules) - "Module"-Button im Journal (Admins): an/aus, Reihenfolge hoch/runter, eigene Module löschen M2 – Eigene Module: - Generisches Tabellen-Modul: frei definierbare Spalten (Text / Haken mit Zeitstempel / Auto-Zeit), max 8 Spalten - Generische Daten-API /api/projects/[id]/modules/[moduleId]/items (+ [itemId]), neue Tabelle module_items, Live-Sync über journal-refresh Events M3 – Feld-Vorlagen: - Checkliste, Atemschutz-Überwachung (Trupp/Druck/Zeit/Draussen), Kräfte vor Ort (Name/Funktion/seit/Weg), Lagemeldungen (Hauptbereich), leeres Modul mit Spalten-Editor Eingebaute Module (Journal/SOMA/Pendenzen) bleiben ohne Konfiguration exakt wie bisher — kaputte Configs fallen sicher auf den Standard zurück. Co-Authored-By: Claude Opus 4.8 (1M context) --- ROADMAP.md | 14 + package.json | 2 +- prisma/migrate.js | 13 + prisma/schema.prisma | 21 + .../[moduleId]/items/[itemId]/route.ts | 61 ++ .../[id]/modules/[moduleId]/items/route.ts | 56 ++ src/app/api/tenant/modules/route.ts | 57 ++ src/components/journal/journal-view.tsx | 731 ++++++++---------- .../journal/module-manager-dialog.tsx | 261 +++++++ .../journal/modules/pendenzen-module.tsx | 108 +++ .../journal/modules/soma-module.tsx | 99 +++ .../journal/modules/table-module.tsx | 209 +++++ src/components/journal/types.ts | 40 + src/lib/modules.ts | 152 ++++ 14 files changed, 1412 insertions(+), 412 deletions(-) create mode 100644 src/app/api/projects/[id]/modules/[moduleId]/items/[itemId]/route.ts create mode 100644 src/app/api/projects/[id]/modules/[moduleId]/items/route.ts create mode 100644 src/app/api/tenant/modules/route.ts create mode 100644 src/components/journal/module-manager-dialog.tsx create mode 100644 src/components/journal/modules/pendenzen-module.tsx create mode 100644 src/components/journal/modules/soma-module.tsx create mode 100644 src/components/journal/modules/table-module.tsx create mode 100644 src/components/journal/types.ts create mode 100644 src/lib/modules.ts diff --git a/ROADMAP.md b/ROADMAP.md index 31ab16a..69de48a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -74,6 +74,20 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross - Offen (später): Berechnungsparameter pro Mandant, mehrere Leitungen/Pumpenkette, Elevation-Proxy mit Cache, Ergebnis als speicherbares Lageplan-Objekt. +## Zusatz – Modul-Baukasten (Einsatz-Cockpit) ✅ v1.5.0 + +- [x] Journal-Monolith zerlegt: SOMA + Pendenzen als eigenständige Module, + gemeinsame Typen, Karten-Hülle (`ModuleCard`) +- [x] Modul-Registry (`src/lib/modules.ts`) + Konfiguration pro Mandant + (`tenants.modulesConfig`), API `GET/PUT /api/tenant/modules` +- [x] „Module verwalten"-Dialog: an/aus, Reihenfolge, eigene Module erstellen +- [x] Generisches Tabellen-Modul (Spaltentypen Text/Haken/Zeit) mit eigener + Daten-API (`module_items`), Live-Sync über journal-refresh +- [x] Vorlagen: Checkliste, Atemschutz-Überwachung, Kräfte vor Ort, + Lagemeldungen, leeres Modul +- Offen (später): Übungs-Cockpit auf denselben Baukasten umstellen, + Modul-Daten in Rapport-PDF übernehmen + ## Phase 5 – Kür - [x] **5.1 – Linien-Typ-Abfrage** 🟡 ✅ diff --git a/package.json b/package.json index 35da3e4..c627871 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.4.17", + "version": "1.5.0", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "private": true, "scripts": { diff --git a/prisma/migrate.js b/prisma/migrate.js index 1dd9cf5..86cb8d4 100644 --- a/prisma/migrate.js +++ b/prisma/migrate.js @@ -82,6 +82,8 @@ async function migrate() { // Journal `ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "isCorrected" BOOLEAN DEFAULT false`, `ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "correctionOfId" TEXT`, + // Modul-Baukasten (Cockpit-Konfiguration pro Mandant) + `ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "modulesConfig" JSONB`, ] let added = 0 for (const sql of columnMigrations) { @@ -131,6 +133,17 @@ async function migrate() { "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "projectId" TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE )`, + // Modul-Baukasten: generische Zeilen für Tabellen-Module + `CREATE TABLE IF NOT EXISTS module_items ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), + "moduleId" TEXT NOT NULL, + data JSONB NOT NULL DEFAULT '{}', + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "projectId" TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS module_items_project_module_idx ON module_items("projectId", "moduleId")`, ] for (const sql of tableMigrations) { try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 73030e1..37ee653 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -74,6 +74,8 @@ model Tenant { notes String? hiddenIconIds String[] @default([]) journalSuggestions String[] @default([]) + // Modul-Baukasten: Cockpit-Konfiguration (siehe src/lib/modules.ts). NULL = Standard. + modulesConfig Json? // Privacy consent privacyAccepted Boolean @default(false) @@ -193,6 +195,7 @@ model Project { journalPendenzen JournalPendenz[] exerciseGoals ExerciseGoal[] rapports Rapport[] + moduleItems ModuleItem[] @@map("projects") } @@ -349,6 +352,24 @@ model JournalPendenz { @@map("journal_pendenzen") } +// Generischer Datenspeicher für Tabellen-Module (Modul-Baukasten). +// data enthält die Zellwerte gemäss Spaltendefinition des Moduls, +// z.B. { "trupp": "Trupp 1", "druck": "280", "out": true, "outAt": "..." } +model ModuleItem { + id String @id @default(uuid()) + moduleId String + data Json @default("{}") + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId, moduleId]) + @@map("module_items") +} + model JournalCheckTemplate { id String @id @default(uuid()) label String diff --git a/src/app/api/projects/[id]/modules/[moduleId]/items/[itemId]/route.ts b/src/app/api/projects/[id]/modules/[moduleId]/items/[itemId]/route.ts new file mode 100644 index 0000000..43a9209 --- /dev/null +++ b/src/app/api/projects/[id]/modules/[moduleId]/items/[itemId]/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { prisma } from '@/lib/db' +import { getProjectWithTenantCheck } from '@/lib/tenant' + +/** Zeile eines Tabellen-Moduls aktualisieren (data wird gemerged) */ +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string; moduleId: string; itemId: string }> } +) { + try { + const { id, moduleId, itemId } = await params + const user = await getSession() + if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) + if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) + + const project = await getProjectWithTenantCheck(id, user) + if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 }) + + const existing = await (prisma as any).moduleItem.findFirst({ + where: { id: itemId, projectId: id, moduleId }, + }) + if (!existing) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 }) + + const body = await request.json() + const patch = body && typeof body.data === 'object' && body.data !== null ? body.data : {} + + const item = await (prisma as any).moduleItem.update({ + where: { id: itemId }, + data: { data: { ...(existing.data || {}), ...patch } }, + }) + return NextResponse.json(item) + } catch (error) { + console.error('Error updating module item:', error) + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) + } +} + +/** Zeile eines Tabellen-Moduls löschen */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string; moduleId: string; itemId: string }> } +) { + try { + const { id, moduleId, itemId } = await params + const user = await getSession() + if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) + if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) + + const project = await getProjectWithTenantCheck(id, user) + if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 }) + + await (prisma as any).moduleItem.deleteMany({ + where: { id: itemId, projectId: id, moduleId }, + }) + return NextResponse.json({ ok: true }) + } catch (error) { + console.error('Error deleting module item:', error) + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) + } +} diff --git a/src/app/api/projects/[id]/modules/[moduleId]/items/route.ts b/src/app/api/projects/[id]/modules/[moduleId]/items/route.ts new file mode 100644 index 0000000..f3a1e3e --- /dev/null +++ b/src/app/api/projects/[id]/modules/[moduleId]/items/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { prisma } from '@/lib/db' +import { getProjectWithTenantCheck } from '@/lib/tenant' + +/** Zeilen eines Tabellen-Moduls laden */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string; moduleId: string }> } +) { + try { + const { id, moduleId } = await params + const user = await getSession() + if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) + + const project = await getProjectWithTenantCheck(id, user) + if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 }) + + const items = await (prisma as any).moduleItem.findMany({ + where: { projectId: id, moduleId }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + }) + return NextResponse.json({ items }) + } catch (error) { + console.error('Error fetching module items:', error) + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) + } +} + +/** Neue Zeile in einem Tabellen-Modul anlegen */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string; moduleId: string }> } +) { + try { + const { id, moduleId } = await params + const user = await getSession() + if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) + if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) + + const project = await getProjectWithTenantCheck(id, user) + if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 }) + + const body = await request.json() + const data = body && typeof body.data === 'object' && body.data !== null ? body.data : {} + + const count = await (prisma as any).moduleItem.count({ where: { projectId: id, moduleId } }) + const item = await (prisma as any).moduleItem.create({ + data: { projectId: id, moduleId, data, sortOrder: count }, + }) + return NextResponse.json(item, { status: 201 }) + } catch (error) { + console.error('Error creating module item:', error) + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) + } +} diff --git a/src/app/api/tenant/modules/route.ts b/src/app/api/tenant/modules/route.ts new file mode 100644 index 0000000..326470b --- /dev/null +++ b/src/app/api/tenant/modules/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { getSession } from '@/lib/auth' +import { normalizeModules, DEFAULT_MODULES } from '@/lib/modules' + +/** Cockpit-Modul-Konfiguration des eigenen Mandanten lesen */ +export async function GET() { + try { + const user = await getSession() + if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) + + if (!user.tenantId) { + // SERVER_ADMIN ohne Mandant: Standard-Cockpit + return NextResponse.json({ modules: DEFAULT_MODULES, canManage: false }) + } + + const tenant = await (prisma as any).tenant.findUnique({ + where: { id: user.tenantId }, + select: { modulesConfig: true }, + }) + + const canManage = user.role === 'SERVER_ADMIN' || user.role === 'TENANT_ADMIN' + return NextResponse.json({ + modules: normalizeModules(tenant?.modulesConfig), + canManage, + }) + } catch (error) { + console.error('Error fetching module config:', error) + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) + } +} + +/** Cockpit-Modul-Konfiguration speichern (nur Admins) */ +export async function PUT(request: NextRequest) { + try { + const user = await getSession() + if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) + if (!user.tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 }) + if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') { + return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) + } + + const body = await request.json() + // normalizeModules verwirft Unbekanntes und stellt eingebaute Module sicher + const modules = normalizeModules(body?.modules) + + await (prisma as any).tenant.update({ + where: { id: user.tenantId }, + data: { modulesConfig: modules }, + }) + + return NextResponse.json({ modules }) + } catch (error) { + console.error('Error saving module config:', error) + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) + } +} diff --git a/src/components/journal/journal-view.tsx b/src/components/journal/journal-view.tsx index a67f793..6c9ddf2 100644 --- a/src/components/journal/journal-view.tsx +++ b/src/components/journal/journal-view.tsx @@ -3,42 +3,19 @@ import { useState, useEffect, useCallback, useRef, MutableRefObject } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { ScrollArea } from '@/components/ui/scroll-area' import { - Plus, Trash2, Check, Clock, CheckSquare, + Plus, Check, Clock, CheckSquare, AlertTriangle, ClipboardList, Loader2, Printer, Pencil, Send, FileText, + Blocks, ListChecks, Shield, Users, Radio, Table as TableIcon, } from 'lucide-react' import { getSocket } from '@/lib/socket' import { RapportDialog } from '@/components/journal/rapport-dialog' - -interface JournalEntry { - id: string - time: string - what: string - who: string | null - done: boolean - doneAt: string | null - isCorrected?: boolean - correctionOfId?: string | null -} - -interface JournalCheckItem { - id: string - label: string - confirmed: boolean - confirmedAt: string | null - ok: boolean - okAt: string | null -} - -interface JournalPendenz { - id: string - what: string - who: string | null - whenHow: string | null - done: boolean - doneAt: string | null -} +import { ModuleManagerDialog } from '@/components/journal/module-manager-dialog' +import { SomaModule } from '@/components/journal/modules/soma-module' +import { PendenzenModule } from '@/components/journal/modules/pendenzen-module' +import { TableModule } from '@/components/journal/modules/table-module' +import { DEFAULT_MODULES, type CockpitModule } from '@/lib/modules' +import { type JournalEntry, type JournalCheckItem, type JournalPendenz, formatTime } from '@/components/journal/types' interface JournalViewProps { projectId: string | null @@ -56,14 +33,38 @@ interface JournalViewProps { mapScreenshot?: string } -function formatTime(dateStr: string) { - return new Date(dateStr).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) +/** Lucide-Icons für Modul-Karten (Schlüssel siehe src/lib/modules.ts) */ +const MODULE_ICONS: Record = { + 'clipboard-list': ClipboardList, + 'alert-triangle': AlertTriangle, + 'check-square': CheckSquare, + 'list-checks': ListChecks, + 'shield': Shield, + 'users': Users, + 'radio': Radio, + 'table': TableIcon, } -function formatDateTime(dateStr: string) { - const d = new Date(dateStr) - return d.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) + ' ' + - d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) +/** Karten-Hülle für Seitenspalten-Module — einheitliche rote Titelzeile */ +function ModuleCard({ icon, name, count, children }: { + icon: string + name: string + count?: string + children: React.ReactNode +}) { + const Icon = MODULE_ICONS[icon] || TableIcon + return ( +
+
+

+ + {name} + {count && ({count})} +

+
+ {children} +
+ ) } export function JournalView({ projectId, projectTitle, projectLocation, mode, einsatzleiter, journalfuehrer, canEdit, tenantId, einsatzNr, tenantName, tenantLogoUrl, mapRef, mapScreenshot: preCapuredScreenshot }: JournalViewProps) { @@ -74,18 +75,15 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei const [isLoading, setIsLoading] = useState(false) const initDoneRef = useRef(false) + // Modul-Baukasten: Cockpit-Konfiguration des Mandanten + const [modules, setModules] = useState(DEFAULT_MODULES) + const [canManageModules, setCanManageModules] = useState(false) + const [showModuleManager, setShowModuleManager] = useState(false) + // New entry form const [newWhat, setNewWhat] = useState('') const [newWho, setNewWho] = useState('') - // New pendenz form - const [newPendWhat, setNewPendWhat] = useState('') - const [newPendWho, setNewPendWho] = useState('') - const [newPendWhen, setNewPendWhen] = useState('') - - // New check item - const [newCheckLabel, setNewCheckLabel] = useState('') - const scrollRef = useRef(null) // Rapport creation @@ -147,6 +145,17 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei } }, [projectId]) + // Cockpit-Modul-Konfiguration laden + useEffect(() => { + fetch('/api/tenant/modules') + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data?.modules) setModules(data.modules) + if (data) setCanManageModules(!!data.canManage) + }) + .catch(() => {}) + }, [tenantId]) + // Init check items from templates if none exist (guarded against double-call) const initCheckItems = useCallback(async () => { if (!projectId || initDoneRef.current) return @@ -290,24 +299,23 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei }, [projectId, notifyJournalChanged]) // Add custom check item - const addCheckItem = useCallback(async () => { - if (!projectId || !newCheckLabel.trim()) return + const addCheckItem = useCallback(async (label: string) => { + if (!projectId || !label.trim()) return try { const res = await fetch(`/api/projects/${projectId}/journal/check-items`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ label: newCheckLabel.trim(), sortOrder: checkItems.length }), + body: JSON.stringify({ label: label.trim(), sortOrder: checkItems.length }), }) if (res.ok) { const item = await res.json() setCheckItems(prev => [...prev, item]) - setNewCheckLabel('') notifyJournalChanged() } } catch (err) { console.error('Failed to add check item:', err) } - }, [projectId, newCheckLabel, checkItems.length, notifyJournalChanged]) + }, [projectId, checkItems.length, notifyJournalChanged]) // Delete check item const deleteCheckItem = useCallback(async (itemId: string) => { @@ -322,26 +330,23 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei }, [projectId, notifyJournalChanged]) // Add pendenz - const addPendenz = useCallback(async () => { - if (!projectId || !newPendWhat.trim()) return + const addPendenz = useCallback(async (what: string, who: string, whenHow: string) => { + if (!projectId || !what.trim()) return try { const res = await fetch(`/api/projects/${projectId}/journal/pendenzen`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ what: newPendWhat.trim(), who: newPendWho.trim() || null, whenHow: newPendWhen.trim() || null }), + body: JSON.stringify({ what: what.trim(), who: who.trim() || null, whenHow: whenHow.trim() || null }), }) if (res.ok) { const item = await res.json() setPendenzen(prev => [...prev, item]) - setNewPendWhat('') - setNewPendWho('') - setNewPendWhen('') notifyJournalChanged() } } catch (err) { console.error('Failed to add pendenz:', err) } - }, [projectId, newPendWhat, newPendWho, newPendWhen, notifyJournalChanged]) + }, [projectId, notifyJournalChanged]) // Toggle pendenz done const togglePendenzDone = useCallback(async (p: JournalPendenz) => { @@ -417,6 +422,11 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei ) } + // Aktivierte Module nach Slot aufteilen (Reihenfolge = sortOrder aus der Konfiguration) + const enabledModules = modules.filter(m => m.enabled) + const mainModules = enabledModules.filter(m => m.slot === 'main') + const sideModules = enabledModules.filter(m => m.slot === 'side') + return ( <> {/* Print styles: landscape, compact */} @@ -437,6 +447,12 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei {isUebung ? 'Übungs-Journal' : 'Einsatz-Journal'}
+ {canManageModules && ( + + )}
@@ -543,372 +559,265 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei )} - {/* Main content: side-by-side on large screens */} + {/* Main content: side-by-side on large screens — Module gemäss Mandanten-Konfiguration */}
- {/* Journal entries */} + {/* Hauptbereich: Journal + weitere main-Module */}
- {/* Table header */} -
-
Zeit
-
Was
-
Wer
-
Ok
-
-
+ {mainModules.map(mod => { + if (mod.type === 'journal') { + return ( +
+ {/* Table header */} +
+
Zeit
+
Was
+
Wer
+
Ok
+
+
- {isLoading ? ( -
- - Lade Journal... -
- ) : entries.length === 0 ? ( -
- Noch keine Einträge. -
- ) : ( -
- {entries.map((entry, idx) => ( -
-
- {formatTime(entry.time)} -
-
- {entry.what} - {(entry as any).isCorrected && ( - - (korrigiert) - - )} - {entry.done && entry.doneAt && ( - - (erledigt um {formatTime(entry.doneAt)}) - - )} -
-
- {entry.who || '\u2013'} -
-
- {canEdit && !(entry as any).isCorrected ? ( - - ) : ( - entry.done ? : (entry as any).isCorrected ? : null - )} -
-
- {canEdit && !(entry as any).isCorrected && !(entry as any).correctionOfId && ( - - )} -
-
- ))} -
- )} + {isLoading ? ( +
+ + Lade Journal... +
+ ) : entries.length === 0 ? ( +
+ Noch keine Einträge. +
+ ) : ( +
+ {entries.map((entry, idx) => ( +
+
+ {formatTime(entry.time)} +
+
+ {entry.what} + {(entry as any).isCorrected && ( + + (korrigiert) + + )} + {entry.done && entry.doneAt && ( + + (erledigt um {formatTime(entry.doneAt)}) + + )} +
+
+ {entry.who || '–'} +
+
+ {canEdit && !(entry as any).isCorrected ? ( + + ) : ( + entry.done ? : (entry as any).isCorrected ? : null + )} +
+
+ {canEdit && !(entry as any).isCorrected && !(entry as any).correctionOfId && ( + + )} +
+
+ ))} +
+ )} - {/* Correction inline form */} - {correctionEntryId && ( -
-

Korrektur erstellen:

-
- setCorrectionText(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && correctEntry()} - className="flex-1 h-7 text-xs border-amber-300" - autoFocus - /> - - -
-
- )} - - {/* New entry form */} - {canEdit && ( -
-
-
- - {new Date().toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })} -
-
- {/* iPhone-style suggestion bar */} - {showSuggestions && filteredSuggestions.length > 0 && ( -
-
- {filteredSuggestions.map((s, i) => { - const lower = newWhat.toLowerCase() - const idx = s.toLowerCase().indexOf(lower) - return ( - - ) - })} + {/* Correction inline form */} + {correctionEntryId && ( +
+

Korrektur erstellen:

+
+ setCorrectionText(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && correctEntry()} + className="flex-1 h-7 text-xs border-amber-300" + autoFocus + /> + +
)} - { - setNewWhat(e.target.value) - filterSuggestions(e.target.value) - }} - onKeyDown={(e) => { - if (showSuggestions && filteredSuggestions.length > 0) { - if (e.key === 'ArrowRight' || e.key === 'Tab') { - e.preventDefault() - setSelectedSuggestionIdx(prev => (prev + 1) % filteredSuggestions.length) - return - } - if (e.key === 'ArrowLeft') { - e.preventDefault() - setSelectedSuggestionIdx(prev => prev <= 0 ? filteredSuggestions.length - 1 : prev - 1) - return - } - if (e.key === 'Enter' && selectedSuggestionIdx >= 0) { - e.preventDefault() - setNewWhat(filteredSuggestions[selectedSuggestionIdx]) - setShowSuggestions(false) - setSelectedSuggestionIdx(-1) - return - } - } - if (e.key === 'Enter') { setShowSuggestions(false); addEntry() } - if (e.key === 'Escape') { setShowSuggestions(false); setSelectedSuggestionIdx(-1) } - }} - onFocus={() => filterSuggestions(newWhat)} - onBlur={() => setTimeout(() => { setShowSuggestions(false); setSelectedSuggestionIdx(-1) }, 150)} - className="h-7 text-xs w-full" - /> -
- setNewWho(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && addEntry()} - className="w-16 md:w-20 h-7 text-xs" - /> - -
-
- )} -
- {/* Right column: SOMA + Pendenzen */} -
- {/* SOMA Checklist */} -
-
-

- - SOMA - - ({checkItems.filter(c => c.confirmed).length}/{checkItems.length}) - -

-
- {/* SOMA table with column headers */} -
-
-
JA
-
Ok
-
-
- {checkItems.map((item, idx) => ( -
-
- {item.label} - {item.confirmedAt && item.confirmed && ( - {formatTime(item.confirmedAt)} - )} - {canEdit && ( - - )} -
-
- -
-
- -
-
- ))} -
- {canEdit && ( -
- setNewCheckLabel(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && addCheckItem()} - className="h-6 text-[11px]" - /> - -
- )} -
- - {/* Pendenzen */} -
-
-

- - Pendenzen - - ({pendenzen.filter(p => p.done).length}/{pendenzen.length}) - -

-
- {pendenzen.length === 0 ? ( -
- Keine Pendenzen -
- ) : ( -
- {pendenzen.map((p) => ( -
- -
-

- {p.what} - {p.done && p.doneAt && ( - - (erledigt um {formatTime(p.doneAt)}) - - )} -

-
- {p.who && Wer: {p.who}} - {p.whenHow && Wann: {p.whenHow}} + {/* New entry form */} + {canEdit && ( +
+
+
+ + {new Date().toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })} +
+
+ {/* iPhone-style suggestion bar */} + {showSuggestions && filteredSuggestions.length > 0 && ( +
+
+ {filteredSuggestions.map((s, i) => { + const lower = newWhat.toLowerCase() + const idx = s.toLowerCase().indexOf(lower) + return ( + + ) + })} +
+
+ )} + { + setNewWhat(e.target.value) + filterSuggestions(e.target.value) + }} + onKeyDown={(e) => { + if (showSuggestions && filteredSuggestions.length > 0) { + if (e.key === 'ArrowRight' || e.key === 'Tab') { + e.preventDefault() + setSelectedSuggestionIdx(prev => (prev + 1) % filteredSuggestions.length) + return + } + if (e.key === 'ArrowLeft') { + e.preventDefault() + setSelectedSuggestionIdx(prev => prev <= 0 ? filteredSuggestions.length - 1 : prev - 1) + return + } + if (e.key === 'Enter' && selectedSuggestionIdx >= 0) { + e.preventDefault() + setNewWhat(filteredSuggestions[selectedSuggestionIdx]) + setShowSuggestions(false) + setSelectedSuggestionIdx(-1) + return + } + } + if (e.key === 'Enter') { setShowSuggestions(false); addEntry() } + if (e.key === 'Escape') { setShowSuggestions(false); setSelectedSuggestionIdx(-1) } + }} + onFocus={() => filterSuggestions(newWhat)} + onBlur={() => setTimeout(() => { setShowSuggestions(false); setSelectedSuggestionIdx(-1) }, 150)} + className="h-7 text-xs w-full" + /> +
+ setNewWho(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addEntry()} + className="w-16 md:w-20 h-7 text-xs" + /> +
- {canEdit && ( - - )} -
- ))} -
- )} - {canEdit && ( -
-
- setNewPendWhat(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && addPendenz()} - className="flex-1 h-6 text-[11px]" - /> - setNewPendWho(e.target.value)} - className="w-14 h-6 text-[11px]" - /> - setNewPendWhen(e.target.value)} - className="w-14 h-6 text-[11px]" - /> - + )}
+ ) + } + // Weitere main-Module (z.B. Lagemeldungen): breite Tabellen-Karte + return ( +
+ + +
- )} -
+ ) + })}
+ + {/* Seitenspalte: SOMA, Pendenzen + eigene Module */} + {sideModules.length > 0 && ( +
+ {sideModules.map(mod => { + if (mod.type === 'soma') { + return ( + c.confirmed).length}/${checkItems.length}`}> + + + ) + } + if (mod.type === 'pendenzen') { + return ( + p.done).length}/${pendenzen.length}`}> + + + ) + } + return ( + + + + ) + })} +
+ )}
+ {/* Modul-Verwaltung */} + + {/* Rapport Dialog */} {showRapportDialog && projectId && ( void + modules: CockpitModule[] + onSaved: (modules: CockpitModule[]) => void +} + +/** + * "Module verwalten" — der Baukasten: + * Module ein-/ausschalten, Reihenfolge ändern, eigene Tabellen-Module + * aus Vorlagen erstellen (Checkliste, Atemschutz, Kräfte, Lagemeldungen, leer). + * Gespeichert wird pro Mandant (gilt für alle Einsätze). + */ +export function ModuleManagerDialog({ open, onOpenChange, modules, onSaved }: ModuleManagerDialogProps) { + const [working, setWorking] = useState(modules) + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState('') + + // Neues Modul aus Vorlage + const [showAdd, setShowAdd] = useState(false) + const [presetKey, setPresetKey] = useState(MODULE_PRESETS[0].key) + const [newName, setNewName] = useState('') + const [newColumns, setNewColumns] = useState(MODULE_PRESETS[0].columns) + + useEffect(() => { + if (open) { + setWorking(modules) + setError('') + setShowAdd(false) + } + }, [open, modules]) + + const move = (idx: number, dir: -1 | 1) => { + const next = [...working] + const target = idx + dir + if (target < 0 || target >= next.length) return + ;[next[idx], next[target]] = [next[target], next[idx]] + setWorking(next.map((m, i) => ({ ...m, sortOrder: i }))) + } + + const toggle = (idx: number) => { + setWorking(prev => prev.map((m, i) => i === idx ? { ...m, enabled: !m.enabled } : m)) + } + + const removeCustom = (idx: number) => { + setWorking(prev => prev.filter((_, i) => i !== idx).map((m, i) => ({ ...m, sortOrder: i }))) + } + + const selectPreset = (key: string) => { + const preset = MODULE_PRESETS.find(p => p.key === key)! + setPresetKey(key) + setNewName(preset.key === 'leer' ? '' : preset.name) + setNewColumns(preset.columns) + } + + const addModule = () => { + const preset = MODULE_PRESETS.find(p => p.key === presetKey)! + const name = newName.trim() || preset.name + const mod: CockpitModule = { + id: `custom-${Date.now().toString(36)}`, + type: 'table', + name, + icon: preset.icon, + slot: preset.slot, + enabled: true, + sortOrder: working.length, + columns: newColumns.filter(c => c.label.trim()), + } + if (!mod.columns || mod.columns.length === 0) return + setWorking(prev => [...prev, mod]) + setShowAdd(false) + setNewName('') + } + + const updateColumn = (idx: number, patch: Partial) => { + setNewColumns(prev => prev.map((c, i) => i === idx ? { ...c, ...patch } : c)) + } + + const addColumn = () => { + if (newColumns.length >= 8) return + setNewColumns(prev => [...prev, { key: `col${prev.length}-${Date.now().toString(36).slice(-4)}`, label: '', type: 'text' }]) + } + + const removeColumn = (idx: number) => { + setNewColumns(prev => prev.filter((_, i) => i !== idx)) + } + + const save = async () => { + setIsSaving(true) + setError('') + try { + const res = await fetch('/api/tenant/modules', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ modules: working }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Speichern fehlgeschlagen') + onSaved(data.modules) + onOpenChange(false) + } catch (e) { + setError(e instanceof Error ? e.message : 'Unbekannter Fehler') + } finally { + setIsSaving(false) + } + } + + const COLUMN_TYPES: { value: ModuleColumnType; label: string }[] = [ + { value: 'text', label: 'Text' }, + { value: 'check', label: 'Haken' }, + { value: 'time', label: 'Zeit (auto)' }, + ] + + return ( + + + + + + Cockpit-Module verwalten + + + +

+ Stelle das Einsatz-Cockpit für deine Feuerwehr zusammen. Gilt für alle Einsätze deiner Organisation. +

+ + {/* Modul-Liste */} +
+ {working.map((m, idx) => ( +
+ {/* An/aus */} + + +
+

{m.name}

+

+ {m.type === 'table' ? `Eigenes Modul · ${m.columns?.length || 0} Spalten` : 'Eingebaut'} + {' · '}{m.slot === 'main' ? 'Hauptbereich' : 'Seitenspalte'} +

+
+ +
+ + + {m.type === 'table' && ( + + )} +
+
+ ))} +
+ + {/* Neues Modul */} + {!showAdd ? ( + + ) : ( +
+
+

Neues Modul

+ +
+ + {/* Vorlagen */} +
+ {MODULE_PRESETS.map(p => ( + + ))} +
+ +
+ + setNewName(e.target.value)} placeholder="z.B. Atemschutz Zug 1" className="h-8 text-sm" /> +
+ + {/* Spalten-Editor */} +
+ + {newColumns.map((c, i) => ( +
+ updateColumn(i, { label: e.target.value })} + placeholder={`Spalte ${i + 1}`} + className="h-7 text-xs flex-1" + /> + + +
+ ))} + {newColumns.length < 8 && ( + + )} +
+ + +
+ )} + + {error &&

{error}

} + + + + + +
+
+ ) +} diff --git a/src/components/journal/modules/pendenzen-module.tsx b/src/components/journal/modules/pendenzen-module.tsx new file mode 100644 index 0000000..f983559 --- /dev/null +++ b/src/components/journal/modules/pendenzen-module.tsx @@ -0,0 +1,108 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Plus, Trash2, Check } from 'lucide-react' +import { type JournalPendenz, formatTime } from '@/components/journal/types' + +interface PendenzenModuleProps { + items: JournalPendenz[] + canEdit: boolean + onAdd: (what: string, who: string, whenHow: string) => void + onToggle: (p: JournalPendenz) => void + onDelete: (id: string) => void +} + +/** Pendenzen — eingebautes Cockpit-Modul (Was/Wer/Wann mit Erledigt-Haken) */ +export function PendenzenModule({ items, canEdit, onAdd, onToggle, onDelete }: PendenzenModuleProps) { + const [what, setWhat] = useState('') + const [who, setWho] = useState('') + const [whenHow, setWhenHow] = useState('') + + const submit = () => { + if (!what.trim()) return + onAdd(what.trim(), who.trim(), whenHow.trim()) + setWhat('') + setWho('') + setWhenHow('') + } + + return ( + <> + {items.length === 0 ? ( +
+ Keine Pendenzen +
+ ) : ( +
+ {items.map((p) => ( +
+ +
+

+ {p.what} + {p.done && p.doneAt && ( + + (erledigt um {formatTime(p.doneAt)}) + + )} +

+
+ {p.who && Wer: {p.who}} + {p.whenHow && Wann: {p.whenHow}} +
+
+ {canEdit && ( + + )} +
+ ))} +
+ )} + {canEdit && ( +
+
+ setWhat(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && submit()} + className="flex-1 h-6 text-[11px]" + /> + setWho(e.target.value)} + className="w-14 h-6 text-[11px]" + /> + setWhenHow(e.target.value)} + className="w-14 h-6 text-[11px]" + /> + +
+
+ )} + + ) +} diff --git a/src/components/journal/modules/soma-module.tsx b/src/components/journal/modules/soma-module.tsx new file mode 100644 index 0000000..a5797a5 --- /dev/null +++ b/src/components/journal/modules/soma-module.tsx @@ -0,0 +1,99 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Plus, Trash2, Check } from 'lucide-react' +import { type JournalCheckItem, formatTime, formatDateTime } from '@/components/journal/types' + +interface SomaModuleProps { + items: JournalCheckItem[] + canEdit: boolean + onToggle: (item: JournalCheckItem, field: 'confirmed' | 'ok') => void + onAdd: (label: string) => void + onDelete: (itemId: string) => void +} + +/** SOMA-Checkliste — eingebautes Cockpit-Modul (JA/Ok mit Zeitstempel) */ +export function SomaModule({ items, canEdit, onToggle, onAdd, onDelete }: SomaModuleProps) { + const [newLabel, setNewLabel] = useState('') + + const submit = () => { + if (!newLabel.trim()) return + onAdd(newLabel.trim()) + setNewLabel('') + } + + return ( + <> + {/* Spalten-Kopf */} +
+
+
JA
+
Ok
+
+
+ {items.map((item, idx) => ( +
+
+ {item.label} + {item.confirmedAt && item.confirmed && ( + {formatTime(item.confirmedAt)} + )} + {canEdit && ( + + )} +
+
+ +
+
+ +
+
+ ))} +
+ {canEdit && ( +
+ setNewLabel(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && submit()} + className="h-6 text-[11px]" + /> + +
+ )} + + ) +} diff --git a/src/components/journal/modules/table-module.tsx b/src/components/journal/modules/table-module.tsx new file mode 100644 index 0000000..4376ec2 --- /dev/null +++ b/src/components/journal/modules/table-module.tsx @@ -0,0 +1,209 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Plus, Trash2, Check, Loader2 } from 'lucide-react' +import type { CockpitModule, ModuleColumn } from '@/lib/modules' + +interface ModuleItemRow { + id: string + data: Record + createdAt: string +} + +interface TableModuleProps { + projectId: string + module: CockpitModule + canEdit: boolean + /** Live-Sync: andere Clients über Änderung informieren */ + notifyChanged: () => void +} + +function formatTime(dateStr: string) { + return new Date(dateStr).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) +} + +/** + * Generisches Tabellen-Modul des Modul-Baukastens. + * Rendert beliebige Spalten (text / check / time) gemäss Modul-Konfiguration. + * Daten liegen als JSON-Zeilen in module_items (generische API). + */ +export function TableModule({ projectId, module, canEdit, notifyChanged }: TableModuleProps) { + const [items, setItems] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [draft, setDraft] = useState>({}) + + const columns: ModuleColumn[] = module.columns || [] + const textColumns = columns.filter(c => c.type === 'text') + + const load = useCallback(async () => { + try { + const res = await fetch(`/api/projects/${projectId}/modules/${module.id}/items`) + if (res.ok) { + const data = await res.json() + setItems(data.items || []) + } + } catch (err) { + console.error(`Failed to load module ${module.id}:`, err) + } finally { + setIsLoading(false) + } + }, [projectId, module.id]) + + useEffect(() => { load() }, [load]) + + // Live-Refresh, wenn andere Clients Journal-Änderungen melden + useEffect(() => { + const onRefresh = () => load() + window.addEventListener('journal-refresh', onRefresh) + return () => window.removeEventListener('journal-refresh', onRefresh) + }, [load]) + + const addRow = useCallback(async () => { + const hasContent = textColumns.some(c => (draft[c.key] || '').trim()) + if (!hasContent) return + const data: Record = {} + for (const c of textColumns) { + const v = (draft[c.key] || '').trim() + if (v) data[c.key] = v + } + try { + const res = await fetch(`/api/projects/${projectId}/modules/${module.id}/items`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data }), + }) + if (res.ok) { + const item = await res.json() + setItems(prev => [...prev, item]) + setDraft({}) + notifyChanged() + } + } catch (err) { + console.error('Failed to add module item:', err) + } + }, [projectId, module.id, draft, textColumns, notifyChanged]) + + const toggleCheck = useCallback(async (item: ModuleItemRow, key: string) => { + if (!canEdit) return + const next = !item.data[key] + try { + const res = await fetch(`/api/projects/${projectId}/modules/${module.id}/items/${item.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: { [key]: next, [`${key}At`]: next ? new Date().toISOString() : null } }), + }) + if (res.ok) { + const updated = await res.json() + setItems(prev => prev.map(i => i.id === updated.id ? updated : i)) + notifyChanged() + } + } catch (err) { + console.error('Failed to toggle module item:', err) + } + }, [projectId, module.id, canEdit, notifyChanged]) + + const deleteRow = useCallback(async (itemId: string) => { + try { + await fetch(`/api/projects/${projectId}/modules/${module.id}/items/${itemId}`, { method: 'DELETE' }) + setItems(prev => prev.filter(i => i.id !== itemId)) + notifyChanged() + } catch (err) { + console.error('Failed to delete module item:', err) + } + }, [projectId, module.id, notifyChanged]) + + // Grid-Template aus Spalten ableiten (+ Lösch-Spalte) + const gridCols = columns.map(c => + c.type === 'check' ? '32px' : c.type === 'time' ? '52px' : 'minmax(0,1fr)' + ).join(' ') + (canEdit ? ' 24px' : '') + + return ( +
+ {/* Spalten-Kopf */} +
+ {columns.map(c => ( +
{c.label}
+ ))} + {canEdit &&
} +
+ + {isLoading ? ( +
+ Laden… +
+ ) : items.length === 0 ? ( +
Keine Einträge
+ ) : ( +
+ {items.map((item, idx) => ( +
+ {columns.map(c => { + if (c.type === 'check') { + const checked = !!item.data[c.key] + const at = item.data[`${c.key}At`] + return ( +
+ +
+ ) + } + if (c.type === 'time') { + // Zeit-Spalte: automatisch = Erstellzeit (oder explizit gesetzter Wert) + const t = item.data[c.key] || item.createdAt + return ( +
+ {t ? formatTime(t) : '–'} +
+ ) + } + return ( +
+ {item.data[c.key] || } +
+ ) + })} + {canEdit && ( + + )} +
+ ))} +
+ )} + + {/* Neue Zeile */} + {canEdit && ( +
+ {textColumns.map((c, i) => ( + setDraft(prev => ({ ...prev, [c.key]: e.target.value }))} + onKeyDown={(e) => e.key === 'Enter' && addRow()} + className={`h-6 text-[11px] ${i === 0 ? 'flex-1 min-w-0' : 'w-20 shrink-0'}`} + /> + ))} + +
+ )} +
+ ) +} diff --git a/src/components/journal/types.ts b/src/components/journal/types.ts new file mode 100644 index 0000000..86867b7 --- /dev/null +++ b/src/components/journal/types.ts @@ -0,0 +1,40 @@ +/** Gemeinsame Typen der Cockpit-Module */ + +export interface JournalEntry { + id: string + time: string + what: string + who: string | null + done: boolean + doneAt: string | null + isCorrected?: boolean + correctionOfId?: string | null +} + +export interface JournalCheckItem { + id: string + label: string + confirmed: boolean + confirmedAt: string | null + ok: boolean + okAt: string | null +} + +export interface JournalPendenz { + id: string + what: string + who: string | null + whenHow: string | null + done: boolean + doneAt: string | null +} + +export function formatTime(dateStr: string) { + return new Date(dateStr).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) +} + +export function formatDateTime(dateStr: string) { + const d = new Date(dateStr) + return d.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) + ' ' + + d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) +} diff --git a/src/lib/modules.ts b/src/lib/modules.ts new file mode 100644 index 0000000..bda3015 --- /dev/null +++ b/src/lib/modules.ts @@ -0,0 +1,152 @@ +/** + * Modul-Baukasten für das Einsatz-Cockpit (Journal-Bereich). + * + * Jede Feuerwehr stellt sich ihr Cockpit selbst zusammen: + * - Eingebaute Module: Journal, SOMA, Pendenzen + * - Generische Tabellen-Module ("table"): frei definierbare Spalten + * (Checklisten, Atemschutz-Überwachung, Kräfte vor Ort, Lagemeldungen …) + * + * Die Konfiguration liegt pro Mandant in `tenants.modulesConfig` (JSONB). + * Ohne Konfiguration gelten die DEFAULT_MODULES (heutiges Verhalten). + */ + +export type ModuleColumnType = 'text' | 'check' | 'time' + +export interface ModuleColumn { + key: string + label: string + type: ModuleColumnType + /** Optionale Tailwind-Breite für die Spalte, z.B. 'w-20' */ + width?: string +} + +export type ModuleType = 'journal' | 'soma' | 'pendenzen' | 'table' + +export interface CockpitModule { + /** Eindeutig pro Mandant. Eingebaute Module: 'journal' | 'soma' | 'pendenzen' */ + id: string + type: ModuleType + name: string + /** Lucide-Icon-Schlüssel (siehe MODULE_ICONS im Cockpit) */ + icon: string + /** 'main' = breite Hauptspalte, 'side' = schmale Seitenspalte */ + slot: 'main' | 'side' + enabled: boolean + sortOrder: number + /** Nur für type 'table': Spaltendefinition */ + columns?: ModuleColumn[] +} + +/** Standard-Cockpit — entspricht dem bisherigen festen Aufbau */ +export const DEFAULT_MODULES: CockpitModule[] = [ + { id: 'journal', type: 'journal', name: 'Journal', icon: 'clipboard-list', slot: 'main', enabled: true, sortOrder: 0 }, + { id: 'soma', type: 'soma', name: 'SOMA', icon: 'alert-triangle', slot: 'side', enabled: true, sortOrder: 1 }, + { id: 'pendenzen', type: 'pendenzen', name: 'Pendenzen', icon: 'check-square', slot: 'side', enabled: true, sortOrder: 2 }, +] + +/** Vorlagen für neue Tabellen-Module — ein Klick, fertig konfiguriert */ +export const MODULE_PRESETS: { key: string; name: string; icon: string; slot: 'main' | 'side'; description: string; columns: ModuleColumn[] }[] = [ + { + key: 'checkliste', + name: 'Checkliste', + icon: 'list-checks', + slot: 'side', + description: 'Einfache Abhak-Liste (z.B. Rückzugskontrolle, Alarmstufen)', + columns: [ + { key: 'label', label: 'Punkt', type: 'text' }, + { key: 'done', label: 'Ok', type: 'check', width: 'w-10' }, + ], + }, + { + key: 'atemschutz', + name: 'Atemschutz-Überwachung', + icon: 'shield', + slot: 'side', + description: 'Trupps mit Druck und Zeit überwachen', + columns: [ + { key: 'trupp', label: 'Trupp', type: 'text' }, + { key: 'druck', label: 'Druck (bar)', type: 'text', width: 'w-20' }, + { key: 'start', label: 'Eingesetzt', type: 'time', width: 'w-16' }, + { key: 'out', label: 'Draussen', type: 'check', width: 'w-10' }, + ], + }, + { + key: 'kraefte', + name: 'Kräfte vor Ort', + icon: 'users', + slot: 'side', + description: 'Wer/was ist am Einsatzort (AdF, Fahrzeuge, Partner)', + columns: [ + { key: 'name', label: 'Name / Mittel', type: 'text' }, + { key: 'funktion', label: 'Funktion', type: 'text', width: 'w-24' }, + { key: 'seit', label: 'Vor Ort seit', type: 'time', width: 'w-16' }, + { key: 'abgemeldet', label: 'Weg', type: 'check', width: 'w-10' }, + ], + }, + { + key: 'lagemeldung', + name: 'Lagemeldungen', + icon: 'radio', + slot: 'main', + description: 'Strukturierte Lagemeldungen (Zeit / Lage / Massnahmen / Bedarf)', + columns: [ + { key: 'zeit', label: 'Zeit', type: 'time', width: 'w-16' }, + { key: 'lage', label: 'Lage', type: 'text' }, + { key: 'massnahmen', label: 'Massnahmen', type: 'text' }, + { key: 'bedarf', label: 'Bedarf', type: 'text' }, + ], + }, + { + key: 'leer', + name: 'Eigenes Modul', + icon: 'table', + slot: 'side', + description: 'Leere Tabelle — Spalten komplett selbst definieren', + columns: [ + { key: 'text', label: 'Text', type: 'text' }, + ], + }, +] + +/** + * Konfiguration validieren/reparieren: eingebaute Module sicherstellen, + * Unbekanntes verwerfen, stabil sortieren. + */ +export function normalizeModules(raw: unknown): CockpitModule[] { + if (!Array.isArray(raw) || raw.length === 0) return DEFAULT_MODULES + + const seen = new Set() + const result: CockpitModule[] = [] + + for (const m of raw as any[]) { + if (!m || typeof m.id !== 'string' || seen.has(m.id)) continue + const type: ModuleType = ['journal', 'soma', 'pendenzen', 'table'].includes(m.type) ? m.type : 'table' + if (type === 'table' && (!Array.isArray(m.columns) || m.columns.length === 0)) continue + seen.add(m.id) + result.push({ + id: m.id, + type, + name: typeof m.name === 'string' && m.name.trim() ? m.name.trim().slice(0, 60) : 'Modul', + icon: typeof m.icon === 'string' ? m.icon : 'table', + slot: m.slot === 'main' ? 'main' : 'side', + enabled: m.enabled !== false, + sortOrder: typeof m.sortOrder === 'number' ? m.sortOrder : result.length, + columns: type === 'table' + ? (m.columns as any[]).slice(0, 8).map((c, i) => ({ + key: typeof c.key === 'string' && c.key ? c.key : `col${i}`, + label: typeof c.label === 'string' ? c.label.slice(0, 40) : `Spalte ${i + 1}`, + type: (['text', 'check', 'time'] as const).includes(c.type) ? c.type : 'text', + width: typeof c.width === 'string' ? c.width : undefined, + })) + : undefined, + }) + } + + // Eingebaute Module ergänzen, falls sie in der Config fehlen (dann deaktiviert lassen? → aktiv, + // damit ein kaputter Config-Stand nie das Journal verschwinden lässt) + for (const def of DEFAULT_MODULES) { + if (!seen.has(def.id)) result.push({ ...def, sortOrder: result.length }) + } + + return result.sort((a, b) => a.sortOrder - b.sortOrder) +}