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,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 })
}
}

View File

@@ -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 })
}
}

View File

@@ -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 })
}
}

View File

@@ -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<string, typeof AlertTriangle> = {
'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 (
<div className="border-b border-border">
<div className="px-2 py-1.5 bg-stone-100 dark:bg-muted/50 border-b-2 border-red-400 dark:border-red-800">
<h3 className="font-semibold text-xs md:text-sm print:text-[10px] flex items-center gap-1 text-red-800 dark:text-red-400">
<Icon className="w-3.5 h-3.5" />
{name}
{count && <span className="text-[10px] text-muted-foreground font-normal">({count})</span>}
</h3>
</div>
{children}
</div>
)
}
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<CockpitModule[]>(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<HTMLDivElement>(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'}
</h2>
<div className="flex gap-1.5 print:hidden">
{canManageModules && (
<Button variant="outline" size="sm" onClick={() => setShowModuleManager(true)} title="Cockpit-Module verwalten">
<Blocks className="w-4 h-4 md:mr-1.5" />
<span className="hidden md:inline">Module</span>
</Button>
)}
<Button
variant="outline"
size="sm"
@@ -502,19 +518,19 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-1 text-sm print:text-xs">
<div>
<span className="text-muted-foreground text-xs print:text-[9px]">Einsatz</span>
<p className="font-semibold truncate">{einsatzNr && <span className="font-mono text-xs bg-primary/10 text-primary px-1 py-0.5 rounded mr-1">{einsatzNr}</span>}{projectTitle || '\u2013'}</p>
<p className="font-semibold truncate">{einsatzNr && <span className="font-mono text-xs bg-primary/10 text-primary px-1 py-0.5 rounded mr-1">{einsatzNr}</span>}{projectTitle || ''}</p>
</div>
<div>
<span className="text-muted-foreground text-xs print:text-[9px]">Standort</span>
<p className="font-semibold truncate">{projectLocation || '\u2013'}</p>
<p className="font-semibold truncate">{projectLocation || ''}</p>
</div>
<div>
<span className="text-muted-foreground text-xs print:text-[9px]">Einsatzleiter</span>
<p className="font-semibold truncate">{einsatzleiter || '\u2013'}</p>
<p className="font-semibold truncate">{einsatzleiter || ''}</p>
</div>
<div>
<span className="text-muted-foreground text-xs print:text-[9px]">Journalführer</span>
<p className="font-semibold truncate">{journalfuehrer || '\u2013'}</p>
<p className="font-semibold truncate">{journalfuehrer || ''}</p>
</div>
</div>
@@ -543,372 +559,265 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
)}
</div>
{/* Main content: side-by-side on large screens */}
{/* Main content: side-by-side on large screens — Module gemäss Mandanten-Konfiguration */}
<div className="flex flex-col lg:flex-row print:flex-row">
{/* Journal entries */}
{/* Hauptbereich: Journal + weitere main-Module */}
<div className="flex-1 min-w-0">
{/* Table header */}
<div className="grid grid-cols-[55px_1fr_70px_40px_30px] md:grid-cols-[70px_1fr_90px_50px_40px] print:grid-cols-[60px_1fr_70px_40px] gap-px text-[11px] md:text-xs font-semibold sticky top-0 z-10 shadow-sm">
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground border-b border-red-200 dark:border-border">Zeit</div>
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground border-b border-red-200 dark:border-border">Was</div>
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground border-b border-red-200 dark:border-border">Wer</div>
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground text-center border-b border-red-200 dark:border-border">Ok</div>
<div className="bg-stone-100 dark:bg-muted px-1 py-1.5 print:hidden border-b border-red-200 dark:border-border"></div>
</div>
{mainModules.map(mod => {
if (mod.type === 'journal') {
return (
<div key={mod.id}>
{/* Table header */}
<div className="grid grid-cols-[55px_1fr_70px_40px_30px] md:grid-cols-[70px_1fr_90px_50px_40px] print:grid-cols-[60px_1fr_70px_40px] gap-px text-[11px] md:text-xs font-semibold sticky top-0 z-10 shadow-sm">
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground border-b border-red-200 dark:border-border">Zeit</div>
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground border-b border-red-200 dark:border-border">Was</div>
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground border-b border-red-200 dark:border-border">Wer</div>
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground text-center border-b border-red-200 dark:border-border">Ok</div>
<div className="bg-stone-100 dark:bg-muted px-1 py-1.5 print:hidden border-b border-red-200 dark:border-border"></div>
</div>
{isLoading ? (
<div className="flex items-center justify-center p-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Lade Journal...
</div>
) : entries.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">
Noch keine Einträge.
</div>
) : (
<div className="divide-y divide-border">
{entries.map((entry, idx) => (
<div
key={entry.id}
className={`grid grid-cols-[55px_1fr_70px_40px_30px] md:grid-cols-[70px_1fr_90px_50px_40px] print:grid-cols-[60px_1fr_70px_40px] gap-px text-[11px] md:text-xs print:text-[9px] ${entry.done ? 'bg-green-50 dark:bg-green-950/20 print:bg-green-50' : idx % 2 === 0 ? 'bg-white dark:bg-card' : 'bg-stone-50 dark:bg-muted/30'}`}
>
<div className="px-2 py-1.5 print:py-1 font-mono tabular-nums text-muted-foreground">
{formatTime(entry.time)}
</div>
<div className={`px-2 py-1.5 print:py-1 break-words ${entry.done ? 'text-muted-foreground' : ''} ${(entry as any).isCorrected ? 'line-through opacity-50' : ''} ${(entry as any).correctionOfId ? 'text-amber-700 dark:text-amber-400 italic' : ''}`}>
{entry.what}
{(entry as any).isCorrected && (
<span className="ml-1.5 text-red-500 text-[10px] print:text-[8px] font-medium no-underline">
(korrigiert)
</span>
)}
{entry.done && entry.doneAt && (
<span className="ml-1.5 text-green-600 text-[10px] print:text-[8px] font-medium">
(erledigt um {formatTime(entry.doneAt)})
</span>
)}
</div>
<div className="px-2 py-1.5 print:py-1 text-muted-foreground truncate">
{entry.who || '\u2013'}
</div>
<div className="px-1 py-1.5 print:py-1 flex items-center justify-center">
{canEdit && !(entry as any).isCorrected ? (
<button
onClick={() => toggleEntryDone(entry)}
className={`w-4 h-4 md:w-5 md:h-5 rounded border-2 flex items-center justify-center transition-colors ${
entry.done
? 'bg-green-500 border-green-500 text-white'
: 'border-muted-foreground/30 hover:border-primary'
}`}
>
{entry.done && <Check className="w-2.5 h-2.5 md:w-3 md:h-3" />}
</button>
) : (
entry.done ? <Check className="w-3 h-3 text-green-500 opacity-40" /> : (entry as any).isCorrected ? <span className="w-4 h-4 md:w-5 md:h-5 rounded border-2 border-muted-foreground/20 bg-muted/30" /> : null
)}
</div>
<div className="px-1 py-1.5 flex items-center justify-center print:hidden">
{canEdit && !(entry as any).isCorrected && !(entry as any).correctionOfId && (
<button
onClick={() => { setCorrectionEntryId(entry.id); setCorrectionText(entry.what) }}
className="text-muted-foreground hover:text-amber-600 p-0.5"
title="Korrektur erstellen"
>
<Pencil className="w-3 h-3" />
</button>
)}
</div>
</div>
))}
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center p-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Lade Journal...
</div>
) : entries.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">
Noch keine Einträge.
</div>
) : (
<div className="divide-y divide-border">
{entries.map((entry, idx) => (
<div
key={entry.id}
className={`grid grid-cols-[55px_1fr_70px_40px_30px] md:grid-cols-[70px_1fr_90px_50px_40px] print:grid-cols-[60px_1fr_70px_40px] gap-px text-[11px] md:text-xs print:text-[9px] ${entry.done ? 'bg-green-50 dark:bg-green-950/20 print:bg-green-50' : idx % 2 === 0 ? 'bg-white dark:bg-card' : 'bg-stone-50 dark:bg-muted/30'}`}
>
<div className="px-2 py-1.5 print:py-1 font-mono tabular-nums text-muted-foreground">
{formatTime(entry.time)}
</div>
<div className={`px-2 py-1.5 print:py-1 break-words ${entry.done ? 'text-muted-foreground' : ''} ${(entry as any).isCorrected ? 'line-through opacity-50' : ''} ${(entry as any).correctionOfId ? 'text-amber-700 dark:text-amber-400 italic' : ''}`}>
{entry.what}
{(entry as any).isCorrected && (
<span className="ml-1.5 text-red-500 text-[10px] print:text-[8px] font-medium no-underline">
(korrigiert)
</span>
)}
{entry.done && entry.doneAt && (
<span className="ml-1.5 text-green-600 text-[10px] print:text-[8px] font-medium">
(erledigt um {formatTime(entry.doneAt)})
</span>
)}
</div>
<div className="px-2 py-1.5 print:py-1 text-muted-foreground truncate">
{entry.who || ''}
</div>
<div className="px-1 py-1.5 print:py-1 flex items-center justify-center">
{canEdit && !(entry as any).isCorrected ? (
<button
onClick={() => toggleEntryDone(entry)}
className={`w-4 h-4 md:w-5 md:h-5 rounded border-2 flex items-center justify-center transition-colors ${
entry.done
? 'bg-green-500 border-green-500 text-white'
: 'border-muted-foreground/30 hover:border-primary'
}`}
>
{entry.done && <Check className="w-2.5 h-2.5 md:w-3 md:h-3" />}
</button>
) : (
entry.done ? <Check className="w-3 h-3 text-green-500 opacity-40" /> : (entry as any).isCorrected ? <span className="w-4 h-4 md:w-5 md:h-5 rounded border-2 border-muted-foreground/20 bg-muted/30" /> : null
)}
</div>
<div className="px-1 py-1.5 flex items-center justify-center print:hidden">
{canEdit && !(entry as any).isCorrected && !(entry as any).correctionOfId && (
<button
onClick={() => { setCorrectionEntryId(entry.id); setCorrectionText(entry.what) }}
className="text-muted-foreground hover:text-amber-600 p-0.5"
title="Korrektur erstellen"
>
<Pencil className="w-3 h-3" />
</button>
)}
</div>
</div>
))}
</div>
)}
{/* Correction inline form */}
{correctionEntryId && (
<div className="border-t-2 border-amber-400 p-2 bg-amber-50 dark:bg-amber-950/30 print:hidden">
<p className="text-xs font-semibold text-amber-700 dark:text-amber-400 mb-1.5">Korrektur erstellen:</p>
<div className="flex gap-1.5">
<Input
placeholder="Korrekturtext..."
value={correctionText}
onChange={(e) => setCorrectionText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && correctEntry()}
className="flex-1 h-7 text-xs border-amber-300"
autoFocus
/>
<Button size="sm" onClick={correctEntry} disabled={!correctionText.trim()} className="h-7 px-2 bg-amber-600 hover:bg-amber-700">
<Check className="w-3.5 h-3.5" />
</Button>
<Button size="sm" variant="outline" onClick={() => { setCorrectionEntryId(null); setCorrectionText('') }} className="h-7 px-2">
</Button>
</div>
</div>
)}
{/* New entry form */}
{canEdit && (
<div className="border-t border-border p-2 bg-white dark:bg-card print:hidden sticky bottom-0 shadow-[0_-2px_4px_rgba(0,0,0,0.05)]">
<div className="flex gap-1.5">
<div className="flex items-center gap-1 text-[11px] text-muted-foreground font-mono w-[55px] md:w-[70px] shrink-0">
<Clock className="w-3 h-3" />
{new Date().toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}
</div>
<div className="flex-1 relative">
{/* iPhone-style suggestion bar */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div ref={suggestionsRef} className="absolute bottom-full left-0 right-0 mb-1 z-50">
<div className="flex gap-1 overflow-x-auto scrollbar-hide py-1 px-0.5">
{filteredSuggestions.map((s, i) => {
const lower = newWhat.toLowerCase()
const idx = s.toLowerCase().indexOf(lower)
return (
<button
key={i}
type="button"
className={`shrink-0 px-2.5 py-1 text-xs rounded-full border transition-all whitespace-nowrap ${
i === selectedSuggestionIdx
? 'bg-red-600 text-white border-red-600'
: 'bg-white dark:bg-card border-border text-foreground hover:bg-red-50 hover:border-red-200 shadow-sm'
}`}
onMouseDown={(e) => {
e.preventDefault()
setNewWhat(s)
setShowSuggestions(false)
setSelectedSuggestionIdx(-1)
}}
>
{idx >= 0 ? (
<>{s.slice(0, idx)}<strong className="font-bold">{s.slice(idx, idx + lower.length)}</strong>{s.slice(idx + lower.length)}</>
) : s}
</button>
)
})}
{/* Correction inline form */}
{correctionEntryId && (
<div className="border-t-2 border-amber-400 p-2 bg-amber-50 dark:bg-amber-950/30 print:hidden">
<p className="text-xs font-semibold text-amber-700 dark:text-amber-400 mb-1.5">Korrektur erstellen:</p>
<div className="flex gap-1.5">
<Input
placeholder="Korrekturtext..."
value={correctionText}
onChange={(e) => setCorrectionText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && correctEntry()}
className="flex-1 h-7 text-xs border-amber-300"
autoFocus
/>
<Button size="sm" onClick={correctEntry} disabled={!correctionText.trim()} className="h-7 px-2 bg-amber-600 hover:bg-amber-700">
<Check className="w-3.5 h-3.5" />
</Button>
<Button size="sm" variant="outline" onClick={() => { setCorrectionEntryId(null); setCorrectionText('') }} className="h-7 px-2">
</Button>
</div>
</div>
)}
<Input
placeholder="Was..."
value={newWhat}
onChange={(e) => {
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"
/>
</div>
<Input
placeholder="Wer"
value={newWho}
onChange={(e) => setNewWho(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addEntry()}
className="w-16 md:w-20 h-7 text-xs"
/>
<Button size="sm" onClick={addEntry} disabled={!newWhat.trim()} className="h-7 px-2">
<Plus className="w-3.5 h-3.5" />
</Button>
</div>
</div>
)}
</div>
{/* Right column: SOMA + Pendenzen */}
<div className="w-full lg:w-72 xl:w-80 print:w-[280px] border-t lg:border-t-0 lg:border-l-2 lg:border-l-red-200 dark:lg:border-l-border border-border bg-white dark:bg-card shrink-0 shadow-sm">
{/* SOMA Checklist */}
<div className="border-b border-border">
<div className="px-2 py-1.5 bg-stone-100 dark:bg-muted/50 border-b-2 border-red-400 dark:border-red-800">
<h3 className="font-semibold text-xs md:text-sm print:text-[10px] flex items-center gap-1 text-red-800 dark:text-red-400">
<AlertTriangle className="w-3.5 h-3.5" />
SOMA
<span className="text-[10px] text-muted-foreground font-normal">
({checkItems.filter(c => c.confirmed).length}/{checkItems.length})
</span>
</h3>
</div>
{/* SOMA table with column headers */}
<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">
{checkItems.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={() => deleteCheckItem(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 && toggleCheck(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 && toggleCheck(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={newCheckLabel}
onChange={(e) => setNewCheckLabel(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addCheckItem()}
className="h-6 text-[11px]"
/>
<Button size="sm" variant="ghost" onClick={addCheckItem} disabled={!newCheckLabel.trim()} className="h-6 px-1.5">
<Plus className="w-3 h-3" />
</Button>
</div>
)}
</div>
{/* Pendenzen */}
<div>
<div className="px-2 py-1.5 bg-stone-100 dark:bg-muted/50 border-b-2 border-red-400 dark:border-red-800">
<h3 className="font-semibold text-xs md:text-sm print:text-[10px] flex items-center gap-1 text-red-800 dark:text-red-400">
<CheckSquare className="w-3.5 h-3.5" />
Pendenzen
<span className="text-[10px] text-muted-foreground font-normal">
({pendenzen.filter(p => p.done).length}/{pendenzen.length})
</span>
</h3>
</div>
{pendenzen.length === 0 ? (
<div className="text-center text-muted-foreground py-4 text-xs">
Keine Pendenzen
</div>
) : (
<div className="divide-y divide-border/50">
{pendenzen.map((p) => (
<div key={p.id} className={`flex items-start gap-1.5 px-2 py-1.5 text-xs print:text-[9px] group ${p.done ? 'bg-green-50 dark:bg-green-950/20 print:bg-green-50' : ''}`}>
<button
onClick={() => canEdit && togglePendenzDone(p)}
className={`w-4 h-4 mt-0.5 rounded border-2 flex items-center justify-center shrink-0 transition-colors ${
p.done
? 'bg-green-500 border-green-500 text-white'
: 'border-muted-foreground/30 hover:border-primary'
}`}
disabled={!canEdit}
>
{p.done && <Check className="w-2.5 h-2.5" />}
</button>
<div className="flex-1 min-w-0">
<p className={`${p.done ? 'text-muted-foreground' : ''}`}>
{p.what}
{p.done && p.doneAt && (
<span className="ml-1 text-green-600 text-[10px] print:text-[8px] font-medium no-underline">
(erledigt um {formatTime(p.doneAt)})
</span>
)}
</p>
<div className="flex gap-2 text-[10px] text-muted-foreground">
{p.who && <span>Wer: {p.who}</span>}
{p.whenHow && <span>Wann: {p.whenHow}</span>}
{/* New entry form */}
{canEdit && (
<div className="border-t border-border p-2 bg-white dark:bg-card print:hidden sticky bottom-0 shadow-[0_-2px_4px_rgba(0,0,0,0.05)]">
<div className="flex gap-1.5">
<div className="flex items-center gap-1 text-[11px] text-muted-foreground font-mono w-[55px] md:w-[70px] shrink-0">
<Clock className="w-3 h-3" />
{new Date().toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}
</div>
<div className="flex-1 relative">
{/* iPhone-style suggestion bar */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div ref={suggestionsRef} className="absolute bottom-full left-0 right-0 mb-1 z-50">
<div className="flex gap-1 overflow-x-auto scrollbar-hide py-1 px-0.5">
{filteredSuggestions.map((s, i) => {
const lower = newWhat.toLowerCase()
const idx = s.toLowerCase().indexOf(lower)
return (
<button
key={i}
type="button"
className={`shrink-0 px-2.5 py-1 text-xs rounded-full border transition-all whitespace-nowrap ${
i === selectedSuggestionIdx
? 'bg-red-600 text-white border-red-600'
: 'bg-white dark:bg-card border-border text-foreground hover:bg-red-50 hover:border-red-200 shadow-sm'
}`}
onMouseDown={(e) => {
e.preventDefault()
setNewWhat(s)
setShowSuggestions(false)
setSelectedSuggestionIdx(-1)
}}
>
{idx >= 0 ? (
<>{s.slice(0, idx)}<strong className="font-bold">{s.slice(idx, idx + lower.length)}</strong>{s.slice(idx + lower.length)}</>
) : s}
</button>
)
})}
</div>
</div>
)}
<Input
placeholder="Was..."
value={newWhat}
onChange={(e) => {
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"
/>
</div>
<Input
placeholder="Wer"
value={newWho}
onChange={(e) => setNewWho(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addEntry()}
className="w-16 md:w-20 h-7 text-xs"
/>
<Button size="sm" onClick={addEntry} disabled={!newWhat.trim()} className="h-7 px-2">
<Plus className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{canEdit && (
<button
onClick={() => deletePendenz(p.id)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive shrink-0 mt-0.5 print:hidden"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
)}
{canEdit && (
<div className="border-t border-border p-1.5 print:hidden">
<div className="flex gap-1">
<Input
placeholder="Was..."
value={newPendWhat}
onChange={(e) => setNewPendWhat(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addPendenz()}
className="flex-1 h-6 text-[11px]"
/>
<Input
placeholder="Wer"
value={newPendWho}
onChange={(e) => setNewPendWho(e.target.value)}
className="w-14 h-6 text-[11px]"
/>
<Input
placeholder="Wann"
value={newPendWhen}
onChange={(e) => setNewPendWhen(e.target.value)}
className="w-14 h-6 text-[11px]"
/>
<Button size="sm" variant="ghost" onClick={addPendenz} disabled={!newPendWhat.trim()} className="h-6 px-1.5">
<Plus className="w-3 h-3" />
</Button>
)}
</div>
)
}
// Weitere main-Module (z.B. Lagemeldungen): breite Tabellen-Karte
return (
<div key={mod.id} className="border-t-4 border-stone-200 dark:border-border bg-white dark:bg-card">
<ModuleCard icon={mod.icon} name={mod.name}>
<TableModule projectId={projectId} module={mod} canEdit={canEdit} notifyChanged={notifyJournalChanged} />
</ModuleCard>
</div>
)}
</div>
)
})}
</div>
{/* Seitenspalte: SOMA, Pendenzen + eigene Module */}
{sideModules.length > 0 && (
<div className="w-full lg:w-72 xl:w-80 print:w-[280px] border-t lg:border-t-0 lg:border-l-2 lg:border-l-red-200 dark:lg:border-l-border border-border bg-white dark:bg-card shrink-0 shadow-sm">
{sideModules.map(mod => {
if (mod.type === 'soma') {
return (
<ModuleCard key={mod.id} icon={mod.icon} name={mod.name} count={`${checkItems.filter(c => c.confirmed).length}/${checkItems.length}`}>
<SomaModule
items={checkItems}
canEdit={canEdit}
onToggle={toggleCheck}
onAdd={addCheckItem}
onDelete={deleteCheckItem}
/>
</ModuleCard>
)
}
if (mod.type === 'pendenzen') {
return (
<ModuleCard key={mod.id} icon={mod.icon} name={mod.name} count={`${pendenzen.filter(p => p.done).length}/${pendenzen.length}`}>
<PendenzenModule
items={pendenzen}
canEdit={canEdit}
onAdd={addPendenz}
onToggle={togglePendenzDone}
onDelete={deletePendenz}
/>
</ModuleCard>
)
}
return (
<ModuleCard key={mod.id} icon={mod.icon} name={mod.name}>
<TableModule projectId={projectId} module={mod} canEdit={canEdit} notifyChanged={notifyJournalChanged} />
</ModuleCard>
)
})}
</div>
)}
</div>
</div>
{/* Modul-Verwaltung */}
<ModuleManagerDialog
open={showModuleManager}
onOpenChange={setShowModuleManager}
modules={modules}
onSaved={setModules}
/>
{/* Rapport Dialog */}
{showRapportDialog && projectId && (
<RapportDialog

View File

@@ -0,0 +1,261 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog'
import {
ArrowUp, ArrowDown, Plus, Trash2, Loader2, Blocks, X,
} from 'lucide-react'
import { MODULE_PRESETS, type CockpitModule, type ModuleColumn, type ModuleColumnType } from '@/lib/modules'
interface ModuleManagerDialogProps {
open: boolean
onOpenChange: (open: boolean) => 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<CockpitModule[]>(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<ModuleColumn[]>(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<ModuleColumn>) => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Blocks className="w-5 h-5" />
Cockpit-Module verwalten
</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground -mt-2">
Stelle das Einsatz-Cockpit für deine Feuerwehr zusammen. Gilt für alle Einsätze deiner Organisation.
</p>
{/* Modul-Liste */}
<div className="space-y-1.5">
{working.map((m, idx) => (
<div key={m.id} className={`flex items-center gap-2 rounded-lg border px-2.5 py-2 ${m.enabled ? 'bg-card' : 'bg-muted/40 opacity-60'}`}>
{/* An/aus */}
<button
onClick={() => toggle(idx)}
className={`relative w-9 h-5 rounded-full transition-colors shrink-0 ${m.enabled ? 'bg-green-500' : 'bg-muted-foreground/30'}`}
title={m.enabled ? 'Deaktivieren' : 'Aktivieren'}
>
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${m.enabled ? 'left-[18px]' : 'left-0.5'}`} />
</button>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{m.name}</p>
<p className="text-[10px] text-muted-foreground">
{m.type === 'table' ? `Eigenes Modul · ${m.columns?.length || 0} Spalten` : 'Eingebaut'}
{' · '}{m.slot === 'main' ? 'Hauptbereich' : 'Seitenspalte'}
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button onClick={() => move(idx, -1)} disabled={idx === 0} className="p-1 text-muted-foreground hover:text-foreground disabled:opacity-30" title="Nach oben">
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button onClick={() => move(idx, 1)} disabled={idx === working.length - 1} className="p-1 text-muted-foreground hover:text-foreground disabled:opacity-30" title="Nach unten">
<ArrowDown className="w-3.5 h-3.5" />
</button>
{m.type === 'table' && (
<button onClick={() => removeCustom(idx)} className="p-1 text-muted-foreground hover:text-destructive" title="Modul löschen (Daten bleiben in der DB)">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
))}
</div>
{/* Neues Modul */}
{!showAdd ? (
<Button variant="outline" size="sm" onClick={() => { selectPreset(MODULE_PRESETS[0].key); setShowAdd(true) }} className="w-full">
<Plus className="w-4 h-4 mr-1.5" /> Modul hinzufügen
</Button>
) : (
<div className="rounded-lg border p-3 space-y-3 bg-muted/20">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold">Neues Modul</p>
<button onClick={() => setShowAdd(false)} className="text-muted-foreground hover:text-foreground"><X className="w-4 h-4" /></button>
</div>
{/* Vorlagen */}
<div className="grid grid-cols-2 gap-1.5">
{MODULE_PRESETS.map(p => (
<button
key={p.key}
onClick={() => selectPreset(p.key)}
className={`text-left rounded-md border-2 px-2 py-1.5 transition-colors ${
presetKey === p.key ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground'
}`}
title={p.description}
>
<p className="text-xs font-medium">{p.name}</p>
<p className="text-[10px] text-muted-foreground line-clamp-1">{p.description}</p>
</button>
))}
</div>
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Input value={newName} onChange={e => setNewName(e.target.value)} placeholder="z.B. Atemschutz Zug 1" className="h-8 text-sm" />
</div>
{/* Spalten-Editor */}
<div className="space-y-1.5">
<Label className="text-xs">Spalten</Label>
{newColumns.map((c, i) => (
<div key={c.key} className="flex items-center gap-1.5">
<Input
value={c.label}
onChange={e => updateColumn(i, { label: e.target.value })}
placeholder={`Spalte ${i + 1}`}
className="h-7 text-xs flex-1"
/>
<select
value={c.type}
onChange={e => updateColumn(i, { type: e.target.value as ModuleColumnType })}
className="h-7 text-xs rounded-md border border-input bg-background px-1.5"
>
{COLUMN_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<button onClick={() => removeColumn(i)} disabled={newColumns.length <= 1} className="p-1 text-muted-foreground hover:text-destructive disabled:opacity-30">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
{newColumns.length < 8 && (
<button onClick={addColumn} className="text-xs text-primary hover:underline flex items-center gap-1">
<Plus className="w-3 h-3" /> Spalte hinzufügen
</button>
)}
</div>
<Button size="sm" onClick={addModule} disabled={newColumns.filter(c => c.label.trim()).length === 0} className="w-full">
<Plus className="w-4 h-4 mr-1" /> Modul erstellen
</Button>
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>Abbrechen</Button>
<Button onClick={save} disabled={isSaving}>
{isSaving ? <><Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> Speichern</> : 'Speichern'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -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 ? (
<div className="text-center text-muted-foreground py-4 text-xs">
Keine Pendenzen
</div>
) : (
<div className="divide-y divide-border/50">
{items.map((p) => (
<div key={p.id} className={`flex items-start gap-1.5 px-2 py-1.5 text-xs print:text-[9px] group ${p.done ? 'bg-green-50 dark:bg-green-950/20 print:bg-green-50' : ''}`}>
<button
onClick={() => canEdit && onToggle(p)}
className={`w-4 h-4 mt-0.5 rounded border-2 flex items-center justify-center shrink-0 transition-colors ${
p.done
? 'bg-green-500 border-green-500 text-white'
: 'border-muted-foreground/30 hover:border-primary'
}`}
disabled={!canEdit}
>
{p.done && <Check className="w-2.5 h-2.5" />}
</button>
<div className="flex-1 min-w-0">
<p className={`${p.done ? 'text-muted-foreground' : ''}`}>
{p.what}
{p.done && p.doneAt && (
<span className="ml-1 text-green-600 text-[10px] print:text-[8px] font-medium no-underline">
(erledigt um {formatTime(p.doneAt)})
</span>
)}
</p>
<div className="flex gap-2 text-[10px] text-muted-foreground">
{p.who && <span>Wer: {p.who}</span>}
{p.whenHow && <span>Wann: {p.whenHow}</span>}
</div>
</div>
{canEdit && (
<button
onClick={() => onDelete(p.id)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive shrink-0 mt-0.5 print:hidden"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
)}
{canEdit && (
<div className="border-t border-border p-1.5 print:hidden">
<div className="flex gap-1">
<Input
placeholder="Was..."
value={what}
onChange={(e) => setWhat(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
className="flex-1 h-6 text-[11px]"
/>
<Input
placeholder="Wer"
value={who}
onChange={(e) => setWho(e.target.value)}
className="w-14 h-6 text-[11px]"
/>
<Input
placeholder="Wann"
value={whenHow}
onChange={(e) => setWhenHow(e.target.value)}
className="w-14 h-6 text-[11px]"
/>
<Button size="sm" variant="ghost" onClick={submit} disabled={!what.trim()} className="h-6 px-1.5">
<Plus className="w-3 h-3" />
</Button>
</div>
</div>
)}
</>
)
}

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

View File

@@ -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<string, any>
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<ModuleItemRow[]>([])
const [isLoading, setIsLoading] = useState(true)
const [draft, setDraft] = useState<Record<string, string>>({})
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<string, any> = {}
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 (
<div>
{/* Spalten-Kopf */}
<div className="grid text-[10px] font-semibold text-muted-foreground border-b border-border/50" style={{ gridTemplateColumns: gridCols }}>
{columns.map(c => (
<div key={c.key} className={`px-1.5 py-1 truncate ${c.type !== 'text' ? 'text-center' : ''}`}>{c.label}</div>
))}
{canEdit && <div />}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-4 text-muted-foreground text-xs">
<Loader2 className="w-3.5 h-3.5 animate-spin mr-1.5" /> Laden
</div>
) : items.length === 0 ? (
<div className="text-center text-muted-foreground py-3 text-xs">Keine Einträge</div>
) : (
<div className="divide-y divide-border/50">
{items.map((item, idx) => (
<div key={item.id} className={`grid items-center text-xs print:text-[9px] group ${idx % 2 === 0 ? '' : 'bg-stone-50 dark:bg-muted/20'}`} style={{ gridTemplateColumns: gridCols }}>
{columns.map(c => {
if (c.type === 'check') {
const checked = !!item.data[c.key]
const at = item.data[`${c.key}At`]
return (
<div key={c.key} className="flex items-center justify-center py-1.5">
<button
onClick={() => toggleCheck(item, c.key)}
disabled={!canEdit}
title={at ? `${c.label}: ${formatTime(at)}` : c.label}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
checked ? 'bg-green-500 border-green-500 text-white' : 'border-muted-foreground/30 hover:border-green-400'
}`}
>
{checked && <Check className="w-3 h-3" />}
</button>
</div>
)
}
if (c.type === 'time') {
// Zeit-Spalte: automatisch = Erstellzeit (oder explizit gesetzter Wert)
const t = item.data[c.key] || item.createdAt
return (
<div key={c.key} className="px-1.5 py-1.5 font-mono tabular-nums text-muted-foreground text-center text-[11px]">
{t ? formatTime(t) : ''}
</div>
)
}
return (
<div key={c.key} className="px-1.5 py-1.5 break-words min-w-0">
{item.data[c.key] || <span className="text-muted-foreground"></span>}
</div>
)
})}
{canEdit && (
<button
onClick={() => deleteRow(item.id)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive flex items-center justify-center print:hidden"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
)}
{/* Neue Zeile */}
{canEdit && (
<div className="flex gap-1 px-1.5 py-1.5 border-t border-border/50 print:hidden">
{textColumns.map((c, i) => (
<Input
key={c.key}
placeholder={c.label + '…'}
value={draft[c.key] || ''}
onChange={(e) => 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'}`}
/>
))}
<Button size="sm" variant="ghost" onClick={addRow} disabled={!textColumns.some(c => (draft[c.key] || '').trim())} className="h-6 px-1.5 shrink-0">
<Plus className="w-3 h-3" />
</Button>
</div>
)}
</div>
)
}

View File

@@ -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' })
}

152
src/lib/modules.ts Normal file
View File

@@ -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<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)
}