feat(exercise): Übungsmodus mit Übungs-Cockpit + direkte Einstiegs-Buttons
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 40m38s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 40m38s
- Topbar: direkte Buttons "Neuer Einsatz" (rot) und "Neue Übung" (blau) statt versteckt im Menü; öffnen den Dialog direkt im richtigen Modus - Übungs-Cockpit ersetzt bei Übungen das Journal: Übungsziele mit Zielerreichung (Offen/Erreicht/Teilweise/Nicht erreicht) + Auswertung - Neues Model ExerciseGoal + Project.exerciseEvaluation (Auto-Migration) - API-Routen /projects/[id]/exercise-goals (Liste/Erstellen/Update/Löschen) - Deutliches "ÜBUNG"-Banner gegen Verwechslung mit echtem Einsatz - Sidebar-Tab heisst bei Übung "Auswertung" - Version 1.4.4 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,14 +37,21 @@ interface ProjectDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onProjectCreated: (project: Project) => void
|
||||
initialMode?: ProjectMode
|
||||
}
|
||||
|
||||
export function ProjectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onProjectCreated,
|
||||
initialMode = 'EINSATZ',
|
||||
}: ProjectDialogProps) {
|
||||
const [mode, setMode] = useState<ProjectMode>('EINSATZ')
|
||||
const [mode, setMode] = useState<ProjectMode>(initialMode)
|
||||
|
||||
// Beim Öffnen den vom Einstiegspunkt gewählten Modus übernehmen (Einsatz- vs. Übung-Button)
|
||||
useEffect(() => {
|
||||
if (open) setMode(initialMode)
|
||||
}, [open, initialMode])
|
||||
const [title, setTitle] = useState('')
|
||||
const [location, setLocation] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
305
src/components/exercise/exercise-cockpit.tsx
Normal file
305
src/components/exercise/exercise-cockpit.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
'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 {
|
||||
GraduationCap,
|
||||
Plus,
|
||||
Target,
|
||||
Check,
|
||||
CircleDashed,
|
||||
CircleSlash,
|
||||
MinusCircle,
|
||||
Trash2,
|
||||
ClipboardCheck,
|
||||
Loader2,
|
||||
} 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()
|
||||
|
||||
// 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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -39,6 +39,7 @@ interface RightSidebarProps {
|
||||
isCollapsed?: boolean
|
||||
onToggleCollapse?: () => void
|
||||
tenantId?: string | null
|
||||
mode?: 'EINSATZ' | 'UEBUNG'
|
||||
}
|
||||
|
||||
const categoryIcons: Record<string, typeof Flame> = {
|
||||
@@ -100,7 +101,8 @@ function DraggableSymbol({ symbol, canEdit }: {
|
||||
)
|
||||
}
|
||||
|
||||
export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTab, onTabChange, isCollapsed, onToggleCollapse, tenantId }: RightSidebarProps) {
|
||||
export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTab, onTabChange, isCollapsed, onToggleCollapse, tenantId, mode }: RightSidebarProps) {
|
||||
const journalLabel = mode === 'UEBUNG' ? 'Auswertung' : 'Journal'
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeCategory, setActiveCategory] = useState<string>('')
|
||||
const [categories, setCategories] = useState<DisplayCategory[]>([])
|
||||
@@ -247,7 +249,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
activeTab === 'journal' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
}`}
|
||||
title="Journal"
|
||||
title={journalLabel}
|
||||
>
|
||||
<ClipboardList className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -287,7 +289,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
||||
}`}
|
||||
>
|
||||
<ClipboardList className="w-3.5 h-3.5" />
|
||||
Journal
|
||||
{journalLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Plus,
|
||||
Flame,
|
||||
GraduationCap,
|
||||
Save,
|
||||
FolderOpen,
|
||||
Download,
|
||||
@@ -43,13 +44,13 @@ import {
|
||||
Unlock,
|
||||
} from 'lucide-react'
|
||||
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
|
||||
import type { Project, DrawFeature } from '@/types'
|
||||
import type { Project, DrawFeature, ProjectMode } from '@/types'
|
||||
import { formatDateTime } from '@/lib/utils'
|
||||
import { Logo } from '@/components/ui/logo'
|
||||
|
||||
interface TopbarProps {
|
||||
project: Project | null
|
||||
onNewProject: () => void
|
||||
onNewProject: (mode?: ProjectMode) => void
|
||||
onSaveProject: () => void
|
||||
onLoadProject: (project: Project, features: DrawFeature[]) => void
|
||||
onDeleteProject?: (projectId: string) => void
|
||||
@@ -188,6 +189,26 @@ export function Topbar({
|
||||
<span className="hidden lg:inline">{presentationLocked ? 'Gesperrt' : 'Frei'}</span>
|
||||
</Button>
|
||||
|
||||
{/* Direkte Einstiegspunkte: Einsatz (rot) und Übung (blau) */}
|
||||
<Button
|
||||
data-tour="new-project"
|
||||
onClick={() => onNewProject('EINSATZ')}
|
||||
className="h-9 md:h-10 px-2 md:px-3 text-sm bg-red-600 hover:bg-red-700 text-white"
|
||||
title="Neuen Einsatz erstellen"
|
||||
>
|
||||
<Flame className="w-5 h-5 md:mr-1" />
|
||||
<span className="hidden lg:inline">Neuer Einsatz</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onNewProject('UEBUNG')}
|
||||
variant="outline"
|
||||
className="h-9 md:h-10 px-2 md:px-3 text-sm border-blue-500 text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-950/40"
|
||||
title="Neue Übung erstellen"
|
||||
>
|
||||
<GraduationCap className="w-5 h-5 md:mr-1" />
|
||||
<span className="hidden lg:inline">Neue Übung</span>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="h-9 md:h-10 px-2 md:px-3 text-sm" title="Menü">
|
||||
@@ -196,10 +217,6 @@ export function Topbar({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-52">
|
||||
<DropdownMenuItem data-tour="new-project" onClick={onNewProject} className="py-2.5 px-3">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Neuer Einsatz
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleOpenLoadDialog} className="py-2.5 px-3">
|
||||
<List className="w-4 h-4 mr-2" />
|
||||
Einsätze verwalten
|
||||
|
||||
Reference in New Issue
Block a user