Files
Lageplan/src/lib/modules.ts
Pepe Ziberi 2dbb2204f6 feat(cockpit): Modul-Baukasten – Einsatz-Cockpit frei zusammenstellbar (v1.5.0)
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) <noreply@anthropic.com>
2026-07-19 18:15:34 +02:00

153 lines
5.6 KiB
TypeScript

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