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:
@@ -23,8 +23,10 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross
|
|||||||
- [x] **1.1 – `mode`-Feld am Projekt** 🟢 ✅
|
- [x] **1.1 – `mode`-Feld am Projekt** 🟢 ✅
|
||||||
- Feld `mode` (EINSATZ/UEBUNG) im Schema + Auto-Migration + Validierung.
|
- Feld `mode` (EINSATZ/UEBUNG) im Schema + Auto-Migration + Validierung.
|
||||||
- Auswahl beim Erstellen (Segmented-Control), Badge in Projektliste, eigene Nummer `Ü-…`.
|
- 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")_
|
- [x] **1.2 – UI je nach Modus** 🟡 ✅
|
||||||
- Offen: Übung → Journal ganz aus, stattdessen „Übungsziele / Zielerreichung / Auswertung"-Panel.
|
- 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
|
## Phase 2 – Symbole aufräumen & ergänzen
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.4.3",
|
"version": "1.4.4",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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 "einsatzleiter" TEXT`,
|
||||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "journalfuehrer" 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 "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 "editingById" TEXT`,
|
||||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingUserName" TEXT`,
|
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingUserName" TEXT`,
|
||||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingSessionId" 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,
|
"createdById" TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
UNIQUE("tenantId", "reportNumber")
|
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) {
|
for (const sql of tableMigrations) {
|
||||||
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ }
|
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ }
|
||||||
|
|||||||
@@ -151,9 +151,11 @@ model TenantMembership {
|
|||||||
model Project {
|
model Project {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
einsatzNr String?
|
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.
|
// Als String (nicht Enum) gehalten, damit die idempotente Raw-SQL-Migration einfach bleibt.
|
||||||
mode String @default("EINSATZ")
|
mode String @default("EINSATZ")
|
||||||
|
// Nur für Übungen: Gesamt-Auswertung / Lessons Learned (Freitext).
|
||||||
|
exerciseEvaluation String?
|
||||||
title String
|
title String
|
||||||
location String?
|
location String?
|
||||||
description String?
|
description String?
|
||||||
@@ -183,11 +185,31 @@ model Project {
|
|||||||
journalEntries JournalEntry[]
|
journalEntries JournalEntry[]
|
||||||
journalCheckItems JournalCheckItem[]
|
journalCheckItems JournalCheckItem[]
|
||||||
journalPendenzen JournalPendenz[]
|
journalPendenzen JournalPendenz[]
|
||||||
|
exerciseGoals ExerciseGoal[]
|
||||||
rapports Rapport[]
|
rapports Rapport[]
|
||||||
|
|
||||||
@@map("projects")
|
@@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 {
|
model Feature {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
type String
|
type String
|
||||||
|
|||||||
69
src/app/api/projects/[id]/exercise-goals/[goalId]/route.ts
Normal file
69
src/app/api/projects/[id]/exercise-goals/[goalId]/route.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/app/api/projects/[id]/exercise-goals/route.ts
Normal file
63
src/app/api/projects/[id]/exercise-goals/route.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { Topbar } from '@/components/layout/topbar'
|
|||||||
import { LeftToolbar } from '@/components/layout/left-toolbar'
|
import { LeftToolbar } from '@/components/layout/left-toolbar'
|
||||||
import { RightSidebar } from '@/components/layout/right-sidebar'
|
import { RightSidebar } from '@/components/layout/right-sidebar'
|
||||||
import { ProjectDialog } from '@/components/dialogs/project-dialog'
|
import { ProjectDialog } from '@/components/dialogs/project-dialog'
|
||||||
|
import { ExerciseCockpit } from '@/components/exercise/exercise-cockpit'
|
||||||
import { TextDialog } from '@/components/dialogs/text-dialog'
|
import { TextDialog } from '@/components/dialogs/text-dialog'
|
||||||
import { LineLabelDialog } from '@/components/dialogs/line-label-dialog'
|
import { LineLabelDialog } from '@/components/dialogs/line-label-dialog'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { JournalView } from '@/components/journal/journal-view'
|
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 { CustomDragLayer } from '@/components/map/custom-drag-layer'
|
||||||
import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour'
|
import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour'
|
||||||
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
|
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 { useAutoSave } from '@/hooks/use-auto-save'
|
||||||
import { useOfflineSync } from '@/hooks/use-offline-sync'
|
import { useOfflineSync } from '@/hooks/use-offline-sync'
|
||||||
import { useRealtimeSync } from '@/hooks/use-realtime-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 { useToolStore } from '@/stores/tool-store'
|
||||||
import { useUIStore } from '@/stores/ui-store'
|
import { useUIStore } from '@/stores/ui-store'
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ export default function AppPage() {
|
|||||||
const [features, setFeatures] = useState<DrawFeature[]>([])
|
const [features, setFeatures] = useState<DrawFeature[]>([])
|
||||||
|
|
||||||
const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false)
|
const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false)
|
||||||
|
const [newProjectMode, setNewProjectMode] = useState<ProjectMode>('EINSATZ')
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen] = useState(false)
|
const [isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen] = useState(false)
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
@@ -455,7 +457,8 @@ export default function AppPage() {
|
|||||||
}
|
}
|
||||||
}, [authLoading, user])
|
}, [authLoading, user])
|
||||||
|
|
||||||
const handleNewProject = () => {
|
const handleNewProject = (mode: ProjectMode = 'EINSATZ') => {
|
||||||
|
setNewProjectMode(mode)
|
||||||
setIsProjectDialogOpen(true)
|
setIsProjectDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,6 +821,14 @@ export default function AppPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Übungs-Banner — verhindert Verwechslung mit echtem Einsatz */}
|
||||||
|
{currentProject?.mode === 'UEBUNG' && (
|
||||||
|
<div className="flex items-center justify-center gap-2 px-4 py-1.5 bg-blue-600 text-white text-sm font-semibold tracking-wide">
|
||||||
|
<GraduationCap className="w-4 h-4 shrink-0" />
|
||||||
|
<span>ÜBUNG — kein realer Einsatz</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Offline banner */}
|
{/* Offline banner */}
|
||||||
{isOffline && (
|
{isOffline && (
|
||||||
<div className="flex items-center justify-center gap-3 px-4 py-2 bg-orange-50 dark:bg-orange-950/40 border-b border-orange-200 dark:border-orange-800 text-sm text-orange-800 dark:text-orange-300">
|
<div className="flex items-center justify-center gap-3 px-4 py-2 bg-orange-50 dark:bg-orange-950/40 border-b border-orange-200 dark:border-orange-800 text-sm text-orange-800 dark:text-orange-300">
|
||||||
@@ -924,23 +935,35 @@ export default function AppPage() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Journal view — always mounted, hidden via CSS */}
|
{/* Journal / Übungs-Cockpit — always mounted, hidden via CSS */}
|
||||||
<main className={`flex-1 flex flex-col min-h-0 bg-background ${activeTab !== 'journal' ? 'hidden' : ''}`}>
|
<main className={`flex-1 flex flex-col min-h-0 bg-background ${activeTab !== 'journal' ? 'hidden' : ''}`}>
|
||||||
<JournalView
|
{currentProject?.mode === 'UEBUNG' ? (
|
||||||
projectId={currentProject?.id || null}
|
<ExerciseCockpit
|
||||||
projectTitle={currentProject?.title || ''}
|
projectId={currentProject?.id || null}
|
||||||
projectLocation={currentProject?.location || ''}
|
projectTitle={currentProject?.title || ''}
|
||||||
mode={currentProject?.mode}
|
canEdit={canEdit}
|
||||||
einsatzleiter={currentProject?.einsatzleiter || ''}
|
initialEvaluation={currentProject?.exerciseEvaluation || ''}
|
||||||
journalfuehrer={currentProject?.journalfuehrer || ''}
|
onEvaluationSaved={(value) =>
|
||||||
einsatzNr={(currentProject as any)?.einsatzNr || ''}
|
setCurrentProject((prev) => (prev ? { ...prev, exerciseEvaluation: value } : prev))
|
||||||
canEdit={canEdit}
|
}
|
||||||
tenantId={tenant?.id || null}
|
/>
|
||||||
tenantName={tenant?.name || ''}
|
) : (
|
||||||
tenantLogoUrl={tenant?.id ? `/api/admin/tenants/${tenant.id}/logo/serve` : null}
|
<JournalView
|
||||||
mapRef={mapRef}
|
projectId={currentProject?.id || null}
|
||||||
mapScreenshot={lastMapScreenshot}
|
projectTitle={currentProject?.title || ''}
|
||||||
/>
|
projectLocation={currentProject?.location || ''}
|
||||||
|
mode={currentProject?.mode}
|
||||||
|
einsatzleiter={currentProject?.einsatzleiter || ''}
|
||||||
|
journalfuehrer={currentProject?.journalfuehrer || ''}
|
||||||
|
einsatzNr={(currentProject as any)?.einsatzNr || ''}
|
||||||
|
canEdit={canEdit}
|
||||||
|
tenantId={tenant?.id || null}
|
||||||
|
tenantName={tenant?.name || ''}
|
||||||
|
tenantLogoUrl={tenant?.id ? `/api/admin/tenants/${tenant.id}/logo/serve` : null}
|
||||||
|
mapRef={mapRef}
|
||||||
|
mapScreenshot={lastMapScreenshot}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Right sidebar — always visible, contains Karte/Journal tabs */}
|
{/* Right sidebar — always visible, contains Karte/Journal tabs */}
|
||||||
@@ -955,6 +978,7 @@ export default function AppPage() {
|
|||||||
isCollapsed={isSidebarCollapsed}
|
isCollapsed={isSidebarCollapsed}
|
||||||
onToggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
onToggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||||
tenantId={tenant?.id || null}
|
tenantId={tenant?.id || null}
|
||||||
|
mode={currentProject?.mode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -962,6 +986,7 @@ export default function AppPage() {
|
|||||||
open={isProjectDialogOpen}
|
open={isProjectDialogOpen}
|
||||||
onOpenChange={setIsProjectDialogOpen}
|
onOpenChange={setIsProjectDialogOpen}
|
||||||
onProjectCreated={handleProjectCreated}
|
onProjectCreated={handleProjectCreated}
|
||||||
|
initialMode={newProjectMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextDialog
|
<TextDialog
|
||||||
|
|||||||
@@ -37,14 +37,21 @@ interface ProjectDialogProps {
|
|||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
onProjectCreated: (project: Project) => void
|
onProjectCreated: (project: Project) => void
|
||||||
|
initialMode?: ProjectMode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectDialog({
|
export function ProjectDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onProjectCreated,
|
onProjectCreated,
|
||||||
|
initialMode = 'EINSATZ',
|
||||||
}: ProjectDialogProps) {
|
}: 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 [title, setTitle] = useState('')
|
||||||
const [location, setLocation] = useState('')
|
const [location, setLocation] = useState('')
|
||||||
const [description, setDescription] = 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
|
isCollapsed?: boolean
|
||||||
onToggleCollapse?: () => void
|
onToggleCollapse?: () => void
|
||||||
tenantId?: string | null
|
tenantId?: string | null
|
||||||
|
mode?: 'EINSATZ' | 'UEBUNG'
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryIcons: Record<string, typeof Flame> = {
|
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 [searchQuery, setSearchQuery] = useState('')
|
||||||
const [activeCategory, setActiveCategory] = useState<string>('')
|
const [activeCategory, setActiveCategory] = useState<string>('')
|
||||||
const [categories, setCategories] = useState<DisplayCategory[]>([])
|
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 ${
|
className={`p-1.5 rounded-md transition-colors ${
|
||||||
activeTab === 'journal' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
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" />
|
<ClipboardList className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -287,7 +289,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ClipboardList className="w-3.5 h-3.5" />
|
<ClipboardList className="w-3.5 h-3.5" />
|
||||||
Journal
|
{journalLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Flame,
|
||||||
|
GraduationCap,
|
||||||
Save,
|
Save,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Download,
|
Download,
|
||||||
@@ -43,13 +44,13 @@ import {
|
|||||||
Unlock,
|
Unlock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
|
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 { formatDateTime } from '@/lib/utils'
|
||||||
import { Logo } from '@/components/ui/logo'
|
import { Logo } from '@/components/ui/logo'
|
||||||
|
|
||||||
interface TopbarProps {
|
interface TopbarProps {
|
||||||
project: Project | null
|
project: Project | null
|
||||||
onNewProject: () => void
|
onNewProject: (mode?: ProjectMode) => void
|
||||||
onSaveProject: () => void
|
onSaveProject: () => void
|
||||||
onLoadProject: (project: Project, features: DrawFeature[]) => void
|
onLoadProject: (project: Project, features: DrawFeature[]) => void
|
||||||
onDeleteProject?: (projectId: string) => void
|
onDeleteProject?: (projectId: string) => void
|
||||||
@@ -188,6 +189,26 @@ export function Topbar({
|
|||||||
<span className="hidden lg:inline">{presentationLocked ? 'Gesperrt' : 'Frei'}</span>
|
<span className="hidden lg:inline">{presentationLocked ? 'Gesperrt' : 'Frei'}</span>
|
||||||
</Button>
|
</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>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" className="h-9 md:h-10 px-2 md:px-3 text-sm" title="Menü">
|
<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>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-52">
|
<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">
|
<DropdownMenuItem onClick={handleOpenLoadDialog} className="py-2.5 px-3">
|
||||||
<List className="w-4 h-4 mr-2" />
|
<List className="w-4 h-4 mr-2" />
|
||||||
Einsätze verwalten
|
Einsätze verwalten
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type ProjectMode = (typeof PROJECT_MODES)[number]
|
|||||||
export const projectSchema = z.object({
|
export const projectSchema = z.object({
|
||||||
title: z.string().min(1, 'Titel erforderlich').max(200, 'Titel zu lang'),
|
title: z.string().min(1, 'Titel erforderlich').max(200, 'Titel zu lang'),
|
||||||
mode: z.enum(PROJECT_MODES).optional(),
|
mode: z.enum(PROJECT_MODES).optional(),
|
||||||
|
exerciseEvaluation: z.string().max(10000).optional(),
|
||||||
location: z.string().optional(),
|
location: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
einsatzleiter: z.string().optional(),
|
einsatzleiter: z.string().optional(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface Project {
|
|||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
mode?: ProjectMode
|
mode?: ProjectMode
|
||||||
|
exerciseEvaluation?: string | null
|
||||||
location?: string
|
location?: string
|
||||||
description?: string
|
description?: string
|
||||||
einsatzleiter?: string
|
einsatzleiter?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user