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>
This commit is contained in:
Pepe Ziberi
2026-07-19 18:15:34 +02:00
parent 6eab656ae6
commit 2dbb2204f6
14 changed files with 1412 additions and 412 deletions

View File

@@ -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 */}
<div className="grid grid-cols-[1fr_28px_28px] print:grid-cols-[1fr_24px_24px] text-[10px] font-semibold text-muted-foreground border-b border-border/50">
<div className="px-2 py-1"></div>
<div className="px-0.5 py-1 text-center text-red-600">JA</div>
<div className="px-0.5 py-1 text-center text-red-500">Ok</div>
</div>
<div className="divide-y divide-border/50">
{items.map((item, idx) => (
<div key={item.id} className={`grid grid-cols-[1fr_28px_28px] print:grid-cols-[1fr_24px_24px] items-center text-xs md:text-sm print:text-[9px] group ${idx % 2 === 0 ? '' : 'bg-stone-50 dark:bg-muted/20'}`}>
<div className="px-2 py-1.5 flex items-center gap-1 min-w-0">
<span className="truncate">{item.label}</span>
{item.confirmedAt && item.confirmed && (
<span className="text-[9px] text-red-500 shrink-0">{formatTime(item.confirmedAt)}</span>
)}
{canEdit && (
<button
onClick={() => onDelete(item.id)}
className="ml-auto opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive shrink-0 print:hidden"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
<div className="flex items-center justify-center py-1.5">
<button
onClick={() => canEdit && onToggle(item, 'confirmed')}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
item.confirmed
? 'bg-blue-500 border-blue-500 text-white'
: 'border-muted-foreground/30 hover:border-blue-400'
}`}
title={item.confirmedAt ? `JA: ${formatDateTime(item.confirmedAt)}` : 'JA'}
disabled={!canEdit}
>
{item.confirmed && <Check className="w-3 h-3" />}
</button>
</div>
<div className="flex items-center justify-center py-1.5">
<button
onClick={() => canEdit && onToggle(item, 'ok')}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
item.ok
? 'bg-green-500 border-green-500 text-white'
: 'border-muted-foreground/30 hover:border-green-400'
}`}
title={item.okAt ? `Ok: ${formatDateTime(item.okAt)}` : 'Ok'}
disabled={!canEdit}
>
{item.ok && <Check className="w-3 h-3" />}
</button>
</div>
</div>
))}
</div>
{canEdit && (
<div className="flex gap-1 px-2 py-1.5 border-t border-border/50 print:hidden">
<Input
placeholder="Neuer Punkt..."
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
className="h-6 text-[11px]"
/>
<Button size="sm" variant="ghost" onClick={submit} disabled={!newLabel.trim()} className="h-6 px-1.5">
<Plus className="w-3 h-3" />
</Button>
</div>
)}
</>
)
}