feat(projects): Einsatz/Übung-Modus
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 22m24s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 22m24s
- Feld mode (EINSATZ/UEBUNG) am Projekt + idempotente Auto-Migration - Auswahl beim Erstellen (Segmented-Control mit Lucide-Icons) - Badge "Einsatz"/"Übung" in der Projektliste - Eigene Nummerierung für Übungen (Ü-YYYY-NNNN, getrennt gezählt) - Journal-Titel wird bei Übung zu "Übungs-Journal" - ROADMAP.md mit Verbesserungs-Fahrplan ergänzt Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
52
ROADMAP.md
Normal file
52
ROADMAP.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Lageplan – Verbesserungs-Fahrplan
|
||||||
|
|
||||||
|
Priorisierter Plan zur Weiterentwicklung. Wir arbeiten ihn Punkt für Punkt ab.
|
||||||
|
Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 – Fundament & Sicherheit
|
||||||
|
|
||||||
|
- [x] **0.1 – JWT-Secret härten** 🟢 ✅
|
||||||
|
- Gemeinsames Secret-Modul; in Produktion hart fehlschlagen, wenn `NEXTAUTH_SECRET`
|
||||||
|
fehlt oder < 32 Zeichen. Kein öffentlich bekannter Fallback mehr.
|
||||||
|
- Betrifft: `src/lib/auth.ts`, `src/middleware.ts`
|
||||||
|
- _Hinweis: Cookie-Flags (httpOnly/secure/sameSite) sind bereits korrekt._
|
||||||
|
- [x] **0.2 – Token-Laufzeit verkürzt** 🟢 ✅
|
||||||
|
- „Angemeldet bleiben" 30 → 14 Tage. Laufzeiten zentralisiert (`SESSION_MAX_AGE_*` in `auth.ts`).
|
||||||
|
- _Offen für später: automatische Verlängerung bei Aktivität + serverseitiger Widerruf (grösserer Umbau, braucht Live-Test)._
|
||||||
|
- [ ] **0.3 – Tenant-Admin-UX** 🟡
|
||||||
|
- Mitglieder einladen / Rolle ändern / deaktivieren. APIs existieren bereits.
|
||||||
|
|
||||||
|
## Phase 1 – Einsatz vs. Übung
|
||||||
|
|
||||||
|
- [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.
|
||||||
|
|
||||||
|
## Phase 2 – Symbole aufräumen & ergänzen
|
||||||
|
|
||||||
|
- [ ] **2.1 – Kategorien vereinheitlichen** 🟢 (Motorspritze, Hydrant, Leitern …)
|
||||||
|
- [ ] **2.2 – Fehlende taktische Zeichen** 🟡 (Warteraum, Rettungsachse … – Input von Fabian)
|
||||||
|
- [ ] **2.3 – Symbol-Schönheitsfehler** 🟢 (Eingang/Treppe Umriss, Absperrung Seitenlinien)
|
||||||
|
- [ ] **2.4 – Custom-Felder pro Symbol** 🟢 (z. B. Stockwerke; `Item.properties` existiert)
|
||||||
|
|
||||||
|
## Phase 3 – Lage-/Rapportansicht
|
||||||
|
|
||||||
|
- [ ] **3.1 – Reduzierte Lage-View** 🟡
|
||||||
|
- Read-Only (Pendenzen + Stand + Aufgaben) für Grossbildschirm. `Rapport` + Token existieren.
|
||||||
|
|
||||||
|
## Phase 4 – Echte Kollaboration
|
||||||
|
|
||||||
|
- [ ] **4.1 – Karte & Journal getrennt live-editierbar** 🔴
|
||||||
|
- Single-Editor-Lock aufbrechen; Socket.io-Sync erweitern.
|
||||||
|
|
||||||
|
## Phase 5 – Kür
|
||||||
|
|
||||||
|
- [ ] **5.1 – Linien-Typ-Abfrage** 🟡 (Rettungsachse / Leitung / normal)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Fortschritt wird hier laufend abgehakt._
|
||||||
@@ -70,6 +70,7 @@ async function migrate() {
|
|||||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "planBounds" JSONB`,
|
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "planBounds" JSONB`,
|
||||||
`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 "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`,
|
||||||
|
|||||||
@@ -151,6 +151,9 @@ 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).
|
||||||
|
// Als String (nicht Enum) gehalten, damit die idempotente Raw-SQL-Migration einfach bleibt.
|
||||||
|
mode String @default("EINSATZ")
|
||||||
title String
|
title String
|
||||||
location String?
|
location String?
|
||||||
description String?
|
description String?
|
||||||
|
|||||||
@@ -55,9 +55,11 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique Einsatz-Nr: E-YYYY-NNNN (auto-increment per tenant per year)
|
// Generate unique number per tenant/year/mode.
|
||||||
|
// Einsatz: E-YYYY-NNNN · Übung: Ü-YYYY-NNNN (getrennte Zählung je Modus)
|
||||||
|
const mode = validated.data.mode === 'UEBUNG' ? 'UEBUNG' : 'EINSATZ'
|
||||||
const year = new Date().getFullYear()
|
const year = new Date().getFullYear()
|
||||||
const einsatzPrefix = `E-${year}-`
|
const einsatzPrefix = `${mode === 'UEBUNG' ? 'Ü' : 'E'}-${year}-`
|
||||||
let einsatzNr = `${einsatzPrefix}0001`
|
let einsatzNr = `${einsatzPrefix}0001`
|
||||||
try {
|
try {
|
||||||
const lastProject = await (prisma as any).project.findFirst({
|
const lastProject = await (prisma as any).project.findFirst({
|
||||||
@@ -78,6 +80,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const project = await (prisma as any).project.create({
|
const project = await (prisma as any).project.create({
|
||||||
data: {
|
data: {
|
||||||
...validated.data,
|
...validated.data,
|
||||||
|
mode,
|
||||||
einsatzNr,
|
einsatzNr,
|
||||||
ownerId: user.id,
|
ownerId: user.id,
|
||||||
tenantId: user.tenantId || null,
|
tenantId: user.tenantId || null,
|
||||||
|
|||||||
@@ -534,6 +534,7 @@ export default function AppPage() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: currentProject.title,
|
title: currentProject.title,
|
||||||
|
mode: currentProject.mode,
|
||||||
location: currentProject.location || undefined,
|
location: currentProject.location || undefined,
|
||||||
description: currentProject.description || undefined,
|
description: currentProject.description || undefined,
|
||||||
einsatzleiter: currentProject.einsatzleiter || undefined,
|
einsatzleiter: currentProject.einsatzleiter || undefined,
|
||||||
@@ -929,6 +930,7 @@ export default function AppPage() {
|
|||||||
projectId={currentProject?.id || null}
|
projectId={currentProject?.id || null}
|
||||||
projectTitle={currentProject?.title || ''}
|
projectTitle={currentProject?.title || ''}
|
||||||
projectLocation={currentProject?.location || ''}
|
projectLocation={currentProject?.location || ''}
|
||||||
|
mode={currentProject?.mode}
|
||||||
einsatzleiter={currentProject?.einsatzleiter || ''}
|
einsatzleiter={currentProject?.einsatzleiter || ''}
|
||||||
journalfuehrer={currentProject?.journalfuehrer || ''}
|
journalfuehrer={currentProject?.journalfuehrer || ''}
|
||||||
einsatzNr={(currentProject as any)?.einsatzNr || ''}
|
einsatzNr={(currentProject as any)?.einsatzNr || ''}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { MapPin, Loader2, X } from 'lucide-react'
|
import { MapPin, Loader2, X, Flame, GraduationCap } from 'lucide-react'
|
||||||
import type { Project } from '@/types'
|
import type { Project, ProjectMode } from '@/types'
|
||||||
|
|
||||||
interface NominatimResult {
|
interface NominatimResult {
|
||||||
place_id: number
|
place_id: number
|
||||||
@@ -44,6 +44,7 @@ export function ProjectDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onProjectCreated,
|
onProjectCreated,
|
||||||
}: ProjectDialogProps) {
|
}: ProjectDialogProps) {
|
||||||
|
const [mode, setMode] = useState<ProjectMode>('EINSATZ')
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [location, setLocation] = useState('')
|
const [location, setLocation] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
@@ -142,6 +143,7 @@ export function ProjectDialog({
|
|||||||
try {
|
try {
|
||||||
const body: any = {
|
const body: any = {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
|
mode,
|
||||||
location: location.trim() || undefined,
|
location: location.trim() || undefined,
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
einsatzleiter: einsatzleiter.trim() || undefined,
|
einsatzleiter: einsatzleiter.trim() || undefined,
|
||||||
@@ -169,6 +171,7 @@ export function ProjectDialog({
|
|||||||
onProjectCreated(data.project)
|
onProjectCreated(data.project)
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
|
setMode('EINSATZ')
|
||||||
setTitle('')
|
setTitle('')
|
||||||
setLocation('')
|
setLocation('')
|
||||||
setDescription('')
|
setDescription('')
|
||||||
@@ -189,6 +192,7 @@ export function ProjectDialog({
|
|||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (!isCreating) {
|
if (!isCreating) {
|
||||||
|
setMode('EINSATZ')
|
||||||
setTitle('')
|
setTitle('')
|
||||||
setLocation('')
|
setLocation('')
|
||||||
setDescription('')
|
setDescription('')
|
||||||
@@ -200,21 +204,59 @@ export function ProjectDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isUebung = mode === 'UEBUNG'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Neuer Einsatz</DialogTitle>
|
<DialogTitle>{isUebung ? 'Neue Übung' : 'Neuer Einsatz'}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Modus-Auswahl: Einsatz vs. Übung */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Art</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('EINSATZ')}
|
||||||
|
disabled={isCreating}
|
||||||
|
className={`flex items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
!isUebung
|
||||||
|
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300'
|
||||||
|
: 'border-input text-muted-foreground hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Flame className="w-4 h-4" /> Einsatz
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('UEBUNG')}
|
||||||
|
disabled={isCreating}
|
||||||
|
className={`flex items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
isUebung
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300'
|
||||||
|
: 'border-input text-muted-foreground hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GraduationCap className="w-4 h-4" /> Übung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{isUebung
|
||||||
|
? 'Übung: zum Vorbereiten und Krokieren von Szenarien.'
|
||||||
|
: 'Einsatz: echte Lage mit Einsatz-Journal.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="project-title">Titel *</Label>
|
<Label htmlFor="project-title">Titel *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="project-title"
|
id="project-title"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="z.B. Wohnungsbrand Musterstrasse"
|
placeholder={isUebung ? 'z.B. Übung Hauptstrasse — Zimmerbrand' : 'z.B. Wohnungsbrand Musterstrasse'}
|
||||||
disabled={isCreating}
|
disabled={isCreating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ interface JournalViewProps {
|
|||||||
projectId: string | null
|
projectId: string | null
|
||||||
projectTitle: string
|
projectTitle: string
|
||||||
projectLocation: string
|
projectLocation: string
|
||||||
|
mode?: 'EINSATZ' | 'UEBUNG'
|
||||||
einsatzleiter: string
|
einsatzleiter: string
|
||||||
journalfuehrer: string
|
journalfuehrer: string
|
||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
@@ -65,7 +66,8 @@ function formatDateTime(dateStr: string) {
|
|||||||
d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JournalView({ projectId, projectTitle, projectLocation, einsatzleiter, journalfuehrer, canEdit, tenantId, einsatzNr, tenantName, tenantLogoUrl, mapRef, mapScreenshot: preCapuredScreenshot }: JournalViewProps) {
|
export function JournalView({ projectId, projectTitle, projectLocation, mode, einsatzleiter, journalfuehrer, canEdit, tenantId, einsatzNr, tenantName, tenantLogoUrl, mapRef, mapScreenshot: preCapuredScreenshot }: JournalViewProps) {
|
||||||
|
const isUebung = mode === 'UEBUNG'
|
||||||
const [entries, setEntries] = useState<JournalEntry[]>([])
|
const [entries, setEntries] = useState<JournalEntry[]>([])
|
||||||
const [checkItems, setCheckItems] = useState<JournalCheckItem[]>([])
|
const [checkItems, setCheckItems] = useState<JournalCheckItem[]>([])
|
||||||
const [pendenzen, setPendenzen] = useState<JournalPendenz[]>([])
|
const [pendenzen, setPendenzen] = useState<JournalPendenz[]>([])
|
||||||
@@ -432,7 +434,7 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
|
|||||||
<div className="flex items-center justify-between mb-2 print:mb-1">
|
<div className="flex items-center justify-between mb-2 print:mb-1">
|
||||||
<h2 className="text-lg md:text-xl font-bold flex items-center gap-2 print:text-base text-red-800 dark:text-red-400">
|
<h2 className="text-lg md:text-xl font-bold flex items-center gap-2 print:text-base text-red-800 dark:text-red-400">
|
||||||
<ClipboardList className="w-5 h-5 md:w-6 md:h-6 print:w-4 print:h-4" />
|
<ClipboardList className="w-5 h-5 md:w-6 md:h-6 print:w-4 print:h-4" />
|
||||||
Einsatz-Journal
|
{isUebung ? 'Übungs-Journal' : 'Einsatz-Journal'}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex gap-1.5 print:hidden">
|
<div className="flex gap-1.5 print:hidden">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -423,6 +423,11 @@ export function Topbar({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h4 className="font-medium">{p.title}</h4>
|
<h4 className="font-medium">{p.title}</h4>
|
||||||
|
{(p as any).mode === 'UEBUNG' ? (
|
||||||
|
<span className="text-xs bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300 px-1.5 py-0.5 rounded">Übung</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300 px-1.5 py-0.5 rounded">Einsatz</span>
|
||||||
|
)}
|
||||||
{project?.id === p.id && (
|
{project?.id === p.id && (
|
||||||
<span className="text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded">Aktiv</span>
|
<span className="text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded">Aktiv</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ export const loginSchema = z.object({
|
|||||||
password: z.string().min(1, 'Passwort erforderlich'),
|
password: z.string().min(1, 'Passwort erforderlich'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const PROJECT_MODES = ['EINSATZ', 'UEBUNG'] as const
|
||||||
|
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(),
|
||||||
location: z.string().optional(),
|
location: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
einsatzleiter: z.string().optional(),
|
einsatzleiter: z.string().optional(),
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
export type ProjectMode = 'EINSATZ' | 'UEBUNG'
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
mode?: ProjectMode
|
||||||
location?: string
|
location?: string
|
||||||
description?: string
|
description?: string
|
||||||
einsatzleiter?: string
|
einsatzleiter?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user