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:
99
src/components/journal/modules/soma-module.tsx
Normal file
99
src/components/journal/modules/soma-module.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user