/** * 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) }