From 1c8f6192529d5c12fcf5b5bab4b65c29a101e156 Mon Sep 17 00:00:00 2001 From: Pepe Ziberi Date: Sat, 18 Jul 2026 21:16:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(exercise):=20=C3=9Cbungsmodus=20mit=20?= =?UTF-8?q?=C3=9Cbungs-Cockpit=20+=20direkte=20Einstiegs-Buttons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- ROADMAP.md | 6 +- package.json | 2 +- prisma/migrate.js | 12 + prisma/schema.prisma | 24 +- .../[id]/exercise-goals/[goalId]/route.ts | 69 ++++ .../api/projects/[id]/exercise-goals/route.ts | 63 ++++ src/app/app/page.tsx | 63 ++-- src/components/dialogs/project-dialog.tsx | 9 +- src/components/exercise/exercise-cockpit.tsx | 305 ++++++++++++++++++ src/components/layout/right-sidebar.tsx | 8 +- src/components/layout/topbar.tsx | 31 +- src/lib/validations.ts | 1 + src/types/index.ts | 1 + 13 files changed, 560 insertions(+), 34 deletions(-) create mode 100644 src/app/api/projects/[id]/exercise-goals/[goalId]/route.ts create mode 100644 src/app/api/projects/[id]/exercise-goals/route.ts create mode 100644 src/components/exercise/exercise-cockpit.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 50f75ea..87535ee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,8 +23,10 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross - [x] **1.1 – `mode`-Feld am Projekt** 🟢 ✅ - Feld `mode` (EINSATZ/UEBUNG) im Schema + Auto-Migration + Validierung. - Auswahl beim Erstellen (Segmented-Control), Badge in Projektliste, eigene Nummer `Ü-…`. -- [ ] **1.2 – UI je nach Modus** 🟡 _(teilweise: Journal-Titel wird bei Übung zu „Übungs-Journal")_ - - Offen: Übung → Journal ganz aus, stattdessen „Übungsziele / Zielerreichung / Auswertung"-Panel. +- [x] **1.2 – UI je nach Modus** 🟡 ✅ + - Direkte Buttons „Neuer Einsatz" (rot) / „Neue Übung" (blau) in der Topbar. + - Übung → Übungs-Cockpit statt Journal: Übungsziele + Zielerreichung + Auswertung. + - Deutliches „ÜBUNG"-Banner, Sidebar-Tab heisst bei Übung „Auswertung". ## Phase 2 – Symbole aufräumen & ergänzen diff --git a/package.json b/package.json index c96fc79..168f89b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.4.3", + "version": "1.4.4", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "private": true, "scripts": { diff --git a/prisma/migrate.js b/prisma/migrate.js index 84d38e3..acd203c 100644 --- a/prisma/migrate.js +++ b/prisma/migrate.js @@ -71,6 +71,7 @@ async function migrate() { `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "einsatzleiter" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "journalfuehrer" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "mode" TEXT NOT NULL DEFAULT 'EINSATZ'`, + `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "exerciseEvaluation" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingById" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingUserName" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingSessionId" TEXT`, @@ -117,6 +118,17 @@ async function migrate() { "createdById" TEXT REFERENCES users(id) ON DELETE SET NULL, UNIQUE("tenantId", "reportNumber") )`, + // Exercise goals (Übungs-Cockpit) + `CREATE TABLE IF NOT EXISTS exercise_goals ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), + text TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'OPEN', + note TEXT, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "projectId" TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE + )`, ] for (const sql of tableMigrations) { try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 51d25c2..fcb7ddb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -151,9 +151,11 @@ model TenantMembership { model Project { id String @id @default(uuid()) einsatzNr String? - // "EINSATZ" = echter Einsatz (mit Journal), "UEBUNG" = Übung (Auswertung statt Journal). + // "EINSATZ" = echter Einsatz (mit Journal), "UEBUNG" = Übung (Cockpit statt Journal). // Als String (nicht Enum) gehalten, damit die idempotente Raw-SQL-Migration einfach bleibt. mode String @default("EINSATZ") + // Nur für Übungen: Gesamt-Auswertung / Lessons Learned (Freitext). + exerciseEvaluation String? title String location String? description String? @@ -183,11 +185,31 @@ model Project { journalEntries JournalEntry[] journalCheckItems JournalCheckItem[] journalPendenzen JournalPendenz[] + exerciseGoals ExerciseGoal[] rapports Rapport[] @@map("projects") } +// ─── Übungs-Cockpit: Übungsziele mit Zielerreichung ────────── + +model ExerciseGoal { + id String @id @default(uuid()) + text String + // Zielerreichung: OPEN | REACHED | PARTIAL | MISSED + status String @default("OPEN") + note String? + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId]) + @@map("exercise_goals") +} + model Feature { id String @id @default(uuid()) type String diff --git a/src/app/api/projects/[id]/exercise-goals/[goalId]/route.ts b/src/app/api/projects/[id]/exercise-goals/[goalId]/route.ts new file mode 100644 index 0000000..d458368 --- /dev/null +++ b/src/app/api/projects/[id]/exercise-goals/[goalId]/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { getSession } from '@/lib/auth' +import { getProjectWithTenantCheck } from '@/lib/tenant' + +const ALLOWED_STATUS = ['OPEN', 'REACHED', 'PARTIAL', 'MISSED'] + +// PUT: Übungsziel aktualisieren (Text / Status / Notiz) +export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string; goalId: string }> }) { + try { + const { id, goalId } = 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).exerciseGoal.findFirst({ + where: { id: goalId, projectId: id }, + }) + if (!existing) return NextResponse.json({ error: 'Ziel nicht gefunden' }, { status: 404 }) + + const body = await req.json() + const data: any = {} + if (body.text !== undefined) data.text = String(body.text).trim() + if (body.note !== undefined) data.note = body.note || null + if (body.status !== undefined) { + if (!ALLOWED_STATUS.includes(body.status)) { + return NextResponse.json({ error: 'Ungültiger Status' }, { status: 400 }) + } + data.status = body.status + } + if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder + + const goal = await (prisma as any).exerciseGoal.update({ + where: { id: goalId }, + data, + }) + return NextResponse.json({ goal }) + } catch (error) { + console.error('Error updating exercise goal:', error) + return NextResponse.json({ error: 'Failed to update goal' }, { status: 500 }) + } +} + +// DELETE: Übungsziel löschen +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string; goalId: string }> }) { + try { + const { id, goalId } = 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).exerciseGoal.findFirst({ + where: { id: goalId, projectId: id }, + }) + if (!existing) return NextResponse.json({ error: 'Ziel nicht gefunden' }, { status: 404 }) + + await (prisma as any).exerciseGoal.delete({ where: { id: goalId } }) + return NextResponse.json({ ok: true }) + } catch (error) { + console.error('Error deleting exercise goal:', error) + return NextResponse.json({ error: 'Failed to delete goal' }, { status: 500 }) + } +} diff --git a/src/app/api/projects/[id]/exercise-goals/route.ts b/src/app/api/projects/[id]/exercise-goals/route.ts new file mode 100644 index 0000000..6fdf0e7 --- /dev/null +++ b/src/app/api/projects/[id]/exercise-goals/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { getSession } from '@/lib/auth' +import { getProjectWithTenantCheck } from '@/lib/tenant' + +// GET: Liste aller Übungsziele eines Projekts +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = 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 goals = await (prisma as any).exerciseGoal.findMany({ + where: { projectId: id }, + orderBy: { sortOrder: 'asc' }, + }) + return NextResponse.json({ goals }) + } catch (error) { + console.error('Error fetching exercise goals:', error) + return NextResponse.json({ error: 'Failed to fetch goals' }, { status: 500 }) + } +} + +// POST: Neues Übungsziel anlegen +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = 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 req.json() + if (!body.text || !String(body.text).trim()) { + return NextResponse.json({ error: 'Text erforderlich' }, { status: 400 }) + } + + // Ans Ende der Liste einsortieren + const last = await (prisma as any).exerciseGoal.findFirst({ + where: { projectId: id }, + orderBy: { sortOrder: 'desc' }, + select: { sortOrder: true }, + }) + + const goal = await (prisma as any).exerciseGoal.create({ + data: { + projectId: id, + text: String(body.text).trim(), + status: 'OPEN', + sortOrder: (last?.sortOrder ?? -1) + 1, + }, + }) + return NextResponse.json({ goal }) + } catch (error) { + console.error('Error creating exercise goal:', error) + return NextResponse.json({ error: 'Failed to create goal' }, { status: 500 }) + } +} diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx index 54fb66f..d4ad4d5 100644 --- a/src/app/app/page.tsx +++ b/src/app/app/page.tsx @@ -11,6 +11,7 @@ import { Topbar } from '@/components/layout/topbar' import { LeftToolbar } from '@/components/layout/left-toolbar' import { RightSidebar } from '@/components/layout/right-sidebar' import { ProjectDialog } from '@/components/dialogs/project-dialog' +import { ExerciseCockpit } from '@/components/exercise/exercise-cockpit' import { TextDialog } from '@/components/dialogs/text-dialog' import { LineLabelDialog } from '@/components/dialogs/line-label-dialog' import { useToast } from '@/components/ui/use-toast' @@ -18,7 +19,7 @@ import { useAuth } from '@/components/providers/auth-provider' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { JournalView } from '@/components/journal/journal-view' -import { Lock, Unlock, Eye, AlertTriangle, WifiOff } from 'lucide-react' +import { Lock, Unlock, Eye, AlertTriangle, WifiOff, GraduationCap } from 'lucide-react' import { CustomDragLayer } from '@/components/map/custom-drag-layer' import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour' import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts' @@ -26,7 +27,7 @@ import { useMapExport } from '@/hooks/use-map-export' import { useAutoSave } from '@/hooks/use-auto-save' import { useOfflineSync } from '@/hooks/use-offline-sync' import { useRealtimeSync } from '@/hooks/use-realtime-sync' -import type { Project, DrawFeature, Feature, JournalEntry, DrawMode } from '@/types' +import type { Project, ProjectMode, DrawFeature, Feature, JournalEntry, DrawMode } from '@/types' import { useToolStore } from '@/stores/tool-store' import { useUIStore } from '@/stores/ui-store' @@ -45,6 +46,7 @@ export default function AppPage() { const [features, setFeatures] = useState([]) const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false) + const [newProjectMode, setNewProjectMode] = useState('EINSATZ') const [isSaving, setIsSaving] = useState(false) const [isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen] = useState(false) const [isFullscreen, setIsFullscreen] = useState(false) @@ -455,7 +457,8 @@ export default function AppPage() { } }, [authLoading, user]) - const handleNewProject = () => { + const handleNewProject = (mode: ProjectMode = 'EINSATZ') => { + setNewProjectMode(mode) setIsProjectDialogOpen(true) } @@ -818,6 +821,14 @@ export default function AppPage() { }} /> + {/* Übungs-Banner — verhindert Verwechslung mit echtem Einsatz */} + {currentProject?.mode === 'UEBUNG' && ( +
+ + ÜBUNG — kein realer Einsatz +
+ )} + {/* Offline banner */} {isOffline && (
@@ -924,23 +935,35 @@ export default function AppPage() {
- {/* Journal view — always mounted, hidden via CSS */} + {/* Journal / Übungs-Cockpit — always mounted, hidden via CSS */}
- + {currentProject?.mode === 'UEBUNG' ? ( + + setCurrentProject((prev) => (prev ? { ...prev, exerciseEvaluation: value } : prev)) + } + /> + ) : ( + + )}
{/* Right sidebar — always visible, contains Karte/Journal tabs */} @@ -955,6 +978,7 @@ export default function AppPage() { isCollapsed={isSidebarCollapsed} onToggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)} tenantId={tenant?.id || null} + mode={currentProject?.mode} /> @@ -962,6 +986,7 @@ export default function AppPage() { open={isProjectDialogOpen} onOpenChange={setIsProjectDialogOpen} onProjectCreated={handleProjectCreated} + initialMode={newProjectMode} /> void onProjectCreated: (project: Project) => void + initialMode?: ProjectMode } export function ProjectDialog({ open, onOpenChange, onProjectCreated, + initialMode = 'EINSATZ', }: ProjectDialogProps) { - const [mode, setMode] = useState('EINSATZ') + const [mode, setMode] = useState(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('') diff --git a/src/components/exercise/exercise-cockpit.tsx b/src/components/exercise/exercise-cockpit.tsx new file mode 100644 index 0000000..72f520f --- /dev/null +++ b/src/components/exercise/exercise-cockpit.tsx @@ -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 = { + 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([]) + 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(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>) => { + 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 ( +
+ {/* Header */} +
+
+

+ + Übungs-Cockpit +

+ {total > 0 && ( + + {reachedCount}/{total} erreicht + + )} +
+

{projectTitle || 'Übung'}

+
+ +
+ {/* Übungsziele */} +
+

+ + Übungsziele +

+ + {canEdit && ( +
+ setNewGoalText(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') addGoal() }} + placeholder="Neues Übungsziel — z.B. Wasserbezug ab Hydrant befehlen" + disabled={isAdding || !projectId} + /> + +
+ )} + + {isLoading ? ( +
+ Lädt… +
+ ) : goals.length === 0 ? ( +

+ Noch keine Ziele. Lege in der Vorbereitung fest, was die Übung auslösen soll. +

+ ) : ( +
    + {goals.map((goal) => ( +
  • +
    +

    {goal.text}

    + {canEdit && ( + + )} +
    + + {/* Zielerreichung */} +
    + {STATUS_ORDER.map((s) => { + const cfg = STATUS_CONFIG[s] + const Icon = cfg.icon + const active = goal.status === s + return ( + + ) + })} +
    + + {/* Notiz zum Ziel */} + {canEdit && ( + 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 && ( +

    {goal.note}

    + )} +
  • + ))} +
+ )} +
+ + {/* Auswertung */} +
+

+ + Auswertung + {evalSaving && } +

+