Files
Lageplan/src/components/exercise/exercise-cockpit.tsx
Pepe Ziberi e02ea21c93 feat(exercise): Übungs-Cockpit nutzt den Modul-Baukasten + Testplan (v1.5.6)
- Übungen zeigen jetzt zusätzlich zu Zielen/Auswertung dieselben eigenen
  Tabellen-Module wie Einsätze (z.B. Atemschutz, Kräfte vor Ort)
- Live-Sync der Modul-Zeilen auch in der Übung (journal-updated Socket)
- docs/TESTPLAN.md: strukturierter Handy-/Einsatz-Testplan

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:53:37 +02:00

341 lines
12 KiB
TypeScript

'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { getSocket } from '@/lib/socket'
import { TableModule } from '@/components/journal/modules/table-module'
import type { CockpitModule } from '@/lib/modules'
import {
GraduationCap,
Plus,
Target,
Check,
CircleDashed,
CircleSlash,
MinusCircle,
Trash2,
ClipboardCheck,
Loader2,
Blocks,
} from 'lucide-react'
type GoalStatus = 'OPEN' | 'REACHED' | 'PARTIAL' | 'MISSED'
interface ExerciseGoal {
id: string
text: string
status: GoalStatus
note: string | null
sortOrder: number
}
interface ExerciseCockpitProps {
projectId: string | null
projectTitle: string
canEdit: boolean
initialEvaluation?: string
onEvaluationSaved?: (value: string) => void
}
const STATUS_CONFIG: Record<GoalStatus, { label: string; icon: typeof Check; cls: string; activeCls: string }> = {
OPEN: {
label: 'Offen',
icon: CircleDashed,
cls: 'text-muted-foreground',
activeCls: 'bg-muted text-foreground border-muted-foreground/40',
},
REACHED: {
label: 'Erreicht',
icon: Check,
cls: 'text-green-600',
activeCls: 'bg-green-100 text-green-700 border-green-500 dark:bg-green-950/40 dark:text-green-300',
},
PARTIAL: {
label: 'Teilweise',
icon: MinusCircle,
cls: 'text-amber-600',
activeCls: 'bg-amber-100 text-amber-700 border-amber-500 dark:bg-amber-950/40 dark:text-amber-300',
},
MISSED: {
label: 'Nicht erreicht',
icon: CircleSlash,
cls: 'text-red-600',
activeCls: 'bg-red-100 text-red-700 border-red-500 dark:bg-red-950/40 dark:text-red-300',
},
}
const STATUS_ORDER: GoalStatus[] = ['OPEN', 'REACHED', 'PARTIAL', 'MISSED']
export function ExerciseCockpit({ projectId, projectTitle, canEdit, initialEvaluation, onEvaluationSaved }: ExerciseCockpitProps) {
const [goals, setGoals] = useState<ExerciseGoal[]>([])
const [isLoading, setIsLoading] = useState(false)
const [newGoalText, setNewGoalText] = useState('')
const [isAdding, setIsAdding] = useState(false)
const [evaluation, setEvaluation] = useState(initialEvaluation || '')
const [evalSaving, setEvalSaving] = useState(false)
const evalDebounce = useRef<NodeJS.Timeout | null>(null)
const { toast } = useToast()
// Modul-Baukasten: dieselben eigenen Tabellen-Module wie im Einsatz
const [tableModules, setTableModules] = useState<CockpitModule[]>([])
useEffect(() => {
fetch('/api/tenant/modules')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.modules) {
setTableModules(data.modules.filter((m: CockpitModule) => m.enabled && m.type === 'table' && m.columns?.length))
}
})
.catch(() => {})
}, [])
const notifyModuleChanged = useCallback(() => {
if (!projectId) return
try { getSocket().emit('journal-updated', { projectId }) } catch { /* offline */ }
}, [projectId])
// Ziele laden
const loadGoals = useCallback(async () => {
if (!projectId) {
setGoals([])
return
}
setIsLoading(true)
try {
const res = await fetch(`/api/projects/${projectId}/exercise-goals`)
if (res.ok) {
const data = await res.json()
setGoals(data.goals || [])
}
} catch (e) {
console.warn('Fehler beim Laden der Übungsziele:', e)
} finally {
setIsLoading(false)
}
}, [projectId])
useEffect(() => {
loadGoals()
}, [loadGoals])
// Auswertung übernehmen, wenn sich das Projekt ändert
useEffect(() => {
setEvaluation(initialEvaluation || '')
}, [projectId, initialEvaluation])
const addGoal = async () => {
if (!projectId || !newGoalText.trim()) return
setIsAdding(true)
try {
const res = await fetch(`/api/projects/${projectId}/exercise-goals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: newGoalText.trim() }),
})
if (!res.ok) throw new Error('Ziel konnte nicht angelegt werden')
const data = await res.json()
setGoals((prev) => [...prev, data.goal])
setNewGoalText('')
} catch (e) {
toast({ title: 'Fehler', description: e instanceof Error ? e.message : 'Unbekannter Fehler', variant: 'destructive' })
} finally {
setIsAdding(false)
}
}
const patchGoal = async (goalId: string, patch: Partial<Pick<ExerciseGoal, 'text' | 'status' | 'note'>>) => {
if (!projectId) return
// Optimistisch aktualisieren
setGoals((prev) => prev.map((g) => (g.id === goalId ? { ...g, ...patch } : g)))
try {
await fetch(`/api/projects/${projectId}/exercise-goals/${goalId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
} catch (e) {
console.warn('Fehler beim Speichern des Ziels:', e)
loadGoals()
}
}
const deleteGoal = async (goalId: string) => {
if (!projectId) return
setGoals((prev) => prev.filter((g) => g.id !== goalId))
try {
await fetch(`/api/projects/${projectId}/exercise-goals/${goalId}`, { method: 'DELETE' })
} catch (e) {
console.warn('Fehler beim Löschen des Ziels:', e)
loadGoals()
}
}
// Auswertung mit Debounce speichern
const handleEvaluationChange = (value: string) => {
setEvaluation(value)
if (!projectId) return
if (evalDebounce.current) clearTimeout(evalDebounce.current)
evalDebounce.current = setTimeout(async () => {
setEvalSaving(true)
try {
await fetch(`/api/projects/${projectId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ exerciseEvaluation: value }),
})
onEvaluationSaved?.(value)
} catch (e) {
console.warn('Fehler beim Speichern der Auswertung:', e)
} finally {
setEvalSaving(false)
}
}, 800)
}
const reachedCount = goals.filter((g) => g.status === 'REACHED').length
const total = goals.length
return (
<div className="h-full overflow-auto bg-blue-50/40 dark:bg-background">
{/* Header */}
<div className="p-3 md:p-4 border-b-2 border-blue-600 dark:border-blue-800 bg-white dark:bg-card shadow-sm">
<div className="flex items-center justify-between mb-1">
<h2 className="text-lg md:text-xl font-bold flex items-center gap-2 text-blue-800 dark:text-blue-400">
<GraduationCap className="w-5 h-5 md:w-6 md:h-6" />
Übungs-Cockpit
</h2>
{total > 0 && (
<span className="text-sm font-medium text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-950/40 px-2 py-0.5 rounded">
{reachedCount}/{total} erreicht
</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate">{projectTitle || 'Übung'}</p>
</div>
<div className="p-3 md:p-4 space-y-6 max-w-3xl">
{/* Übungsziele */}
<section>
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3 text-foreground/80">
<Target className="w-4 h-4 text-blue-600" />
Übungsziele
</h3>
{canEdit && (
<div className="flex gap-2 mb-3">
<Input
value={newGoalText}
onChange={(e) => setNewGoalText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addGoal() }}
placeholder="Neues Übungsziel — z.B. Wasserbezug ab Hydrant befehlen"
disabled={isAdding || !projectId}
/>
<Button onClick={addGoal} disabled={isAdding || !newGoalText.trim() || !projectId}>
{isAdding ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
</Button>
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" /> Lädt
</div>
) : goals.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center border border-dashed rounded-lg">
Noch keine Ziele. Lege in der Vorbereitung fest, was die Übung auslösen soll.
</p>
) : (
<ul className="space-y-2">
{goals.map((goal) => (
<li key={goal.id} className="border rounded-lg p-3 bg-white dark:bg-card">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium flex-1">{goal.text}</p>
{canEdit && (
<button
onClick={() => deleteGoal(goal.id)}
className="text-muted-foreground hover:text-destructive shrink-0"
title="Ziel löschen"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
{/* Zielerreichung */}
<div className="flex flex-wrap gap-1.5 mt-2">
{STATUS_ORDER.map((s) => {
const cfg = STATUS_CONFIG[s]
const Icon = cfg.icon
const active = goal.status === s
return (
<button
key={s}
type="button"
onClick={() => canEdit && patchGoal(goal.id, { status: s })}
disabled={!canEdit}
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors disabled:cursor-default ${
active ? cfg.activeCls : 'border-transparent text-muted-foreground hover:bg-accent'
}`}
>
<Icon className="w-3.5 h-3.5" /> {cfg.label}
</button>
)
})}
</div>
{/* Notiz zum Ziel */}
{canEdit && (
<Input
value={goal.note || ''}
onChange={(e) => setGoals((prev) => prev.map((g) => (g.id === goal.id ? { ...g, note: e.target.value } : g)))}
onBlur={(e) => patchGoal(goal.id, { note: e.target.value })}
placeholder="Notiz / Beobachtung (optional)"
className="mt-2 h-8 text-xs"
/>
)}
{!canEdit && goal.note && (
<p className="mt-2 text-xs text-muted-foreground">{goal.note}</p>
)}
</li>
))}
</ul>
)}
</section>
{/* Auswertung */}
<section>
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3 text-foreground/80">
<ClipboardCheck className="w-4 h-4 text-blue-600" />
Auswertung
{evalSaving && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
</h3>
<textarea
value={evaluation}
onChange={(e) => handleEvaluationChange(e.target.value)}
disabled={!canEdit || !projectId}
rows={6}
placeholder="Gesamtbeurteilung, Lessons Learned, was ist aufgefallen…"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60 resize-y"
/>
<p className="text-xs text-muted-foreground mt-1">Wird automatisch gespeichert.</p>
</section>
{/* Eigene Cockpit-Module (Modul-Baukasten) — z.B. Atemschutz, Kräfte vor Ort */}
{projectId && tableModules.map((mod) => (
<section key={mod.id}>
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3 text-foreground/80">
<Blocks className="w-4 h-4 text-blue-600" />
{mod.name}
</h3>
<div className="border rounded-lg bg-white dark:bg-card overflow-hidden">
<TableModule projectId={projectId} module={mod} canEdit={canEdit} notifyChanged={notifyModuleChanged} />
</div>
</section>
))}
</div>
</div>
)
}