6 Commits

Author SHA1 Message Date
Pepe Ziberi
796ce53db3 feat(offline): Offline-Karten – Einsatzgebiet wird automatisch vorgeladen (v1.5.1)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 30m48s
Der härteste Einsatztauglich-Punkt: bisher war die Karte ohne Empfang nur dort
verfügbar, wo man schon einmal hingezoomt hatte. Jetzt:

- Beim Öffnen eines Einsatzes werden die OSM-Kacheln des Einsatzgebiets im
  Hintergrund vorgeladen (Zoom 13-18, ~800m Radius, max. 400 Kacheln)
- Exakt das MapLibre-Subdomain-Schema (x+y)%3 → Cache-Keys stimmen überein
- Bereits gecachte Kacheln werden übersprungen (schont OSM-Server + Akku)
- Parallelität 6, Start 4s nach Projektladen (Karte rendert zuerst)
- Marker pro Einsatz in localStorage, Auffrischung nach 14 Tagen
- Toast "Karte offline verfügbar" nach Abschluss
- Neue Bibliothek src/lib/offline-tiles.ts (Web-Mercator-Kachelmathematik)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 18:19:19 +02:00
Pepe Ziberi
2dbb2204f6 feat(cockpit): Modul-Baukasten – Einsatz-Cockpit frei zusammenstellbar (v1.5.0)
Jede Feuerwehr stellt ihr Cockpit selbst zusammen (Wunsch: modular & einfach):

M0 – Zerlegt:
- SOMA + Pendenzen als eigenständige Modul-Komponenten, gemeinsame Typen
  (journal/types.ts), einheitliche ModuleCard-Hülle

M1 – Registry & Verwaltung:
- Modul-Registry src/lib/modules.ts, Konfiguration pro Mandant in
  tenants.modulesConfig (JSONB, idempotente Migration)
- API GET/PUT /api/tenant/modules (normalisiert + validiert via normalizeModules)
- "Module"-Button im Journal (Admins): an/aus, Reihenfolge hoch/runter,
  eigene Module löschen

M2 – Eigene Module:
- Generisches Tabellen-Modul: frei definierbare Spalten (Text / Haken mit
  Zeitstempel / Auto-Zeit), max 8 Spalten
- Generische Daten-API /api/projects/[id]/modules/[moduleId]/items (+ [itemId]),
  neue Tabelle module_items, Live-Sync über journal-refresh Events

M3 – Feld-Vorlagen:
- Checkliste, Atemschutz-Überwachung (Trupp/Druck/Zeit/Draussen),
  Kräfte vor Ort (Name/Funktion/seit/Weg), Lagemeldungen (Hauptbereich),
  leeres Modul mit Spalten-Editor

Eingebaute Module (Journal/SOMA/Pendenzen) bleiben ohne Konfiguration exakt
wie bisher — kaputte Configs fallen sicher auf den Standard zurück.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 18:15:34 +02:00
Pepe Ziberi
6eab656ae6 fix(review): App-Kontrolle – 3 Funde behoben (v1.4.17)
Komplett-Check nach den UX-Umbauten (Typecheck + Produktions-Build + Code-Review):

- Tastenkürzel (V/L/G/...) lösen jetzt ebenfalls die Auto-Bearbeitung aus —
  vorher umgingen sie ensureEditing und der erste Strich wurde nicht gesynct
- ensureEditing feuert nicht mehr mehrfach parallel (editingLoading-Guard)
- Onboarding-Tour aktualisiert: Schritt "Bearbeitung starten" (Button existiert
  nicht mehr) → "Einfach loszeichnen"; Symbol-Schritt erwähnt Tap-to-place

Geprüft, ohne Befund: Auto-Save-Gating, Status-Zeile, flache Sidebar,
Linientyp-Dialog inkl. Gefahrenzone, Demo-Seite, Produktions-Build (Exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:58:01 +02:00
Pepe Ziberi
b1451b665a feat(ux): 3-fach entwirrt – Auto-Bearbeitung, Status-Zeile, flache Symbol-Sidebar (v1.4.16)
Auto-Bearbeitung (Lock vereinfacht):
- Kein manuelles "Bearbeitung starten" mehr: beim Werkzeugwählen/Zeichnen/Symbol-
  Antippen wird der Editier-Lock still übernommen (ensureEditing), sofern die Karte
  frei ist. Lock/Konflikt-Anzeige erscheint nur noch bei echtem Mehrpersonen-Zugriff.
- handleStartEditing bekommt silent-Option + boolean-Rückgabe.
- "Karte freigeben" bleibt als kleine Aktion für Übergabe.

Banner → eine kompakte Status-Zeile:
- Übung/Offline/E-Mail/Bearbeitung nicht mehr bis zu 4 Zeilen gestapelt, sondern
  eine einzige Chip-Zeile, die nur zeigt was zutrifft (sonst gar nichts).

Symbol-Sidebar flacher:
- Statt zwei verschachtelter Klapp-Sektionen (Meine Symbole/Bibliothek) mit Unter-
  Kategorie-Tabs jetzt EINE Chip-Reihe (Mandant + Bibliothek) + ein Raster.
- Suche zeigt Treffer flach über alle Kategorien.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:52:17 +02:00
Pepe Ziberi
efedbdba4d refactor(topbar): Handy-Menü entwirrt – ein einziges Menü statt zwei (v1.4.15)
- Mobil nur noch EIN ⋮-Menü (früher ☰ links + ⋮ rechts → verwirrend)
- Topbar mobil reduziert auf: Logo · Speichern · Neuer Einsatz · Menü
- Selten gebrauchte Buttons (Neue Übung, Präsentationsmodus) wandern ins Menü
- Menü gruppiert mit Trennern: Einsatz/Übung · Export/Karte · Ansicht · System/Konto
- Voller Funktionsumfang mobil erreichbar (Einsätze verwalten, Export, Admin,
  Schlaucheinstellungen, Tour, Kennwort, Abmelden)
- Desktop-Layout unverändert

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:41:52 +02:00
Pepe Ziberi
2556a5c4a4 feat(mobile+ux): Vollbild-Button auf Karte + Standort-Vorschlag beim Einsatz (v1.4.14)
- Mobile Vollbild-Button schwebend oben links auf der Karte (versteckt Browser-Leiste,
  document.fullscreen); Icon wechselt zwischen Vollbild/Verlassen
- Neuer Einsatz: aktueller Standort wird beim Öffnen automatisch als Einsatzort
  vorgeschlagen (Reverse-Geocoding via Nominatim), plus "Mein Standort"-Button
- Setzt zugleich mapCenter → Karte startet direkt am Standort
- Bei verweigertem/fehlendem Standort klarer Hinweis, manuelle Eingabe bleibt möglich
- Adress-Anzeige in gemeinsamen buildDisplayName() ausgelagert

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:33:58 +02:00
22 changed files with 1975 additions and 698 deletions

View File

@@ -74,6 +74,29 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross
- Offen (später): Berechnungsparameter pro Mandant, mehrere Leitungen/Pumpenkette,
Elevation-Proxy mit Cache, Ergebnis als speicherbares Lageplan-Objekt.
## Zusatz Modul-Baukasten (Einsatz-Cockpit) ✅ v1.5.0
- [x] Journal-Monolith zerlegt: SOMA + Pendenzen als eigenständige Module,
gemeinsame Typen, Karten-Hülle (`ModuleCard`)
- [x] Modul-Registry (`src/lib/modules.ts`) + Konfiguration pro Mandant
(`tenants.modulesConfig`), API `GET/PUT /api/tenant/modules`
- [x] „Module verwalten"-Dialog: an/aus, Reihenfolge, eigene Module erstellen
- [x] Generisches Tabellen-Modul (Spaltentypen Text/Haken/Zeit) mit eigener
Daten-API (`module_items`), Live-Sync über journal-refresh
- [x] Vorlagen: Checkliste, Atemschutz-Überwachung, Kräfte vor Ort,
Lagemeldungen, leeres Modul
- Offen (später): Übungs-Cockpit auf denselben Baukasten umstellen,
Modul-Daten in Rapport-PDF übernehmen
## Zusatz Offline-Karten ✅ v1.5.1
- [x] Kacheln des Einsatzgebiets werden beim Öffnen eines Einsatzes automatisch
vorgeladen (Zoom 1318, ~800 m Radius, max. 400 Kacheln, Parallelität 6)
- [x] Nutzt exakt das MapLibre-Subdomain-Schema `(x+y) % 3` → Cache-Keys passen
- [x] Bereits gecachte Kacheln werden übersprungen; Marker pro Einsatz (14 Tage)
- [x] SW cachte Kacheln schon Cache-First — jetzt auch nie besuchte Bereiche da
- Offen (später): Satellit/Swisstopo vorladen, grösserer Radius wählbar
## Phase 5 Kür
- [x] **5.1 Linien-Typ-Abfrage** 🟡 ✅

View File

@@ -1,6 +1,6 @@
{
"name": "lageplan",
"version": "1.4.13",
"version": "1.5.1",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true,
"scripts": {

View File

@@ -82,6 +82,8 @@ async function migrate() {
// Journal
`ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "isCorrected" BOOLEAN DEFAULT false`,
`ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "correctionOfId" TEXT`,
// Modul-Baukasten (Cockpit-Konfiguration pro Mandant)
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "modulesConfig" JSONB`,
]
let added = 0
for (const sql of columnMigrations) {
@@ -131,6 +133,17 @@ async function migrate() {
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"projectId" TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE
)`,
// Modul-Baukasten: generische Zeilen für Tabellen-Module
`CREATE TABLE IF NOT EXISTS module_items (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
"moduleId" TEXT NOT NULL,
data JSONB NOT NULL DEFAULT '{}',
"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
)`,
`CREATE INDEX IF NOT EXISTS module_items_project_module_idx ON module_items("projectId", "moduleId")`,
]
for (const sql of tableMigrations) {
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ }

View File

@@ -74,6 +74,8 @@ model Tenant {
notes String?
hiddenIconIds String[] @default([])
journalSuggestions String[] @default([])
// Modul-Baukasten: Cockpit-Konfiguration (siehe src/lib/modules.ts). NULL = Standard.
modulesConfig Json?
// Privacy consent
privacyAccepted Boolean @default(false)
@@ -193,6 +195,7 @@ model Project {
journalPendenzen JournalPendenz[]
exerciseGoals ExerciseGoal[]
rapports Rapport[]
moduleItems ModuleItem[]
@@map("projects")
}
@@ -349,6 +352,24 @@ model JournalPendenz {
@@map("journal_pendenzen")
}
// Generischer Datenspeicher für Tabellen-Module (Modul-Baukasten).
// data enthält die Zellwerte gemäss Spaltendefinition des Moduls,
// z.B. { "trupp": "Trupp 1", "druck": "280", "out": true, "outAt": "..." }
model ModuleItem {
id String @id @default(uuid())
moduleId String
data Json @default("{}")
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId, moduleId])
@@map("module_items")
}
model JournalCheckTemplate {
id String @id @default(uuid())
label String

View File

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { getProjectWithTenantCheck } from '@/lib/tenant'
/** Zeile eines Tabellen-Moduls aktualisieren (data wird gemerged) */
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string; moduleId: string; itemId: string }> }
) {
try {
const { id, moduleId, itemId } = 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).moduleItem.findFirst({
where: { id: itemId, projectId: id, moduleId },
})
if (!existing) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 })
const body = await request.json()
const patch = body && typeof body.data === 'object' && body.data !== null ? body.data : {}
const item = await (prisma as any).moduleItem.update({
where: { id: itemId },
data: { data: { ...(existing.data || {}), ...patch } },
})
return NextResponse.json(item)
} catch (error) {
console.error('Error updating module item:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
/** Zeile eines Tabellen-Moduls löschen */
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string; moduleId: string; itemId: string }> }
) {
try {
const { id, moduleId, itemId } = 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 })
await (prisma as any).moduleItem.deleteMany({
where: { id: itemId, projectId: id, moduleId },
})
return NextResponse.json({ ok: true })
} catch (error) {
console.error('Error deleting module item:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { getProjectWithTenantCheck } from '@/lib/tenant'
/** Zeilen eines Tabellen-Moduls laden */
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string; moduleId: string }> }
) {
try {
const { id, moduleId } = 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 items = await (prisma as any).moduleItem.findMany({
where: { projectId: id, moduleId },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
})
return NextResponse.json({ items })
} catch (error) {
console.error('Error fetching module items:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
/** Neue Zeile in einem Tabellen-Modul anlegen */
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string; moduleId: string }> }
) {
try {
const { id, moduleId } = 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 request.json()
const data = body && typeof body.data === 'object' && body.data !== null ? body.data : {}
const count = await (prisma as any).moduleItem.count({ where: { projectId: id, moduleId } })
const item = await (prisma as any).moduleItem.create({
data: { projectId: id, moduleId, data, sortOrder: count },
})
return NextResponse.json(item, { status: 201 })
} catch (error) {
console.error('Error creating module item:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { normalizeModules, DEFAULT_MODULES } from '@/lib/modules'
/** Cockpit-Modul-Konfiguration des eigenen Mandanten lesen */
export async function GET() {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (!user.tenantId) {
// SERVER_ADMIN ohne Mandant: Standard-Cockpit
return NextResponse.json({ modules: DEFAULT_MODULES, canManage: false })
}
const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: { modulesConfig: true },
})
const canManage = user.role === 'SERVER_ADMIN' || user.role === 'TENANT_ADMIN'
return NextResponse.json({
modules: normalizeModules(tenant?.modulesConfig),
canManage,
})
} catch (error) {
console.error('Error fetching module config:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
/** Cockpit-Modul-Konfiguration speichern (nur Admins) */
export async function PUT(request: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (!user.tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await request.json()
// normalizeModules verwirft Unbekanntes und stellt eingebaute Module sicher
const modules = normalizeModules(body?.modules)
await (prisma as any).tenant.update({
where: { id: user.tenantId },
data: { modulesConfig: modules },
})
return NextResponse.json({ modules })
} catch (error) {
console.error('Error saving module config:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -19,13 +19,14 @@ import { useAuth } from '@/components/providers/auth-provider'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { JournalView } from '@/components/journal/journal-view'
import { Lock, Unlock, Eye, AlertTriangle, WifiOff, GraduationCap, Map as MapIcon, ClipboardList, LayoutGrid, X } from 'lucide-react'
import { Lock, Eye, AlertTriangle, WifiOff, GraduationCap, Map as MapIcon, ClipboardList, LayoutGrid, X } 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'
import { useMapExport } from '@/hooks/use-map-export'
import { useAutoSave } from '@/hooks/use-auto-save'
import { useOfflineSync } from '@/hooks/use-offline-sync'
import { preloadTilesForArea, shouldPreloadForProject, markPreloadedForProject } from '@/lib/offline-tiles'
import { useRealtimeSync } from '@/hooks/use-realtime-sync'
import type { Project, ProjectMode, DrawFeature, Feature, JournalEntry, DrawMode } from '@/types'
import { useToolStore } from '@/stores/tool-store'
@@ -391,11 +392,53 @@ export default function AppPage() {
const canEdit = canEditMap // Abwärtskompatibel für Karten-bezogene Stellen
const isReadOnly = !!editingBy && !isEditingByMe
// Auto-Bearbeitung: sobald ein berechtigter Nutzer zu zeichnen beginnt und die Karte
// frei ist, wird der Editier-Lock still übernommen — kein manuelles „Bearbeitung starten".
const ensureEditing = useCallback(() => {
if (!currentProject || presentationLocked || !roleCanEdit) return
if (isEditingByMe || editingBy) return // ich zeichne schon / jemand anderes hält den Lock
if (editingLoading) return // Lock-Anfrage läuft bereits — nicht doppelt feuern
handleStartEditing({ silent: true })
}, [currentProject, presentationLocked, roleCanEdit, isEditingByMe, editingBy, editingLoading, handleStartEditing])
// Werkzeugwechsel auf ein Zeichen-Tool übernimmt vorab den Lock (rechtzeitig vor dem Zeichnen)
const handleDrawModeChange = useCallback((mode: DrawMode) => {
if (mode !== 'select') ensureEditing()
setDrawMode(mode)
}, [ensureEditing, setDrawMode])
// Features-Version bei Projektwechsel initialisieren
useEffect(() => {
featuresVersionRef.current = (currentProject as any)?.featuresVersion ?? 0
}, [currentProject?.id])
// Offline-Karten: Kacheln des Einsatzgebiets im Hintergrund vorladen,
// damit die Karte auch ohne Empfang (Keller, Funkloch) vollständig da ist.
useEffect(() => {
const proj = currentProject
if (!proj?.id || typeof navigator === 'undefined' || !navigator.onLine) return
const center = (proj as any).mapCenter
if (!center?.lng || !center?.lat) return
if (!shouldPreloadForProject(proj.id)) return
let cancelled = false
// Karte zuerst rendern lassen, dann im Hintergrund vorladen
const timer = setTimeout(async () => {
try {
const result = await preloadTilesForArea(center.lng, center.lat)
if (cancelled || result.skipped) return
markPreloadedForProject(proj.id)
if (result.cached > 0) {
toast({
title: 'Karte offline verfügbar',
description: `${result.cached} Kartenkacheln für das Einsatzgebiet gespeichert.`,
})
}
} catch { /* Vorladen ist Komfort — Fehler still ignorieren */ }
}, 4000)
return () => { cancelled = true; clearTimeout(timer) }
}, [currentProject?.id, toast])
// Speicher-Konflikt: Server-Stand übernehmen und den Nutzer warnen
const handleSaveConflict = useCallback((serverFeatures: DrawFeature[], serverVersion: number) => {
setFeatures(serverFeatures)
@@ -647,6 +690,7 @@ export default function AppPage() {
}
const handleFeaturesChange = useCallback((newFeatures: DrawFeature[]) => {
ensureEditing() // Auto-Bearbeitung: Lock übernehmen, falls noch nicht geschehen
undoStackRef.current.push(featuresRef.current)
redoStackRef.current = []
@@ -666,7 +710,7 @@ export default function AppPage() {
setFeatures(newFeatures)
broadcastFeatures(newFeatures)
}, [addAudit, broadcastFeatures])
}, [addAudit, broadcastFeatures, ensureEditing])
// Wendet den gewählten Linientyp an: setzt properties.lineType und bei Leitung/
// Rettungsachse die konventionelle Farbe/Stärke (Normal behält die Zeichenfarbe).
@@ -723,12 +767,13 @@ export default function AppPage() {
// Tap-to-place: Symbol antippen → Sidebar schliessen, Karte zeigen, nächster Tipp platziert
const handleSymbolSelect = useCallback((symbol: { id: string; imageUrl: string }) => {
ensureEditing() // Lock schon beim Auswählen übernehmen, damit das Platzieren sofort greift
setPendingSymbol(symbol)
setIsSidebarOpen(false)
setActiveTab('map')
setDrawMode('select')
toast({ title: 'Symbol platzieren', description: 'Tippe auf die Karte, um das Symbol zu setzen.' })
}, [setIsSidebarOpen, setActiveTab, toast])
}, [setIsSidebarOpen, setActiveTab, toast, ensureEditing, setDrawMode])
const handleTextPlace = useCallback((coordinates: [number, number]) => {
setTextPlaceCoords(coordinates)
@@ -783,7 +828,7 @@ export default function AppPage() {
onRedo: handleRedo,
onSave: handleSaveProject,
onDelete: handleFeaturesChange,
onToolChange: setDrawMode,
onToolChange: handleDrawModeChange, // Auto-Bearbeitung greift auch bei Tastenkürzeln
onHelpOpen: useCallback(() => setIsShortcutHelpOpen(true), []),
})
@@ -909,82 +954,55 @@ 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 md:py-1.5 bg-blue-600 text-white text-xs md:text-sm font-semibold tracking-wide">
<GraduationCap className="w-4 h-4 shrink-0" />
<span>ÜBUNG kein realer Einsatz</span>
</div>
)}
{/* Offline banner */}
{isOffline && (
<div className="flex items-center justify-center gap-2 md:gap-3 px-3 md:px-4 py-1.5 md:py-2 bg-orange-50 dark:bg-orange-950/40 border-b border-orange-200 dark:border-orange-800 text-xs md:text-sm text-orange-800 dark:text-orange-300">
<WifiOff className="w-4 h-4 shrink-0" />
<span>
<strong>Offline</strong><span className="hidden sm:inline">-Modus Änderungen werden lokal gespeichert und beim Reconnect synchronisiert.</span>
{syncQueueCount > 0 && ` (${syncQueueCount} ausstehend)`}
</span>
</div>
)}
{/* Email verification banner */}
{user && user.emailVerified === false && (
<div className="flex items-center justify-center gap-2 md:gap-3 px-3 md:px-4 py-1.5 md:py-2 bg-amber-50 dark:bg-amber-950/40 border-b border-amber-200 dark:border-amber-800 text-xs md:text-sm text-amber-800 dark:text-amber-300">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>E-Mail noch nicht bestätigt.<span className="hidden sm:inline"> Bitte prüfen Sie Ihren Posteingang.</span></span>
<button
onClick={async () => {
try {
const res = await fetch('/api/auth/resend-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: user.email }),
})
if (res.ok) toast({ title: 'Bestätigungsmail gesendet', description: 'Bitte prüfen Sie Ihren Posteingang.' })
else toast({ title: 'Fehler', description: 'Konnte Bestätigungsmail nicht senden.', variant: 'destructive' })
} catch { toast({ title: 'Fehler', description: 'Verbindungsfehler.', variant: 'destructive' }) }
}}
className="shrink-0 text-xs font-semibold underline hover:no-underline text-amber-700 dark:text-amber-400"
>
Erneut senden
</button>
</div>
)}
{/* Live editing banner */}
{currentProject && (
<div className="flex items-center justify-between gap-2 px-3 md:px-4 py-1.5 bg-muted/50 border-b text-xs md:text-sm">
{isReadOnly ? (
<div className="flex items-center gap-2 text-orange-600 min-w-0">
<Eye className="w-4 h-4 shrink-0" />
<span className="truncate"><strong>{editingBy?.name}</strong> bearbeitet die Karte<span className="hidden sm:inline"> Live-Ansicht{roleCanEdit ? ' · Journal bleibt für Sie bearbeitbar' : ''}</span></span>
</div>
) : isEditingByMe ? (
<div className="flex items-center gap-2 text-green-600 min-w-0">
<Lock className="w-4 h-4 shrink-0" />
<span className="truncate">Sie bearbeiten die Karte<span className="hidden sm:inline"> andere sehen Ihre Änderungen in Echtzeit</span></span>
</div>
) : (
<div className="flex items-center gap-2 text-muted-foreground min-w-0">
<Unlock className="w-4 h-4 shrink-0" />
<span className="truncate">Niemand bearbeitet gerade</span>
</div>
{/* Kompakte Status-Zeile — eine Zeile mit Chips statt bis zu vier gestapelten Bannern.
Zeigt nur, was gerade zutrifft; im Normalfall (Einsatz, online, verifiziert,
niemand fremd am Zeichnen) erscheint sie gar nicht. */}
{(currentProject?.mode === 'UEBUNG' || isOffline || isReadOnly || isEditingByMe || (user && user.emailVerified === false)) && (
<div data-tour="edit-toggle" className="flex flex-wrap items-center gap-1.5 px-3 py-1 border-b bg-muted/30 text-xs">
{currentProject?.mode === 'UEBUNG' && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-600 text-white font-semibold px-2 py-0.5">
<GraduationCap className="w-3.5 h-3.5" /> ÜBUNG kein realer Einsatz
</span>
)}
{isOffline && (
<span className="inline-flex items-center gap-1 rounded-full bg-orange-100 text-orange-800 dark:bg-orange-950/50 dark:text-orange-300 font-medium px-2 py-0.5">
<WifiOff className="w-3.5 h-3.5" /> Offline{syncQueueCount > 0 ? ` · ${syncQueueCount} ausstehend` : ''}
</span>
)}
{isReadOnly && (
<span className="inline-flex items-center gap-1 rounded-full bg-orange-100 text-orange-800 dark:bg-orange-950/50 dark:text-orange-300 font-medium px-2 py-0.5">
<Eye className="w-3.5 h-3.5" /> <strong className="font-semibold">{editingBy?.name}</strong> bearbeitet · Live-Ansicht
</span>
)}
{isEditingByMe && (
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 text-green-800 dark:bg-green-950/50 dark:text-green-300 font-medium px-2 py-0.5">
<Lock className="w-3.5 h-3.5" /> Sie bearbeiten
<button onClick={handleStopEditing} disabled={editingLoading} className="ml-1 underline hover:no-underline disabled:opacity-50" title="Karte für andere freigeben (speichert)">
freigeben
</button>
</span>
)}
{user && user.emailVerified === false && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300 font-medium px-2 py-0.5">
<AlertTriangle className="w-3.5 h-3.5" /> E-Mail unbestätigt
<button
onClick={async () => {
try {
const res = await fetch('/api/auth/resend-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: user.email }),
})
if (res.ok) toast({ title: 'Bestätigungsmail gesendet', description: 'Bitte prüfen Sie Ihren Posteingang.' })
else toast({ title: 'Fehler', description: 'Konnte Bestätigungsmail nicht senden.', variant: 'destructive' })
} catch { toast({ title: 'Fehler', description: 'Verbindungsfehler.', variant: 'destructive' }) }
}}
className="ml-1 underline hover:no-underline"
>
senden
</button>
</span>
)}
<div data-tour="edit-toggle" className="flex items-center gap-2 shrink-0">
{roleCanEdit && !isEditingByMe && !isReadOnly && (
<Button size="sm" variant="default" onClick={handleStartEditing} disabled={editingLoading}>
<Lock className="w-3.5 h-3.5 mr-1" />
Bearbeitung starten
</Button>
)}
{isEditingByMe && (
<Button size="sm" variant="outline" onClick={handleStopEditing} disabled={editingLoading}>
<Unlock className="w-3.5 h-3.5 mr-1" />
Bearbeitung beenden
</Button>
)}
</div>
</div>
)}
@@ -993,7 +1011,7 @@ export default function AppPage() {
<div data-tour="toolbar" className={`contents ${activeTab !== 'map' ? 'hidden' : ''}`}>
<LeftToolbar
drawMode={drawMode || 'select'}
onDrawModeChange={setDrawMode}
onDrawModeChange={handleDrawModeChange}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
selectedWidth={selectedWidth}
@@ -1021,6 +1039,8 @@ export default function AppPage() {
undoDrawPointRef={undoDrawPointRef}
pendingSymbol={pendingSymbol ? { iconId: pendingSymbol.id, imageUrl: pendingSymbol.imageUrl } : null}
onSymbolPlaced={() => setPendingSymbol(null)}
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
/>
</main>
</div>

View File

@@ -33,6 +33,23 @@ interface NominatimResult {
}
}
// Baut aus einem Nominatim-Ergebnis eine kurze, lesbare Adresse (Strasse Nr, PLZ Ort)
function buildDisplayName(result: NominatimResult): string {
const addr = result.address
if (!addr) return result.display_name
const parts: string[] = []
if (addr.road) {
parts.push(addr.road + (addr.house_number ? ' ' + addr.house_number : ''))
}
const city = addr.city || addr.town || addr.village || addr.municipality
if (addr.postcode && city) {
parts.push(`${addr.postcode} ${city}`)
} else if (city) {
parts.push(city)
}
return parts.length > 0 ? parts.join(', ') : result.display_name
}
interface ProjectDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -65,6 +82,9 @@ export function ProjectDialog({
const [isSearching, setIsSearching] = useState(false)
const [showSuggestions, setShowSuggestions] = useState(false)
const [selectedCoords, setSelectedCoords] = useState<{ lat: number; lng: number } | null>(null)
const [isLocating, setIsLocating] = useState(false)
const [locateError, setLocateError] = useState('')
const autoLocatedRef = useRef(false)
const debounceRef = useRef<NodeJS.Timeout | null>(null)
const suggestionsRef = useRef<HTMLDivElement>(null)
@@ -103,28 +123,64 @@ export function ProjectDialog({
}
const handleSelectSuggestion = (result: NominatimResult) => {
// Build a clean display name
const addr = result.address
let displayName = result.display_name
if (addr) {
const parts: string[] = []
if (addr.road) {
parts.push(addr.road + (addr.house_number ? ' ' + addr.house_number : ''))
}
const city = addr.city || addr.town || addr.village || addr.municipality
if (addr.postcode && city) {
parts.push(`${addr.postcode} ${city}`)
} else if (city) {
parts.push(city)
}
if (parts.length > 0) displayName = parts.join(', ')
}
setLocation(displayName)
setLocation(buildDisplayName(result))
setSelectedCoords({ lat: parseFloat(result.lat), lng: parseFloat(result.lon) })
setSuggestions([])
setShowSuggestions(false)
}
// Aktuellen Standort ermitteln und als Einsatzort vorschlagen (Reverse-Geocoding)
const applyCurrentLocation = useCallback(() => {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
setLocateError('Standort auf diesem Gerät nicht verfügbar')
return
}
setIsLocating(true)
setLocateError('')
navigator.geolocation.getCurrentPosition(
async (pos) => {
const { latitude, longitude } = pos.coords
setSelectedCoords({ lat: latitude, lng: longitude })
setShowSuggestions(false)
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${latitude}&lon=${longitude}&format=json&addressdetails=1`
)
if (res.ok) {
const data: NominatimResult = await res.json()
setLocation(buildDisplayName(data))
} else {
setLocation(`${latitude.toFixed(5)}, ${longitude.toFixed(5)}`)
}
} catch {
setLocation(`${latitude.toFixed(5)}, ${longitude.toFixed(5)}`)
} finally {
setIsLocating(false)
}
},
(err) => {
setIsLocating(false)
setLocateError(
err.code === err.PERMISSION_DENIED
? 'Standortzugriff verweigert'
: 'Standort konnte nicht ermittelt werden'
)
},
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 60000 }
)
}, [])
// Beim Öffnen eines neuen Einsatzes den Standort automatisch als Ort vorschlagen
useEffect(() => {
if (open && mode === 'EINSATZ' && !autoLocatedRef.current && !location && !selectedCoords) {
autoLocatedRef.current = true
applyCurrentLocation()
}
if (!open) {
autoLocatedRef.current = false
}
}, [open, mode, location, selectedCoords, applyCurrentLocation])
// Close suggestions on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
@@ -186,6 +242,8 @@ export function ProjectDialog({
setJournalfuehrer('')
setSelectedCoords(null)
setSuggestions([])
setLocateError('')
setIsLocating(false)
} catch (error) {
toast({
title: 'Fehler',
@@ -207,6 +265,8 @@ export function ProjectDialog({
setJournalfuehrer('')
setSelectedCoords(null)
setSuggestions([])
setLocateError('')
setIsLocating(false)
onOpenChange(false)
}
}
@@ -269,14 +329,26 @@ export function ProjectDialog({
</div>
<div className="space-y-2">
<Label htmlFor="project-location">
Einsatzort
{selectedCoords && (
<span className="ml-2 text-xs text-green-600 font-normal inline-flex items-center gap-1">
<MapPin className="w-3 h-3" /> Koordinaten gesetzt
</span>
)}
</Label>
<div className="flex items-center justify-between gap-2">
<Label htmlFor="project-location">
Einsatzort
{selectedCoords && (
<span className="ml-2 text-xs text-green-600 font-normal inline-flex items-center gap-1">
<MapPin className="w-3 h-3" /> Koordinaten gesetzt
</span>
)}
</Label>
<button
type="button"
onClick={applyCurrentLocation}
disabled={isCreating || isLocating}
className="shrink-0 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline disabled:opacity-50"
title="Aktuellen Standort als Einsatzort übernehmen"
>
{isLocating ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <MapPin className="w-3.5 h-3.5" />}
{isLocating ? 'Ermittle…' : 'Mein Standort'}
</button>
</div>
<div className="relative" ref={suggestionsRef}>
<div className="relative">
<Input
@@ -336,9 +408,13 @@ export function ProjectDialog({
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
Adresse suchen die Karte springt automatisch zum Einsatzort
</p>
{locateError ? (
<p className="text-xs text-amber-600">{locateError} Adresse bitte manuell eingeben.</p>
) : (
<p className="text-xs text-muted-foreground">
Standort wird beim Öffnen vorgeschlagen oder Adresse suchen. Die Karte springt automatisch zum Einsatzort.
</p>
)}
</div>
<div className="space-y-2">

View File

@@ -3,42 +3,19 @@
import { useState, useEffect, useCallback, useRef, MutableRefObject } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Plus, Trash2, Check, Clock, CheckSquare,
Plus, Check, Clock, CheckSquare,
AlertTriangle, ClipboardList, Loader2, Printer, Pencil, Send, FileText,
Blocks, ListChecks, Shield, Users, Radio, Table as TableIcon,
} from 'lucide-react'
import { getSocket } from '@/lib/socket'
import { RapportDialog } from '@/components/journal/rapport-dialog'
interface JournalEntry {
id: string
time: string
what: string
who: string | null
done: boolean
doneAt: string | null
isCorrected?: boolean
correctionOfId?: string | null
}
interface JournalCheckItem {
id: string
label: string
confirmed: boolean
confirmedAt: string | null
ok: boolean
okAt: string | null
}
interface JournalPendenz {
id: string
what: string
who: string | null
whenHow: string | null
done: boolean
doneAt: string | null
}
import { ModuleManagerDialog } from '@/components/journal/module-manager-dialog'
import { SomaModule } from '@/components/journal/modules/soma-module'
import { PendenzenModule } from '@/components/journal/modules/pendenzen-module'
import { TableModule } from '@/components/journal/modules/table-module'
import { DEFAULT_MODULES, type CockpitModule } from '@/lib/modules'
import { type JournalEntry, type JournalCheckItem, type JournalPendenz, formatTime } from '@/components/journal/types'
interface JournalViewProps {
projectId: string | null
@@ -56,14 +33,38 @@ interface JournalViewProps {
mapScreenshot?: string
}
function formatTime(dateStr: string) {
return new Date(dateStr).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
/** Lucide-Icons für Modul-Karten (Schlüssel siehe src/lib/modules.ts) */
const MODULE_ICONS: Record<string, typeof AlertTriangle> = {
'clipboard-list': ClipboardList,
'alert-triangle': AlertTriangle,
'check-square': CheckSquare,
'list-checks': ListChecks,
'shield': Shield,
'users': Users,
'radio': Radio,
'table': TableIcon,
}
function formatDateTime(dateStr: string) {
const d = new Date(dateStr)
return d.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) + ' ' +
d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
/** Karten-Hülle für Seitenspalten-Module — einheitliche rote Titelzeile */
function ModuleCard({ icon, name, count, children }: {
icon: string
name: string
count?: string
children: React.ReactNode
}) {
const Icon = MODULE_ICONS[icon] || TableIcon
return (
<div className="border-b border-border">
<div className="px-2 py-1.5 bg-stone-100 dark:bg-muted/50 border-b-2 border-red-400 dark:border-red-800">
<h3 className="font-semibold text-xs md:text-sm print:text-[10px] flex items-center gap-1 text-red-800 dark:text-red-400">
<Icon className="w-3.5 h-3.5" />
{name}
{count && <span className="text-[10px] text-muted-foreground font-normal">({count})</span>}
</h3>
</div>
{children}
</div>
)
}
export function JournalView({ projectId, projectTitle, projectLocation, mode, einsatzleiter, journalfuehrer, canEdit, tenantId, einsatzNr, tenantName, tenantLogoUrl, mapRef, mapScreenshot: preCapuredScreenshot }: JournalViewProps) {
@@ -74,18 +75,15 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
const [isLoading, setIsLoading] = useState(false)
const initDoneRef = useRef(false)
// Modul-Baukasten: Cockpit-Konfiguration des Mandanten
const [modules, setModules] = useState<CockpitModule[]>(DEFAULT_MODULES)
const [canManageModules, setCanManageModules] = useState(false)
const [showModuleManager, setShowModuleManager] = useState(false)
// New entry form
const [newWhat, setNewWhat] = useState('')
const [newWho, setNewWho] = useState('')
// New pendenz form
const [newPendWhat, setNewPendWhat] = useState('')
const [newPendWho, setNewPendWho] = useState('')
const [newPendWhen, setNewPendWhen] = useState('')
// New check item
const [newCheckLabel, setNewCheckLabel] = useState('')
const scrollRef = useRef<HTMLDivElement>(null)
// Rapport creation
@@ -147,6 +145,17 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
}
}, [projectId])
// Cockpit-Modul-Konfiguration laden
useEffect(() => {
fetch('/api/tenant/modules')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.modules) setModules(data.modules)
if (data) setCanManageModules(!!data.canManage)
})
.catch(() => {})
}, [tenantId])
// Init check items from templates if none exist (guarded against double-call)
const initCheckItems = useCallback(async () => {
if (!projectId || initDoneRef.current) return
@@ -290,24 +299,23 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
}, [projectId, notifyJournalChanged])
// Add custom check item
const addCheckItem = useCallback(async () => {
if (!projectId || !newCheckLabel.trim()) return
const addCheckItem = useCallback(async (label: string) => {
if (!projectId || !label.trim()) return
try {
const res = await fetch(`/api/projects/${projectId}/journal/check-items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: newCheckLabel.trim(), sortOrder: checkItems.length }),
body: JSON.stringify({ label: label.trim(), sortOrder: checkItems.length }),
})
if (res.ok) {
const item = await res.json()
setCheckItems(prev => [...prev, item])
setNewCheckLabel('')
notifyJournalChanged()
}
} catch (err) {
console.error('Failed to add check item:', err)
}
}, [projectId, newCheckLabel, checkItems.length, notifyJournalChanged])
}, [projectId, checkItems.length, notifyJournalChanged])
// Delete check item
const deleteCheckItem = useCallback(async (itemId: string) => {
@@ -322,26 +330,23 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
}, [projectId, notifyJournalChanged])
// Add pendenz
const addPendenz = useCallback(async () => {
if (!projectId || !newPendWhat.trim()) return
const addPendenz = useCallback(async (what: string, who: string, whenHow: string) => {
if (!projectId || !what.trim()) return
try {
const res = await fetch(`/api/projects/${projectId}/journal/pendenzen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ what: newPendWhat.trim(), who: newPendWho.trim() || null, whenHow: newPendWhen.trim() || null }),
body: JSON.stringify({ what: what.trim(), who: who.trim() || null, whenHow: whenHow.trim() || null }),
})
if (res.ok) {
const item = await res.json()
setPendenzen(prev => [...prev, item])
setNewPendWhat('')
setNewPendWho('')
setNewPendWhen('')
notifyJournalChanged()
}
} catch (err) {
console.error('Failed to add pendenz:', err)
}
}, [projectId, newPendWhat, newPendWho, newPendWhen, notifyJournalChanged])
}, [projectId, notifyJournalChanged])
// Toggle pendenz done
const togglePendenzDone = useCallback(async (p: JournalPendenz) => {
@@ -417,6 +422,11 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
)
}
// Aktivierte Module nach Slot aufteilen (Reihenfolge = sortOrder aus der Konfiguration)
const enabledModules = modules.filter(m => m.enabled)
const mainModules = enabledModules.filter(m => m.slot === 'main')
const sideModules = enabledModules.filter(m => m.slot === 'side')
return (
<>
{/* Print styles: landscape, compact */}
@@ -437,6 +447,12 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
{isUebung ? 'Übungs-Journal' : 'Einsatz-Journal'}
</h2>
<div className="flex gap-1.5 print:hidden">
{canManageModules && (
<Button variant="outline" size="sm" onClick={() => setShowModuleManager(true)} title="Cockpit-Module verwalten">
<Blocks className="w-4 h-4 md:mr-1.5" />
<span className="hidden md:inline">Module</span>
</Button>
)}
<Button
variant="outline"
size="sm"
@@ -502,19 +518,19 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-1 text-sm print:text-xs">
<div>
<span className="text-muted-foreground text-xs print:text-[9px]">Einsatz</span>
<p className="font-semibold truncate">{einsatzNr && <span className="font-mono text-xs bg-primary/10 text-primary px-1 py-0.5 rounded mr-1">{einsatzNr}</span>}{projectTitle || '\u2013'}</p>
<p className="font-semibold truncate">{einsatzNr && <span className="font-mono text-xs bg-primary/10 text-primary px-1 py-0.5 rounded mr-1">{einsatzNr}</span>}{projectTitle || ''}</p>
</div>
<div>
<span className="text-muted-foreground text-xs print:text-[9px]">Standort</span>
<p className="font-semibold truncate">{projectLocation || '\u2013'}</p>
<p className="font-semibold truncate">{projectLocation || ''}</p>
</div>
<div>
<span className="text-muted-foreground text-xs print:text-[9px]">Einsatzleiter</span>
<p className="font-semibold truncate">{einsatzleiter || '\u2013'}</p>
<p className="font-semibold truncate">{einsatzleiter || ''}</p>
</div>
<div>
<span className="text-muted-foreground text-xs print:text-[9px]">Journalführer</span>
<p className="font-semibold truncate">{journalfuehrer || '\u2013'}</p>
<p className="font-semibold truncate">{journalfuehrer || ''}</p>
</div>
</div>
@@ -543,372 +559,265 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
)}
</div>
{/* Main content: side-by-side on large screens */}
{/* Main content: side-by-side on large screens — Module gemäss Mandanten-Konfiguration */}
<div className="flex flex-col lg:flex-row print:flex-row">
{/* Journal entries */}
{/* Hauptbereich: Journal + weitere main-Module */}
<div className="flex-1 min-w-0">
{/* Table header */}
<div className="grid grid-cols-[55px_1fr_70px_40px_30px] md:grid-cols-[70px_1fr_90px_50px_40px] print:grid-cols-[60px_1fr_70px_40px] gap-px text-[11px] md:text-xs font-semibold sticky top-0 z-10 shadow-sm">
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground border-b border-red-200 dark:border-border">Zeit</div>
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground border-b border-red-200 dark:border-border">Was</div>
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground border-b border-red-200 dark:border-border">Wer</div>
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground text-center border-b border-red-200 dark:border-border">Ok</div>
<div className="bg-stone-100 dark:bg-muted px-1 py-1.5 print:hidden border-b border-red-200 dark:border-border"></div>
</div>
{mainModules.map(mod => {
if (mod.type === 'journal') {
return (
<div key={mod.id}>
{/* Table header */}
<div className="grid grid-cols-[55px_1fr_70px_40px_30px] md:grid-cols-[70px_1fr_90px_50px_40px] print:grid-cols-[60px_1fr_70px_40px] gap-px text-[11px] md:text-xs font-semibold sticky top-0 z-10 shadow-sm">
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground border-b border-red-200 dark:border-border">Zeit</div>
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground border-b border-red-200 dark:border-border">Was</div>
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground border-b border-red-200 dark:border-border">Wer</div>
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground text-center border-b border-red-200 dark:border-border">Ok</div>
<div className="bg-stone-100 dark:bg-muted px-1 py-1.5 print:hidden border-b border-red-200 dark:border-border"></div>
</div>
{isLoading ? (
<div className="flex items-center justify-center p-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Lade Journal...
</div>
) : entries.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">
Noch keine Einträge.
</div>
) : (
<div className="divide-y divide-border">
{entries.map((entry, idx) => (
<div
key={entry.id}
className={`grid grid-cols-[55px_1fr_70px_40px_30px] md:grid-cols-[70px_1fr_90px_50px_40px] print:grid-cols-[60px_1fr_70px_40px] gap-px text-[11px] md:text-xs print:text-[9px] ${entry.done ? 'bg-green-50 dark:bg-green-950/20 print:bg-green-50' : idx % 2 === 0 ? 'bg-white dark:bg-card' : 'bg-stone-50 dark:bg-muted/30'}`}
>
<div className="px-2 py-1.5 print:py-1 font-mono tabular-nums text-muted-foreground">
{formatTime(entry.time)}
</div>
<div className={`px-2 py-1.5 print:py-1 break-words ${entry.done ? 'text-muted-foreground' : ''} ${(entry as any).isCorrected ? 'line-through opacity-50' : ''} ${(entry as any).correctionOfId ? 'text-amber-700 dark:text-amber-400 italic' : ''}`}>
{entry.what}
{(entry as any).isCorrected && (
<span className="ml-1.5 text-red-500 text-[10px] print:text-[8px] font-medium no-underline">
(korrigiert)
</span>
)}
{entry.done && entry.doneAt && (
<span className="ml-1.5 text-green-600 text-[10px] print:text-[8px] font-medium">
(erledigt um {formatTime(entry.doneAt)})
</span>
)}
</div>
<div className="px-2 py-1.5 print:py-1 text-muted-foreground truncate">
{entry.who || '\u2013'}
</div>
<div className="px-1 py-1.5 print:py-1 flex items-center justify-center">
{canEdit && !(entry as any).isCorrected ? (
<button
onClick={() => toggleEntryDone(entry)}
className={`w-4 h-4 md:w-5 md:h-5 rounded border-2 flex items-center justify-center transition-colors ${
entry.done
? 'bg-green-500 border-green-500 text-white'
: 'border-muted-foreground/30 hover:border-primary'
}`}
>
{entry.done && <Check className="w-2.5 h-2.5 md:w-3 md:h-3" />}
</button>
) : (
entry.done ? <Check className="w-3 h-3 text-green-500 opacity-40" /> : (entry as any).isCorrected ? <span className="w-4 h-4 md:w-5 md:h-5 rounded border-2 border-muted-foreground/20 bg-muted/30" /> : null
)}
</div>
<div className="px-1 py-1.5 flex items-center justify-center print:hidden">
{canEdit && !(entry as any).isCorrected && !(entry as any).correctionOfId && (
<button
onClick={() => { setCorrectionEntryId(entry.id); setCorrectionText(entry.what) }}
className="text-muted-foreground hover:text-amber-600 p-0.5"
title="Korrektur erstellen"
>
<Pencil className="w-3 h-3" />
</button>
)}
</div>
</div>
))}
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center p-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Lade Journal...
</div>
) : entries.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">
Noch keine Einträge.
</div>
) : (
<div className="divide-y divide-border">
{entries.map((entry, idx) => (
<div
key={entry.id}
className={`grid grid-cols-[55px_1fr_70px_40px_30px] md:grid-cols-[70px_1fr_90px_50px_40px] print:grid-cols-[60px_1fr_70px_40px] gap-px text-[11px] md:text-xs print:text-[9px] ${entry.done ? 'bg-green-50 dark:bg-green-950/20 print:bg-green-50' : idx % 2 === 0 ? 'bg-white dark:bg-card' : 'bg-stone-50 dark:bg-muted/30'}`}
>
<div className="px-2 py-1.5 print:py-1 font-mono tabular-nums text-muted-foreground">
{formatTime(entry.time)}
</div>
<div className={`px-2 py-1.5 print:py-1 break-words ${entry.done ? 'text-muted-foreground' : ''} ${(entry as any).isCorrected ? 'line-through opacity-50' : ''} ${(entry as any).correctionOfId ? 'text-amber-700 dark:text-amber-400 italic' : ''}`}>
{entry.what}
{(entry as any).isCorrected && (
<span className="ml-1.5 text-red-500 text-[10px] print:text-[8px] font-medium no-underline">
(korrigiert)
</span>
)}
{entry.done && entry.doneAt && (
<span className="ml-1.5 text-green-600 text-[10px] print:text-[8px] font-medium">
(erledigt um {formatTime(entry.doneAt)})
</span>
)}
</div>
<div className="px-2 py-1.5 print:py-1 text-muted-foreground truncate">
{entry.who || ''}
</div>
<div className="px-1 py-1.5 print:py-1 flex items-center justify-center">
{canEdit && !(entry as any).isCorrected ? (
<button
onClick={() => toggleEntryDone(entry)}
className={`w-4 h-4 md:w-5 md:h-5 rounded border-2 flex items-center justify-center transition-colors ${
entry.done
? 'bg-green-500 border-green-500 text-white'
: 'border-muted-foreground/30 hover:border-primary'
}`}
>
{entry.done && <Check className="w-2.5 h-2.5 md:w-3 md:h-3" />}
</button>
) : (
entry.done ? <Check className="w-3 h-3 text-green-500 opacity-40" /> : (entry as any).isCorrected ? <span className="w-4 h-4 md:w-5 md:h-5 rounded border-2 border-muted-foreground/20 bg-muted/30" /> : null
)}
</div>
<div className="px-1 py-1.5 flex items-center justify-center print:hidden">
{canEdit && !(entry as any).isCorrected && !(entry as any).correctionOfId && (
<button
onClick={() => { setCorrectionEntryId(entry.id); setCorrectionText(entry.what) }}
className="text-muted-foreground hover:text-amber-600 p-0.5"
title="Korrektur erstellen"
>
<Pencil className="w-3 h-3" />
</button>
)}
</div>
</div>
))}
</div>
)}
{/* Correction inline form */}
{correctionEntryId && (
<div className="border-t-2 border-amber-400 p-2 bg-amber-50 dark:bg-amber-950/30 print:hidden">
<p className="text-xs font-semibold text-amber-700 dark:text-amber-400 mb-1.5">Korrektur erstellen:</p>
<div className="flex gap-1.5">
<Input
placeholder="Korrekturtext..."
value={correctionText}
onChange={(e) => setCorrectionText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && correctEntry()}
className="flex-1 h-7 text-xs border-amber-300"
autoFocus
/>
<Button size="sm" onClick={correctEntry} disabled={!correctionText.trim()} className="h-7 px-2 bg-amber-600 hover:bg-amber-700">
<Check className="w-3.5 h-3.5" />
</Button>
<Button size="sm" variant="outline" onClick={() => { setCorrectionEntryId(null); setCorrectionText('') }} className="h-7 px-2">
</Button>
</div>
</div>
)}
{/* New entry form */}
{canEdit && (
<div className="border-t border-border p-2 bg-white dark:bg-card print:hidden sticky bottom-0 shadow-[0_-2px_4px_rgba(0,0,0,0.05)]">
<div className="flex gap-1.5">
<div className="flex items-center gap-1 text-[11px] text-muted-foreground font-mono w-[55px] md:w-[70px] shrink-0">
<Clock className="w-3 h-3" />
{new Date().toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}
</div>
<div className="flex-1 relative">
{/* iPhone-style suggestion bar */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div ref={suggestionsRef} className="absolute bottom-full left-0 right-0 mb-1 z-50">
<div className="flex gap-1 overflow-x-auto scrollbar-hide py-1 px-0.5">
{filteredSuggestions.map((s, i) => {
const lower = newWhat.toLowerCase()
const idx = s.toLowerCase().indexOf(lower)
return (
<button
key={i}
type="button"
className={`shrink-0 px-2.5 py-1 text-xs rounded-full border transition-all whitespace-nowrap ${
i === selectedSuggestionIdx
? 'bg-red-600 text-white border-red-600'
: 'bg-white dark:bg-card border-border text-foreground hover:bg-red-50 hover:border-red-200 shadow-sm'
}`}
onMouseDown={(e) => {
e.preventDefault()
setNewWhat(s)
setShowSuggestions(false)
setSelectedSuggestionIdx(-1)
}}
>
{idx >= 0 ? (
<>{s.slice(0, idx)}<strong className="font-bold">{s.slice(idx, idx + lower.length)}</strong>{s.slice(idx + lower.length)}</>
) : s}
</button>
)
})}
{/* Correction inline form */}
{correctionEntryId && (
<div className="border-t-2 border-amber-400 p-2 bg-amber-50 dark:bg-amber-950/30 print:hidden">
<p className="text-xs font-semibold text-amber-700 dark:text-amber-400 mb-1.5">Korrektur erstellen:</p>
<div className="flex gap-1.5">
<Input
placeholder="Korrekturtext..."
value={correctionText}
onChange={(e) => setCorrectionText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && correctEntry()}
className="flex-1 h-7 text-xs border-amber-300"
autoFocus
/>
<Button size="sm" onClick={correctEntry} disabled={!correctionText.trim()} className="h-7 px-2 bg-amber-600 hover:bg-amber-700">
<Check className="w-3.5 h-3.5" />
</Button>
<Button size="sm" variant="outline" onClick={() => { setCorrectionEntryId(null); setCorrectionText('') }} className="h-7 px-2">
</Button>
</div>
</div>
)}
<Input
placeholder="Was..."
value={newWhat}
onChange={(e) => {
setNewWhat(e.target.value)
filterSuggestions(e.target.value)
}}
onKeyDown={(e) => {
if (showSuggestions && filteredSuggestions.length > 0) {
if (e.key === 'ArrowRight' || e.key === 'Tab') {
e.preventDefault()
setSelectedSuggestionIdx(prev => (prev + 1) % filteredSuggestions.length)
return
}
if (e.key === 'ArrowLeft') {
e.preventDefault()
setSelectedSuggestionIdx(prev => prev <= 0 ? filteredSuggestions.length - 1 : prev - 1)
return
}
if (e.key === 'Enter' && selectedSuggestionIdx >= 0) {
e.preventDefault()
setNewWhat(filteredSuggestions[selectedSuggestionIdx])
setShowSuggestions(false)
setSelectedSuggestionIdx(-1)
return
}
}
if (e.key === 'Enter') { setShowSuggestions(false); addEntry() }
if (e.key === 'Escape') { setShowSuggestions(false); setSelectedSuggestionIdx(-1) }
}}
onFocus={() => filterSuggestions(newWhat)}
onBlur={() => setTimeout(() => { setShowSuggestions(false); setSelectedSuggestionIdx(-1) }, 150)}
className="h-7 text-xs w-full"
/>
</div>
<Input
placeholder="Wer"
value={newWho}
onChange={(e) => setNewWho(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addEntry()}
className="w-16 md:w-20 h-7 text-xs"
/>
<Button size="sm" onClick={addEntry} disabled={!newWhat.trim()} className="h-7 px-2">
<Plus className="w-3.5 h-3.5" />
</Button>
</div>
</div>
)}
</div>
{/* Right column: SOMA + Pendenzen */}
<div className="w-full lg:w-72 xl:w-80 print:w-[280px] border-t lg:border-t-0 lg:border-l-2 lg:border-l-red-200 dark:lg:border-l-border border-border bg-white dark:bg-card shrink-0 shadow-sm">
{/* SOMA Checklist */}
<div className="border-b border-border">
<div className="px-2 py-1.5 bg-stone-100 dark:bg-muted/50 border-b-2 border-red-400 dark:border-red-800">
<h3 className="font-semibold text-xs md:text-sm print:text-[10px] flex items-center gap-1 text-red-800 dark:text-red-400">
<AlertTriangle className="w-3.5 h-3.5" />
SOMA
<span className="text-[10px] text-muted-foreground font-normal">
({checkItems.filter(c => c.confirmed).length}/{checkItems.length})
</span>
</h3>
</div>
{/* SOMA table with column headers */}
<div className="grid grid-cols-[1fr_28px_28px] print:grid-cols-[1fr_24px_24px] text-[10px] font-semibold text-muted-foreground border-b border-border/50">
<div className="px-2 py-1"></div>
<div className="px-0.5 py-1 text-center text-red-600">JA</div>
<div className="px-0.5 py-1 text-center text-red-500">Ok</div>
</div>
<div className="divide-y divide-border/50">
{checkItems.map((item, idx) => (
<div key={item.id} className={`grid grid-cols-[1fr_28px_28px] print:grid-cols-[1fr_24px_24px] items-center text-xs md:text-sm print:text-[9px] group ${idx % 2 === 0 ? '' : 'bg-stone-50 dark:bg-muted/20'}`}>
<div className="px-2 py-1.5 flex items-center gap-1 min-w-0">
<span className="truncate">{item.label}</span>
{item.confirmedAt && item.confirmed && (
<span className="text-[9px] text-red-500 shrink-0">{formatTime(item.confirmedAt)}</span>
)}
{canEdit && (
<button
onClick={() => deleteCheckItem(item.id)}
className="ml-auto opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive shrink-0 print:hidden"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
<div className="flex items-center justify-center py-1.5">
<button
onClick={() => canEdit && toggleCheck(item, 'confirmed')}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
item.confirmed
? 'bg-blue-500 border-blue-500 text-white'
: 'border-muted-foreground/30 hover:border-blue-400'
}`}
title={item.confirmedAt ? `JA: ${formatDateTime(item.confirmedAt)}` : 'JA'}
disabled={!canEdit}
>
{item.confirmed && <Check className="w-3 h-3" />}
</button>
</div>
<div className="flex items-center justify-center py-1.5">
<button
onClick={() => canEdit && toggleCheck(item, 'ok')}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
item.ok
? 'bg-green-500 border-green-500 text-white'
: 'border-muted-foreground/30 hover:border-green-400'
}`}
title={item.okAt ? `Ok: ${formatDateTime(item.okAt)}` : 'Ok'}
disabled={!canEdit}
>
{item.ok && <Check className="w-3 h-3" />}
</button>
</div>
</div>
))}
</div>
{canEdit && (
<div className="flex gap-1 px-2 py-1.5 border-t border-border/50 print:hidden">
<Input
placeholder="Neuer Punkt..."
value={newCheckLabel}
onChange={(e) => setNewCheckLabel(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addCheckItem()}
className="h-6 text-[11px]"
/>
<Button size="sm" variant="ghost" onClick={addCheckItem} disabled={!newCheckLabel.trim()} className="h-6 px-1.5">
<Plus className="w-3 h-3" />
</Button>
</div>
)}
</div>
{/* Pendenzen */}
<div>
<div className="px-2 py-1.5 bg-stone-100 dark:bg-muted/50 border-b-2 border-red-400 dark:border-red-800">
<h3 className="font-semibold text-xs md:text-sm print:text-[10px] flex items-center gap-1 text-red-800 dark:text-red-400">
<CheckSquare className="w-3.5 h-3.5" />
Pendenzen
<span className="text-[10px] text-muted-foreground font-normal">
({pendenzen.filter(p => p.done).length}/{pendenzen.length})
</span>
</h3>
</div>
{pendenzen.length === 0 ? (
<div className="text-center text-muted-foreground py-4 text-xs">
Keine Pendenzen
</div>
) : (
<div className="divide-y divide-border/50">
{pendenzen.map((p) => (
<div key={p.id} className={`flex items-start gap-1.5 px-2 py-1.5 text-xs print:text-[9px] group ${p.done ? 'bg-green-50 dark:bg-green-950/20 print:bg-green-50' : ''}`}>
<button
onClick={() => canEdit && togglePendenzDone(p)}
className={`w-4 h-4 mt-0.5 rounded border-2 flex items-center justify-center shrink-0 transition-colors ${
p.done
? 'bg-green-500 border-green-500 text-white'
: 'border-muted-foreground/30 hover:border-primary'
}`}
disabled={!canEdit}
>
{p.done && <Check className="w-2.5 h-2.5" />}
</button>
<div className="flex-1 min-w-0">
<p className={`${p.done ? 'text-muted-foreground' : ''}`}>
{p.what}
{p.done && p.doneAt && (
<span className="ml-1 text-green-600 text-[10px] print:text-[8px] font-medium no-underline">
(erledigt um {formatTime(p.doneAt)})
</span>
)}
</p>
<div className="flex gap-2 text-[10px] text-muted-foreground">
{p.who && <span>Wer: {p.who}</span>}
{p.whenHow && <span>Wann: {p.whenHow}</span>}
{/* New entry form */}
{canEdit && (
<div className="border-t border-border p-2 bg-white dark:bg-card print:hidden sticky bottom-0 shadow-[0_-2px_4px_rgba(0,0,0,0.05)]">
<div className="flex gap-1.5">
<div className="flex items-center gap-1 text-[11px] text-muted-foreground font-mono w-[55px] md:w-[70px] shrink-0">
<Clock className="w-3 h-3" />
{new Date().toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}
</div>
<div className="flex-1 relative">
{/* iPhone-style suggestion bar */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div ref={suggestionsRef} className="absolute bottom-full left-0 right-0 mb-1 z-50">
<div className="flex gap-1 overflow-x-auto scrollbar-hide py-1 px-0.5">
{filteredSuggestions.map((s, i) => {
const lower = newWhat.toLowerCase()
const idx = s.toLowerCase().indexOf(lower)
return (
<button
key={i}
type="button"
className={`shrink-0 px-2.5 py-1 text-xs rounded-full border transition-all whitespace-nowrap ${
i === selectedSuggestionIdx
? 'bg-red-600 text-white border-red-600'
: 'bg-white dark:bg-card border-border text-foreground hover:bg-red-50 hover:border-red-200 shadow-sm'
}`}
onMouseDown={(e) => {
e.preventDefault()
setNewWhat(s)
setShowSuggestions(false)
setSelectedSuggestionIdx(-1)
}}
>
{idx >= 0 ? (
<>{s.slice(0, idx)}<strong className="font-bold">{s.slice(idx, idx + lower.length)}</strong>{s.slice(idx + lower.length)}</>
) : s}
</button>
)
})}
</div>
</div>
)}
<Input
placeholder="Was..."
value={newWhat}
onChange={(e) => {
setNewWhat(e.target.value)
filterSuggestions(e.target.value)
}}
onKeyDown={(e) => {
if (showSuggestions && filteredSuggestions.length > 0) {
if (e.key === 'ArrowRight' || e.key === 'Tab') {
e.preventDefault()
setSelectedSuggestionIdx(prev => (prev + 1) % filteredSuggestions.length)
return
}
if (e.key === 'ArrowLeft') {
e.preventDefault()
setSelectedSuggestionIdx(prev => prev <= 0 ? filteredSuggestions.length - 1 : prev - 1)
return
}
if (e.key === 'Enter' && selectedSuggestionIdx >= 0) {
e.preventDefault()
setNewWhat(filteredSuggestions[selectedSuggestionIdx])
setShowSuggestions(false)
setSelectedSuggestionIdx(-1)
return
}
}
if (e.key === 'Enter') { setShowSuggestions(false); addEntry() }
if (e.key === 'Escape') { setShowSuggestions(false); setSelectedSuggestionIdx(-1) }
}}
onFocus={() => filterSuggestions(newWhat)}
onBlur={() => setTimeout(() => { setShowSuggestions(false); setSelectedSuggestionIdx(-1) }, 150)}
className="h-7 text-xs w-full"
/>
</div>
<Input
placeholder="Wer"
value={newWho}
onChange={(e) => setNewWho(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addEntry()}
className="w-16 md:w-20 h-7 text-xs"
/>
<Button size="sm" onClick={addEntry} disabled={!newWhat.trim()} className="h-7 px-2">
<Plus className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{canEdit && (
<button
onClick={() => deletePendenz(p.id)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive shrink-0 mt-0.5 print:hidden"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
)}
{canEdit && (
<div className="border-t border-border p-1.5 print:hidden">
<div className="flex gap-1">
<Input
placeholder="Was..."
value={newPendWhat}
onChange={(e) => setNewPendWhat(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addPendenz()}
className="flex-1 h-6 text-[11px]"
/>
<Input
placeholder="Wer"
value={newPendWho}
onChange={(e) => setNewPendWho(e.target.value)}
className="w-14 h-6 text-[11px]"
/>
<Input
placeholder="Wann"
value={newPendWhen}
onChange={(e) => setNewPendWhen(e.target.value)}
className="w-14 h-6 text-[11px]"
/>
<Button size="sm" variant="ghost" onClick={addPendenz} disabled={!newPendWhat.trim()} className="h-6 px-1.5">
<Plus className="w-3 h-3" />
</Button>
)}
</div>
)
}
// Weitere main-Module (z.B. Lagemeldungen): breite Tabellen-Karte
return (
<div key={mod.id} className="border-t-4 border-stone-200 dark:border-border bg-white dark:bg-card">
<ModuleCard icon={mod.icon} name={mod.name}>
<TableModule projectId={projectId} module={mod} canEdit={canEdit} notifyChanged={notifyJournalChanged} />
</ModuleCard>
</div>
)}
</div>
)
})}
</div>
{/* Seitenspalte: SOMA, Pendenzen + eigene Module */}
{sideModules.length > 0 && (
<div className="w-full lg:w-72 xl:w-80 print:w-[280px] border-t lg:border-t-0 lg:border-l-2 lg:border-l-red-200 dark:lg:border-l-border border-border bg-white dark:bg-card shrink-0 shadow-sm">
{sideModules.map(mod => {
if (mod.type === 'soma') {
return (
<ModuleCard key={mod.id} icon={mod.icon} name={mod.name} count={`${checkItems.filter(c => c.confirmed).length}/${checkItems.length}`}>
<SomaModule
items={checkItems}
canEdit={canEdit}
onToggle={toggleCheck}
onAdd={addCheckItem}
onDelete={deleteCheckItem}
/>
</ModuleCard>
)
}
if (mod.type === 'pendenzen') {
return (
<ModuleCard key={mod.id} icon={mod.icon} name={mod.name} count={`${pendenzen.filter(p => p.done).length}/${pendenzen.length}`}>
<PendenzenModule
items={pendenzen}
canEdit={canEdit}
onAdd={addPendenz}
onToggle={togglePendenzDone}
onDelete={deletePendenz}
/>
</ModuleCard>
)
}
return (
<ModuleCard key={mod.id} icon={mod.icon} name={mod.name}>
<TableModule projectId={projectId} module={mod} canEdit={canEdit} notifyChanged={notifyJournalChanged} />
</ModuleCard>
)
})}
</div>
)}
</div>
</div>
{/* Modul-Verwaltung */}
<ModuleManagerDialog
open={showModuleManager}
onOpenChange={setShowModuleManager}
modules={modules}
onSaved={setModules}
/>
{/* Rapport Dialog */}
{showRapportDialog && projectId && (
<RapportDialog

View File

@@ -0,0 +1,261 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog'
import {
ArrowUp, ArrowDown, Plus, Trash2, Loader2, Blocks, X,
} from 'lucide-react'
import { MODULE_PRESETS, type CockpitModule, type ModuleColumn, type ModuleColumnType } from '@/lib/modules'
interface ModuleManagerDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
modules: CockpitModule[]
onSaved: (modules: CockpitModule[]) => void
}
/**
* "Module verwalten" — der Baukasten:
* Module ein-/ausschalten, Reihenfolge ändern, eigene Tabellen-Module
* aus Vorlagen erstellen (Checkliste, Atemschutz, Kräfte, Lagemeldungen, leer).
* Gespeichert wird pro Mandant (gilt für alle Einsätze).
*/
export function ModuleManagerDialog({ open, onOpenChange, modules, onSaved }: ModuleManagerDialogProps) {
const [working, setWorking] = useState<CockpitModule[]>(modules)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
// Neues Modul aus Vorlage
const [showAdd, setShowAdd] = useState(false)
const [presetKey, setPresetKey] = useState(MODULE_PRESETS[0].key)
const [newName, setNewName] = useState('')
const [newColumns, setNewColumns] = useState<ModuleColumn[]>(MODULE_PRESETS[0].columns)
useEffect(() => {
if (open) {
setWorking(modules)
setError('')
setShowAdd(false)
}
}, [open, modules])
const move = (idx: number, dir: -1 | 1) => {
const next = [...working]
const target = idx + dir
if (target < 0 || target >= next.length) return
;[next[idx], next[target]] = [next[target], next[idx]]
setWorking(next.map((m, i) => ({ ...m, sortOrder: i })))
}
const toggle = (idx: number) => {
setWorking(prev => prev.map((m, i) => i === idx ? { ...m, enabled: !m.enabled } : m))
}
const removeCustom = (idx: number) => {
setWorking(prev => prev.filter((_, i) => i !== idx).map((m, i) => ({ ...m, sortOrder: i })))
}
const selectPreset = (key: string) => {
const preset = MODULE_PRESETS.find(p => p.key === key)!
setPresetKey(key)
setNewName(preset.key === 'leer' ? '' : preset.name)
setNewColumns(preset.columns)
}
const addModule = () => {
const preset = MODULE_PRESETS.find(p => p.key === presetKey)!
const name = newName.trim() || preset.name
const mod: CockpitModule = {
id: `custom-${Date.now().toString(36)}`,
type: 'table',
name,
icon: preset.icon,
slot: preset.slot,
enabled: true,
sortOrder: working.length,
columns: newColumns.filter(c => c.label.trim()),
}
if (!mod.columns || mod.columns.length === 0) return
setWorking(prev => [...prev, mod])
setShowAdd(false)
setNewName('')
}
const updateColumn = (idx: number, patch: Partial<ModuleColumn>) => {
setNewColumns(prev => prev.map((c, i) => i === idx ? { ...c, ...patch } : c))
}
const addColumn = () => {
if (newColumns.length >= 8) return
setNewColumns(prev => [...prev, { key: `col${prev.length}-${Date.now().toString(36).slice(-4)}`, label: '', type: 'text' }])
}
const removeColumn = (idx: number) => {
setNewColumns(prev => prev.filter((_, i) => i !== idx))
}
const save = async () => {
setIsSaving(true)
setError('')
try {
const res = await fetch('/api/tenant/modules', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ modules: working }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Speichern fehlgeschlagen')
onSaved(data.modules)
onOpenChange(false)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
} finally {
setIsSaving(false)
}
}
const COLUMN_TYPES: { value: ModuleColumnType; label: string }[] = [
{ value: 'text', label: 'Text' },
{ value: 'check', label: 'Haken' },
{ value: 'time', label: 'Zeit (auto)' },
]
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Blocks className="w-5 h-5" />
Cockpit-Module verwalten
</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground -mt-2">
Stelle das Einsatz-Cockpit für deine Feuerwehr zusammen. Gilt für alle Einsätze deiner Organisation.
</p>
{/* Modul-Liste */}
<div className="space-y-1.5">
{working.map((m, idx) => (
<div key={m.id} className={`flex items-center gap-2 rounded-lg border px-2.5 py-2 ${m.enabled ? 'bg-card' : 'bg-muted/40 opacity-60'}`}>
{/* An/aus */}
<button
onClick={() => toggle(idx)}
className={`relative w-9 h-5 rounded-full transition-colors shrink-0 ${m.enabled ? 'bg-green-500' : 'bg-muted-foreground/30'}`}
title={m.enabled ? 'Deaktivieren' : 'Aktivieren'}
>
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${m.enabled ? 'left-[18px]' : 'left-0.5'}`} />
</button>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{m.name}</p>
<p className="text-[10px] text-muted-foreground">
{m.type === 'table' ? `Eigenes Modul · ${m.columns?.length || 0} Spalten` : 'Eingebaut'}
{' · '}{m.slot === 'main' ? 'Hauptbereich' : 'Seitenspalte'}
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button onClick={() => move(idx, -1)} disabled={idx === 0} className="p-1 text-muted-foreground hover:text-foreground disabled:opacity-30" title="Nach oben">
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button onClick={() => move(idx, 1)} disabled={idx === working.length - 1} className="p-1 text-muted-foreground hover:text-foreground disabled:opacity-30" title="Nach unten">
<ArrowDown className="w-3.5 h-3.5" />
</button>
{m.type === 'table' && (
<button onClick={() => removeCustom(idx)} className="p-1 text-muted-foreground hover:text-destructive" title="Modul löschen (Daten bleiben in der DB)">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
))}
</div>
{/* Neues Modul */}
{!showAdd ? (
<Button variant="outline" size="sm" onClick={() => { selectPreset(MODULE_PRESETS[0].key); setShowAdd(true) }} className="w-full">
<Plus className="w-4 h-4 mr-1.5" /> Modul hinzufügen
</Button>
) : (
<div className="rounded-lg border p-3 space-y-3 bg-muted/20">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold">Neues Modul</p>
<button onClick={() => setShowAdd(false)} className="text-muted-foreground hover:text-foreground"><X className="w-4 h-4" /></button>
</div>
{/* Vorlagen */}
<div className="grid grid-cols-2 gap-1.5">
{MODULE_PRESETS.map(p => (
<button
key={p.key}
onClick={() => selectPreset(p.key)}
className={`text-left rounded-md border-2 px-2 py-1.5 transition-colors ${
presetKey === p.key ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground'
}`}
title={p.description}
>
<p className="text-xs font-medium">{p.name}</p>
<p className="text-[10px] text-muted-foreground line-clamp-1">{p.description}</p>
</button>
))}
</div>
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Input value={newName} onChange={e => setNewName(e.target.value)} placeholder="z.B. Atemschutz Zug 1" className="h-8 text-sm" />
</div>
{/* Spalten-Editor */}
<div className="space-y-1.5">
<Label className="text-xs">Spalten</Label>
{newColumns.map((c, i) => (
<div key={c.key} className="flex items-center gap-1.5">
<Input
value={c.label}
onChange={e => updateColumn(i, { label: e.target.value })}
placeholder={`Spalte ${i + 1}`}
className="h-7 text-xs flex-1"
/>
<select
value={c.type}
onChange={e => updateColumn(i, { type: e.target.value as ModuleColumnType })}
className="h-7 text-xs rounded-md border border-input bg-background px-1.5"
>
{COLUMN_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<button onClick={() => removeColumn(i)} disabled={newColumns.length <= 1} className="p-1 text-muted-foreground hover:text-destructive disabled:opacity-30">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
{newColumns.length < 8 && (
<button onClick={addColumn} className="text-xs text-primary hover:underline flex items-center gap-1">
<Plus className="w-3 h-3" /> Spalte hinzufügen
</button>
)}
</div>
<Button size="sm" onClick={addModule} disabled={newColumns.filter(c => c.label.trim()).length === 0} className="w-full">
<Plus className="w-4 h-4 mr-1" /> Modul erstellen
</Button>
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>Abbrechen</Button>
<Button onClick={save} disabled={isSaving}>
{isSaving ? <><Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> Speichern</> : 'Speichern'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,108 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, Trash2, Check } from 'lucide-react'
import { type JournalPendenz, formatTime } from '@/components/journal/types'
interface PendenzenModuleProps {
items: JournalPendenz[]
canEdit: boolean
onAdd: (what: string, who: string, whenHow: string) => void
onToggle: (p: JournalPendenz) => void
onDelete: (id: string) => void
}
/** Pendenzen — eingebautes Cockpit-Modul (Was/Wer/Wann mit Erledigt-Haken) */
export function PendenzenModule({ items, canEdit, onAdd, onToggle, onDelete }: PendenzenModuleProps) {
const [what, setWhat] = useState('')
const [who, setWho] = useState('')
const [whenHow, setWhenHow] = useState('')
const submit = () => {
if (!what.trim()) return
onAdd(what.trim(), who.trim(), whenHow.trim())
setWhat('')
setWho('')
setWhenHow('')
}
return (
<>
{items.length === 0 ? (
<div className="text-center text-muted-foreground py-4 text-xs">
Keine Pendenzen
</div>
) : (
<div className="divide-y divide-border/50">
{items.map((p) => (
<div key={p.id} className={`flex items-start gap-1.5 px-2 py-1.5 text-xs print:text-[9px] group ${p.done ? 'bg-green-50 dark:bg-green-950/20 print:bg-green-50' : ''}`}>
<button
onClick={() => canEdit && onToggle(p)}
className={`w-4 h-4 mt-0.5 rounded border-2 flex items-center justify-center shrink-0 transition-colors ${
p.done
? 'bg-green-500 border-green-500 text-white'
: 'border-muted-foreground/30 hover:border-primary'
}`}
disabled={!canEdit}
>
{p.done && <Check className="w-2.5 h-2.5" />}
</button>
<div className="flex-1 min-w-0">
<p className={`${p.done ? 'text-muted-foreground' : ''}`}>
{p.what}
{p.done && p.doneAt && (
<span className="ml-1 text-green-600 text-[10px] print:text-[8px] font-medium no-underline">
(erledigt um {formatTime(p.doneAt)})
</span>
)}
</p>
<div className="flex gap-2 text-[10px] text-muted-foreground">
{p.who && <span>Wer: {p.who}</span>}
{p.whenHow && <span>Wann: {p.whenHow}</span>}
</div>
</div>
{canEdit && (
<button
onClick={() => onDelete(p.id)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive shrink-0 mt-0.5 print:hidden"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
)}
{canEdit && (
<div className="border-t border-border p-1.5 print:hidden">
<div className="flex gap-1">
<Input
placeholder="Was..."
value={what}
onChange={(e) => setWhat(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
className="flex-1 h-6 text-[11px]"
/>
<Input
placeholder="Wer"
value={who}
onChange={(e) => setWho(e.target.value)}
className="w-14 h-6 text-[11px]"
/>
<Input
placeholder="Wann"
value={whenHow}
onChange={(e) => setWhenHow(e.target.value)}
className="w-14 h-6 text-[11px]"
/>
<Button size="sm" variant="ghost" onClick={submit} disabled={!what.trim()} className="h-6 px-1.5">
<Plus className="w-3 h-3" />
</Button>
</div>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,99 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, Trash2, Check } from 'lucide-react'
import { type JournalCheckItem, formatTime, formatDateTime } from '@/components/journal/types'
interface SomaModuleProps {
items: JournalCheckItem[]
canEdit: boolean
onToggle: (item: JournalCheckItem, field: 'confirmed' | 'ok') => void
onAdd: (label: string) => void
onDelete: (itemId: string) => void
}
/** SOMA-Checkliste — eingebautes Cockpit-Modul (JA/Ok mit Zeitstempel) */
export function SomaModule({ items, canEdit, onToggle, onAdd, onDelete }: SomaModuleProps) {
const [newLabel, setNewLabel] = useState('')
const submit = () => {
if (!newLabel.trim()) return
onAdd(newLabel.trim())
setNewLabel('')
}
return (
<>
{/* Spalten-Kopf */}
<div className="grid grid-cols-[1fr_28px_28px] print:grid-cols-[1fr_24px_24px] text-[10px] font-semibold text-muted-foreground border-b border-border/50">
<div className="px-2 py-1"></div>
<div className="px-0.5 py-1 text-center text-red-600">JA</div>
<div className="px-0.5 py-1 text-center text-red-500">Ok</div>
</div>
<div className="divide-y divide-border/50">
{items.map((item, idx) => (
<div key={item.id} className={`grid grid-cols-[1fr_28px_28px] print:grid-cols-[1fr_24px_24px] items-center text-xs md:text-sm print:text-[9px] group ${idx % 2 === 0 ? '' : 'bg-stone-50 dark:bg-muted/20'}`}>
<div className="px-2 py-1.5 flex items-center gap-1 min-w-0">
<span className="truncate">{item.label}</span>
{item.confirmedAt && item.confirmed && (
<span className="text-[9px] text-red-500 shrink-0">{formatTime(item.confirmedAt)}</span>
)}
{canEdit && (
<button
onClick={() => onDelete(item.id)}
className="ml-auto opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive shrink-0 print:hidden"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
<div className="flex items-center justify-center py-1.5">
<button
onClick={() => canEdit && onToggle(item, 'confirmed')}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
item.confirmed
? 'bg-blue-500 border-blue-500 text-white'
: 'border-muted-foreground/30 hover:border-blue-400'
}`}
title={item.confirmedAt ? `JA: ${formatDateTime(item.confirmedAt)}` : 'JA'}
disabled={!canEdit}
>
{item.confirmed && <Check className="w-3 h-3" />}
</button>
</div>
<div className="flex items-center justify-center py-1.5">
<button
onClick={() => canEdit && onToggle(item, 'ok')}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
item.ok
? 'bg-green-500 border-green-500 text-white'
: 'border-muted-foreground/30 hover:border-green-400'
}`}
title={item.okAt ? `Ok: ${formatDateTime(item.okAt)}` : 'Ok'}
disabled={!canEdit}
>
{item.ok && <Check className="w-3 h-3" />}
</button>
</div>
</div>
))}
</div>
{canEdit && (
<div className="flex gap-1 px-2 py-1.5 border-t border-border/50 print:hidden">
<Input
placeholder="Neuer Punkt..."
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
className="h-6 text-[11px]"
/>
<Button size="sm" variant="ghost" onClick={submit} disabled={!newLabel.trim()} className="h-6 px-1.5">
<Plus className="w-3 h-3" />
</Button>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,209 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, Trash2, Check, Loader2 } from 'lucide-react'
import type { CockpitModule, ModuleColumn } from '@/lib/modules'
interface ModuleItemRow {
id: string
data: Record<string, any>
createdAt: string
}
interface TableModuleProps {
projectId: string
module: CockpitModule
canEdit: boolean
/** Live-Sync: andere Clients über Änderung informieren */
notifyChanged: () => void
}
function formatTime(dateStr: string) {
return new Date(dateStr).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
}
/**
* Generisches Tabellen-Modul des Modul-Baukastens.
* Rendert beliebige Spalten (text / check / time) gemäss Modul-Konfiguration.
* Daten liegen als JSON-Zeilen in module_items (generische API).
*/
export function TableModule({ projectId, module, canEdit, notifyChanged }: TableModuleProps) {
const [items, setItems] = useState<ModuleItemRow[]>([])
const [isLoading, setIsLoading] = useState(true)
const [draft, setDraft] = useState<Record<string, string>>({})
const columns: ModuleColumn[] = module.columns || []
const textColumns = columns.filter(c => c.type === 'text')
const load = useCallback(async () => {
try {
const res = await fetch(`/api/projects/${projectId}/modules/${module.id}/items`)
if (res.ok) {
const data = await res.json()
setItems(data.items || [])
}
} catch (err) {
console.error(`Failed to load module ${module.id}:`, err)
} finally {
setIsLoading(false)
}
}, [projectId, module.id])
useEffect(() => { load() }, [load])
// Live-Refresh, wenn andere Clients Journal-Änderungen melden
useEffect(() => {
const onRefresh = () => load()
window.addEventListener('journal-refresh', onRefresh)
return () => window.removeEventListener('journal-refresh', onRefresh)
}, [load])
const addRow = useCallback(async () => {
const hasContent = textColumns.some(c => (draft[c.key] || '').trim())
if (!hasContent) return
const data: Record<string, any> = {}
for (const c of textColumns) {
const v = (draft[c.key] || '').trim()
if (v) data[c.key] = v
}
try {
const res = await fetch(`/api/projects/${projectId}/modules/${module.id}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
})
if (res.ok) {
const item = await res.json()
setItems(prev => [...prev, item])
setDraft({})
notifyChanged()
}
} catch (err) {
console.error('Failed to add module item:', err)
}
}, [projectId, module.id, draft, textColumns, notifyChanged])
const toggleCheck = useCallback(async (item: ModuleItemRow, key: string) => {
if (!canEdit) return
const next = !item.data[key]
try {
const res = await fetch(`/api/projects/${projectId}/modules/${module.id}/items/${item.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: { [key]: next, [`${key}At`]: next ? new Date().toISOString() : null } }),
})
if (res.ok) {
const updated = await res.json()
setItems(prev => prev.map(i => i.id === updated.id ? updated : i))
notifyChanged()
}
} catch (err) {
console.error('Failed to toggle module item:', err)
}
}, [projectId, module.id, canEdit, notifyChanged])
const deleteRow = useCallback(async (itemId: string) => {
try {
await fetch(`/api/projects/${projectId}/modules/${module.id}/items/${itemId}`, { method: 'DELETE' })
setItems(prev => prev.filter(i => i.id !== itemId))
notifyChanged()
} catch (err) {
console.error('Failed to delete module item:', err)
}
}, [projectId, module.id, notifyChanged])
// Grid-Template aus Spalten ableiten (+ Lösch-Spalte)
const gridCols = columns.map(c =>
c.type === 'check' ? '32px' : c.type === 'time' ? '52px' : 'minmax(0,1fr)'
).join(' ') + (canEdit ? ' 24px' : '')
return (
<div>
{/* Spalten-Kopf */}
<div className="grid text-[10px] font-semibold text-muted-foreground border-b border-border/50" style={{ gridTemplateColumns: gridCols }}>
{columns.map(c => (
<div key={c.key} className={`px-1.5 py-1 truncate ${c.type !== 'text' ? 'text-center' : ''}`}>{c.label}</div>
))}
{canEdit && <div />}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-4 text-muted-foreground text-xs">
<Loader2 className="w-3.5 h-3.5 animate-spin mr-1.5" /> Laden
</div>
) : items.length === 0 ? (
<div className="text-center text-muted-foreground py-3 text-xs">Keine Einträge</div>
) : (
<div className="divide-y divide-border/50">
{items.map((item, idx) => (
<div key={item.id} className={`grid items-center text-xs print:text-[9px] group ${idx % 2 === 0 ? '' : 'bg-stone-50 dark:bg-muted/20'}`} style={{ gridTemplateColumns: gridCols }}>
{columns.map(c => {
if (c.type === 'check') {
const checked = !!item.data[c.key]
const at = item.data[`${c.key}At`]
return (
<div key={c.key} className="flex items-center justify-center py-1.5">
<button
onClick={() => toggleCheck(item, c.key)}
disabled={!canEdit}
title={at ? `${c.label}: ${formatTime(at)}` : c.label}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
checked ? 'bg-green-500 border-green-500 text-white' : 'border-muted-foreground/30 hover:border-green-400'
}`}
>
{checked && <Check className="w-3 h-3" />}
</button>
</div>
)
}
if (c.type === 'time') {
// Zeit-Spalte: automatisch = Erstellzeit (oder explizit gesetzter Wert)
const t = item.data[c.key] || item.createdAt
return (
<div key={c.key} className="px-1.5 py-1.5 font-mono tabular-nums text-muted-foreground text-center text-[11px]">
{t ? formatTime(t) : ''}
</div>
)
}
return (
<div key={c.key} className="px-1.5 py-1.5 break-words min-w-0">
{item.data[c.key] || <span className="text-muted-foreground"></span>}
</div>
)
})}
{canEdit && (
<button
onClick={() => deleteRow(item.id)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive flex items-center justify-center print:hidden"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
)}
{/* Neue Zeile */}
{canEdit && (
<div className="flex gap-1 px-1.5 py-1.5 border-t border-border/50 print:hidden">
{textColumns.map((c, i) => (
<Input
key={c.key}
placeholder={c.label + '…'}
value={draft[c.key] || ''}
onChange={(e) => setDraft(prev => ({ ...prev, [c.key]: e.target.value }))}
onKeyDown={(e) => e.key === 'Enter' && addRow()}
className={`h-6 text-[11px] ${i === 0 ? 'flex-1 min-w-0' : 'w-20 shrink-0'}`}
/>
))}
<Button size="sm" variant="ghost" onClick={addRow} disabled={!textColumns.some(c => (draft[c.key] || '').trim())} className="h-6 px-1.5 shrink-0">
<Plus className="w-3 h-3" />
</Button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,40 @@
/** Gemeinsame Typen der Cockpit-Module */
export interface JournalEntry {
id: string
time: string
what: string
who: string | null
done: boolean
doneAt: string | null
isCorrected?: boolean
correctionOfId?: string | null
}
export interface JournalCheckItem {
id: string
label: string
confirmed: boolean
confirmedAt: string | null
ok: boolean
okAt: string | null
}
export interface JournalPendenz {
id: string
what: string
who: string | null
whenHow: string | null
done: boolean
doneAt: string | null
}
export function formatTime(dateStr: string) {
return new Date(dateStr).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
}
export function formatDateTime(dateStr: string) {
const d = new Date(dateStr)
return d.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) + ' ' +
d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
}

View File

@@ -6,9 +6,9 @@ import { getEmptyImage } from 'react-dnd-html5-backend'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Search, Flame, Droplets, AlertTriangle, Car, Users,
Truck, Building, Target, Upload, Loader2, X, LayoutGrid,
ChevronLeft, ChevronRight, Map, ClipboardList, PanelRightClose, PanelRightOpen,
Search, Flame, Droplets, AlertTriangle,
Building, Target, Upload, Loader2, X,
Map, ClipboardList, PanelRightClose, PanelRightOpen,
Shield, Wrench, Radio, MoreHorizontal, Heart, FolderOpen,
} from 'lucide-react'
@@ -107,13 +107,10 @@ function DraggableSymbol({ symbol, canEdit, onSelect }: {
export function RightSidebar({ onSymbolDrop, onSymbolSelect, 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[]>([])
const [tenantGroups, setTenantGroups] = useState<TenantSymbolGroup[]>([])
const [isLoading, setIsLoading] = useState(true)
const [showTenantSection, setShowTenantSection] = useState(true)
const [showLibrarySection, setShowLibrarySection] = useState(true)
const [expandedTenantCats, setExpandedTenantCats] = useState<Set<string>>(new Set())
const [activeGroupId, setActiveGroupId] = useState<string>('')
useEffect(() => {
async function fetchIcons() {
@@ -139,11 +136,7 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
// Separate tenant-specific legacy "Eigene" category from global library
const eigene = allCats.find(c => c.name === 'Eigene')
const globalCats = allCats.filter(c => c.name !== 'Eigene')
setCategories(globalCats)
if (globalCats.length > 0 && !activeCategory) {
setActiveCategory(globalCats[0].id)
}
// ─── New tenant symbol groups (Phase 1) ───
const groups: TenantSymbolGroup[] = (data.tenantSymbolGroups || []).map((g: any) => ({
@@ -157,20 +150,10 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
// Merge legacy "Eigene" into tenant groups if present
if (eigene && eigene.symbols.length > 0) {
const legacyGroup: TenantSymbolGroup = {
category: { id: '__legacy__', name: 'Eigene' },
symbols: eigene.symbols,
}
groups.unshift(legacyGroup)
groups.unshift({ category: { id: '__legacy__', name: 'Eigene' }, symbols: eigene.symbols })
}
setTenantGroups(groups)
// Auto-expand all tenant groups, auto-collapse library if tenant has symbols
if (groups.length > 0 && groups.some(g => g.symbols.length > 0)) {
setExpandedTenantCats(new Set(groups.map(g => g.category?.id || '__none__')))
setShowLibrarySection(false)
}
}
} catch (err) {
console.error('Failed to load icons:', err)
@@ -181,31 +164,35 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
fetchIcons()
}, [tenantId])
const toggleTenantCat = (catId: string | null) => {
const key = catId || '__none__'
setExpandedTenantCats(prev => {
const n = new Set(prev)
n.has(key) ? n.delete(key) : n.add(key)
return n
})
}
// ─── Flache Gruppen-Liste: Mandanten-Kategorien zuerst, dann Bibliothek — je ein Chip.
// Ersetzt die früheren verschachtelten Klapp-Sektionen (Meine Symbole / Bibliothek). ───
type SymbolGroup = { id: string; name: string; symbols: DisplaySymbol[]; isTenant: boolean }
const groups: SymbolGroup[] = [
...tenantGroups.map(g => ({
id: 'tenant:' + (g.category?.id || 'none'),
name: g.category?.name || 'Eigene',
symbols: g.symbols,
isTenant: true,
})),
...categories.map(c => ({ id: 'lib:' + c.id, name: c.name, symbols: c.symbols, isTenant: false })),
].filter(g => g.symbols.length > 0)
const filteredCategories = categories.map((cat) => ({
...cat,
symbols: cat.symbols.filter((s) =>
(s.name || '').toLowerCase().includes(searchQuery.toLowerCase())
),
}))
const filteredTenantGroups = tenantGroups.map(g => ({
...g,
symbols: g.symbols.filter(s =>
(s.name || '').toLowerCase().includes(searchQuery.toLowerCase())
),
})).filter(g => g.symbols.length > 0)
const totalSymbols = groups.reduce((sum, g) => sum + g.symbols.length, 0)
const currentCategory = filteredCategories.find((c) => c.id === activeCategory)
const totalSymbols = categories.reduce((sum, c) => sum + c.symbols.length, 0) +
tenantGroups.reduce((sum, g) => sum + g.symbols.length, 0)
const q = searchQuery.trim().toLowerCase()
const isSearching = q.length > 0
const searchResults = isSearching
? groups.flatMap(g => g.symbols).filter(s => (s.name || '').toLowerCase().includes(q))
: []
const activeGroup = groups.find(g => g.id === activeGroupId) || groups[0] || null
// Default-Gruppe wählen bzw. korrigieren, falls die aktuelle nicht mehr existiert
useEffect(() => {
if (groups.length && !groups.some(g => g.id === activeGroupId)) {
setActiveGroupId(groups[0].id)
}
}, [groups, activeGroupId])
return (
<>
@@ -325,121 +312,59 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
)}
<ScrollArea className="flex-1">
{/* ─── Section 1: Meine Symbole (Tenant Symbol Groups) ─── */}
{tenantId && (
<div className="border-b border-border">
<button
onClick={() => setShowTenantSection(!showTenantSection)}
className="w-full flex items-center justify-between px-2 py-1.5 text-xs font-semibold text-muted-foreground hover:bg-accent/50 transition-colors"
>
<span className="flex items-center gap-1.5">
<Upload className="w-3.5 h-3.5" />
Meine Symbole ({tenantGroups.reduce((s, g) => s + g.symbols.length, 0)})
</span>
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showTenantSection ? 'rotate-90' : ''}`} />
</button>
{showTenantSection && (
<div className="p-2 pt-0 space-y-1">
{filteredTenantGroups.length === 0 ? (
<div className="text-center text-muted-foreground py-4 text-xs">
Keine eigenen Symbole vorhanden.
<br />
<span className="text-[10px]">Symbole können unter Admin Symbole verwaltet werden.</span>
</div>
) : (
filteredTenantGroups.map(g => {
const key = g.category?.id || '__none__'
const expanded = expandedTenantCats.has(key)
return (
<div key={key} className="border rounded-md">
<button
onClick={() => toggleTenantCat(g.category?.id || null)}
className="w-full flex items-center justify-between px-2 py-1 text-[11px] font-medium hover:bg-muted/40 transition-colors"
>
<span className="flex items-center gap-1.5">
<FolderOpen className="w-3 h-3 text-muted-foreground" />
{g.category?.name || 'Ohne Kategorie'}
<span className="text-[10px] text-muted-foreground">({g.symbols.length})</span>
</span>
<ChevronRight className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
</button>
{expanded && (
<div className="px-1.5 pb-1.5">
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
{g.symbols.map(symbol => (
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} onSelect={onSymbolSelect} />
))}
</div>
</div>
)}
</div>
)
})
)}
</div>
)}
{!isLoading && groups.length === 0 && (
<div className="text-center text-muted-foreground py-8 px-3 text-xs">
Keine Symbole verfügbar.
<br />
<span className="text-[10px]">Eigene Symbole unter Admin Symbole verwalten.</span>
</div>
)}
{/* ─── Section 2: Bibliothek (Global categories) ─── */}
<div>
<button
onClick={() => setShowLibrarySection(!showLibrarySection)}
className="w-full flex items-center justify-between px-2 py-1.5 text-xs font-semibold text-muted-foreground hover:bg-accent/50 transition-colors"
>
<span className="flex items-center gap-1.5">
<LayoutGrid className="w-3.5 h-3.5" />
Bibliothek ({categories.reduce((s, c) => s + c.symbols.length, 0)})
</span>
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showLibrarySection ? 'rotate-90' : ''}`} />
</button>
{showLibrarySection && (
<>
{/* Category tabs */}
<div className="px-1.5 pb-1.5 md:px-2 md:pb-2">
<div className="flex flex-wrap gap-0.5 md:gap-1">
{categories.map((cat) => {
const IconComponent = categoryIcons[cat.name] || Target
return (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`
flex items-center gap-1 px-2 py-1.5 md:px-2.5 md:py-1.5 lg:px-3 lg:py-2 rounded-md md:rounded-lg text-xs md:text-sm font-medium transition-colors
${activeCategory === cat.id
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-accent text-muted-foreground'}
`}
title={`${cat.name} (${cat.symbols.length})`}
>
<IconComponent className="w-3.5 h-3.5 md:w-4 md:h-4" />
<span className="hidden lg:inline max-w-[70px] truncate">{cat.name}</span>
</button>
)
})}
</div>
</div>
{/* Kategorie-Chips (Mandant + Bibliothek in einer Reihe) — nur eine Ebene */}
{!isSearching && groups.length > 0 && (
<div className="sticky top-0 z-[1] bg-card/95 backdrop-blur-sm px-1.5 py-1.5 border-b border-border">
<div className="flex flex-wrap gap-1">
{groups.map(g => {
const IconComponent = g.isTenant ? FolderOpen : (categoryIcons[g.name] || Target)
const active = activeGroup?.id === g.id
return (
<button
key={g.id}
onClick={() => setActiveGroupId(g.id)}
className={`flex items-center gap-1 px-2 py-1.5 rounded-md text-xs font-medium transition-colors ${
active ? 'bg-primary text-primary-foreground' : 'bg-muted hover:bg-accent text-muted-foreground'
}`}
title={`${g.name} (${g.symbols.length})`}
>
<IconComponent className="w-3.5 h-3.5 shrink-0" />
<span className="max-w-[90px] truncate">{g.name}</span>
<span className="opacity-60">{g.symbols.length}</span>
</button>
)
})}
</div>
</div>
)}
{currentCategory && (
<div className="p-2 pt-0">
<h4 className="text-xs md:text-sm font-medium text-muted-foreground mb-1.5">
{currentCategory.name} ({currentCategory.symbols.length})
</h4>
{currentCategory.symbols.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">
Keine Symbole gefunden
</div>
) : (
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
{currentCategory.symbols.map((symbol) => (
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} onSelect={onSymbolSelect} />
))}
</div>
)}
</div>
)}
</>
)}
{/* Symbol-Raster: Suchergebnisse ODER aktive Kategorie */}
<div className="p-2">
{isSearching ? (
searchResults.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">Keine Symbole gefunden</div>
) : (
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
{searchResults.map(symbol => (
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} onSelect={onSymbolSelect} />
))}
</div>
)
) : activeGroup ? (
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
{activeGroup.symbols.map(symbol => (
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} onSelect={onSymbolSelect} />
))}
</div>
) : null}
</div>
</ScrollArea>
</>

View File

@@ -13,6 +13,8 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
@@ -184,7 +186,7 @@ export function Topbar({
<Button
variant={presentationLocked ? 'default' : 'outline'}
className={`h-9 md:h-10 px-2 md:px-3 text-sm ${presentationLocked ? 'bg-amber-600 hover:bg-amber-700 text-white border-amber-600' : ''}`}
className={`hidden md:flex h-9 md:h-10 px-2 md:px-3 text-sm ${presentationLocked ? 'bg-amber-600 hover:bg-amber-700 text-white border-amber-600' : ''}`}
onClick={onTogglePresentationLock}
title={presentationLocked ? 'Präsentationsmodus deaktivieren' : 'Präsentationsmodus aktivieren'}
>
@@ -205,7 +207,7 @@ export function Topbar({
<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"
className="hidden md:flex 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" />
@@ -214,7 +216,7 @@ export function Topbar({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="h-9 md:h-10 px-2 md:px-3 text-sm" title="Menü">
<Button variant="outline" className="hidden md:flex h-9 md:h-10 px-2 md:px-3 text-sm" title="Menü">
<MoreVertical className="w-5 h-5 md:mr-1" />
<span className="hidden lg:inline">Menü</span>
</Button>
@@ -347,36 +349,102 @@ export function Topbar({
</div>
)}
{/* Mobile: overflow menu with all secondary actions */}
{/* Mobile: EIN Menü mit allen Aktionen (ersetzt die früheren zwei getrennten Menüs) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="md:hidden h-9 px-2">
<Button variant="outline" className="md:hidden h-9 px-2" title="Menü">
<MoreVertical className="w-5 h-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuContent align="end" className="w-56 max-h-[80vh] overflow-y-auto">
{userName && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
{userName}{userRole ? ` · ${userRole}` : ''}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
{/* Einsatz / Übung */}
{project && onEndProject && (
<DropdownMenuItem onClick={onEndProject} className="text-red-600 focus:text-red-700">
<CheckCircle2 className="w-4 h-4 mr-2" />
{project.mode === 'UEBUNG' ? 'Übung beenden' : 'Einsatz beenden'}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onToggleTheme}>
{isDarkMode ? <Sun className="w-4 h-4 mr-2" /> : <Moon className="w-4 h-4 mr-2" />}
{isDarkMode ? 'Tagmodus' : 'Nachtmodus'}
<DropdownMenuItem onClick={() => onNewProject('UEBUNG')}>
<GraduationCap className="w-4 h-4 mr-2" />
Neue Übung
</DropdownMenuItem>
<DropdownMenuItem onClick={handleOpenLoadDialog}>
<List className="w-4 h-4 mr-2" />
Einsätze verwalten
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Karte / Export */}
<DropdownMenuItem onClick={() => handleExport('png')}>
<Download className="w-4 h-4 mr-2" />
Als PNG exportieren
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport('pdf')}>
<Download className="w-4 h-4 mr-2" />
Als PDF exportieren
</DropdownMenuItem>
{onClearAll && (
<DropdownMenuItem onClick={onClearAll} className="text-destructive focus:text-destructive">
<Eraser className="w-4 h-4 mr-2" />
Zeichnung leeren
</DropdownMenuItem>
)}
{onTogglePresentationLock && (
<DropdownMenuItem onClick={onTogglePresentationLock}>
{presentationLocked ? <Lock className="w-4 h-4 mr-2" /> : <Unlock className="w-4 h-4 mr-2" />}
{presentationLocked ? 'Präsentation: gesperrt' : 'Präsentationsmodus'}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{/* Ansicht */}
<DropdownMenuItem onClick={onToggleFullscreen}>
{isFullscreen ? <Minimize className="w-4 h-4 mr-2" /> : <Maximize className="w-4 h-4 mr-2" />}
{isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleTheme}>
{isDarkMode ? <Sun className="w-4 h-4 mr-2" /> : <Moon className="w-4 h-4 mr-2" />}
{isDarkMode ? 'Tagmodus' : 'Nachtmodus'}
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* System / Konto */}
{(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && (
<DropdownMenuItem onClick={() => window.location.href = '/admin'}>
<Shield className="w-4 h-4 mr-2" />
Administration
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => setIsHoseSettingsOpen(true)}>
<Settings className="w-4 h-4 mr-2" />
Einstellungen
Schlaucheinstellungen
</DropdownMenuItem>
{onStartTour && (
<DropdownMenuItem onClick={onStartTour}>
<HelpCircle className="w-4 h-4 mr-2" />
Tour starten
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => setShowPasswordDialog(true)}>
<Key className="w-4 h-4 mr-2" />
Kennwort ändern
</DropdownMenuItem>
{onLogout && (
<DropdownMenuItem onClick={onLogout} className="text-destructive">
<DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive">
<LogOut className="w-4 h-4 mr-2" />
Abmelden {userName && `(${userName})`}
Abmelden
</DropdownMenuItem>
)}
</DropdownMenuContent>

View File

@@ -88,6 +88,9 @@ interface MapViewProps {
/** Tap-to-place: wenn gesetzt, platziert der nächste Karten-Tipp dieses Symbol (Mobil-freundlich). */
pendingSymbol?: { iconId: string; imageUrl?: string } | null
onSymbolPlaced?: () => void
/** Vollbild-Umschaltung (mobil als schwebender Button über der Karte) */
isFullscreen?: boolean
onToggleFullscreen?: () => void
}
export function MapView({
@@ -105,6 +108,8 @@ export function MapView({
undoDrawPointRef,
pendingSymbol,
onSymbolPlaced,
isFullscreen,
onToggleFullscreen,
}: MapViewProps) {
const mapContainer = useRef<HTMLDivElement | null>(null)
const map = useRef<maplibregl.Map | null>(null)
@@ -2140,6 +2145,21 @@ export function MapView({
className="w-full h-full"
/>
{/* Vollbild-Umschalter — mobil als schwebender Button (versteckt die Browser-Leiste) */}
{onToggleFullscreen && (
<button
onClick={onToggleFullscreen}
className="md:hidden absolute top-3 left-3 z-10 w-11 h-11 flex items-center justify-center rounded-lg bg-white/92 text-gray-800 shadow-md border border-black/10 active:scale-95 transition-transform"
title={isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
>
{isFullscreen ? (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></svg>
) : (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
)}
</button>
)}
{/* Moveable controls for selected symbol */}
{isSymbolSelected && selectedSymbolRef.current && canEdit && (
<>

View File

@@ -34,11 +34,9 @@ const TOUR_STEPS: TourStep[] = [
position: 'bottom',
},
{
title: 'Bearbeitung starten',
title: 'Einfach loszeichnen',
icon: <Lock className="w-5 h-5 text-green-500" />,
description: 'Klicke auf «Bearbeitung starten» um die Karte zu bearbeiten. Nur ein Benutzer gleichzeitig kann bearbeiten — andere sehen deine Änderungen live.',
targetSelector: '[data-tour="edit-toggle"]',
position: 'bottom',
description: 'Wähle ein Werkzeug und zeichne los — die Bearbeitung startet automatisch. Zeichnet gerade jemand anderes, siehst du dessen Änderungen live und die Karte ist solange gesperrt.',
},
{
title: 'Zeichenwerkzeuge',
@@ -62,9 +60,9 @@ const TOUR_STEPS: TourStep[] = [
],
},
{
title: 'Symbole (Drag & Drop)',
title: 'Symbole platzieren',
icon: <LayoutGrid className="w-5 h-5 text-orange-500" />,
description: 'Rechts findest du taktische Feuerwehr-Symbole. Deine eigenen Symbole stehen zuoberst. Ziehe Symbole per Drag & Drop auf die Karte. Klicke auf ein platziertes Symbol um es zu drehen, skalieren oder löschen.',
description: 'Rechts findest du taktische Feuerwehr-Symbole. Deine eigenen Symbole stehen zuoberst. Symbol antippen und dann auf die Karte tippen — oder per Drag & Drop ziehen. Platzierte Symbole kannst du drehen, skalieren oder löschen.',
targetSelector: '[data-tour="sidebar"]',
position: 'left',
},

View File

@@ -186,8 +186,9 @@ export function useRealtimeSync({
}
}, [currentProject?.id, isEditingByMe])
const handleStartEditing = useCallback(async () => {
if (!currentProject?.id) return
// Rückgabe true = Lock erhalten. `silent` unterdrückt den Erfolgs-Toast (für Auto-Bearbeitung).
const handleStartEditing = useCallback(async (opts?: { silent?: boolean }): Promise<boolean> => {
if (!currentProject?.id) return false
setEditingLoading(true)
try {
const res = await fetch(`/api/projects/${currentProject.id}/editing`, {
@@ -198,7 +199,7 @@ export function useRealtimeSync({
if (!res.ok) {
const data = await res.json()
toast({ title: 'Gesperrt', description: data.error || 'Bearbeitung nicht möglich', variant: 'destructive' })
return
return false
}
setIsEditingByMe(true)
const editingInfo = { id: user!.id, name: user!.name, since: new Date().toISOString() }
@@ -210,9 +211,13 @@ export function useRealtimeSync({
editingBy: editingInfo,
sessionId: sessionIdRef.current,
})
toast({ title: 'Bearbeitung gestartet', description: 'Sie können jetzt zeichnen und Einträge erstellen.' })
if (!opts?.silent) {
toast({ title: 'Bearbeitung gestartet', description: 'Sie können jetzt zeichnen und Einträge erstellen.' })
}
return true
} catch (e) {
toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht starten.', variant: 'destructive' })
return false
} finally {
setEditingLoading(false)
}

152
src/lib/modules.ts Normal file
View File

@@ -0,0 +1,152 @@
/**
* Modul-Baukasten für das Einsatz-Cockpit (Journal-Bereich).
*
* Jede Feuerwehr stellt sich ihr Cockpit selbst zusammen:
* - Eingebaute Module: Journal, SOMA, Pendenzen
* - Generische Tabellen-Module ("table"): frei definierbare Spalten
* (Checklisten, Atemschutz-Überwachung, Kräfte vor Ort, Lagemeldungen …)
*
* Die Konfiguration liegt pro Mandant in `tenants.modulesConfig` (JSONB).
* Ohne Konfiguration gelten die DEFAULT_MODULES (heutiges Verhalten).
*/
export type ModuleColumnType = 'text' | 'check' | 'time'
export interface ModuleColumn {
key: string
label: string
type: ModuleColumnType
/** Optionale Tailwind-Breite für die Spalte, z.B. 'w-20' */
width?: string
}
export type ModuleType = 'journal' | 'soma' | 'pendenzen' | 'table'
export interface CockpitModule {
/** Eindeutig pro Mandant. Eingebaute Module: 'journal' | 'soma' | 'pendenzen' */
id: string
type: ModuleType
name: string
/** Lucide-Icon-Schlüssel (siehe MODULE_ICONS im Cockpit) */
icon: string
/** 'main' = breite Hauptspalte, 'side' = schmale Seitenspalte */
slot: 'main' | 'side'
enabled: boolean
sortOrder: number
/** Nur für type 'table': Spaltendefinition */
columns?: ModuleColumn[]
}
/** Standard-Cockpit — entspricht dem bisherigen festen Aufbau */
export const DEFAULT_MODULES: CockpitModule[] = [
{ id: 'journal', type: 'journal', name: 'Journal', icon: 'clipboard-list', slot: 'main', enabled: true, sortOrder: 0 },
{ id: 'soma', type: 'soma', name: 'SOMA', icon: 'alert-triangle', slot: 'side', enabled: true, sortOrder: 1 },
{ id: 'pendenzen', type: 'pendenzen', name: 'Pendenzen', icon: 'check-square', slot: 'side', enabled: true, sortOrder: 2 },
]
/** Vorlagen für neue Tabellen-Module — ein Klick, fertig konfiguriert */
export const MODULE_PRESETS: { key: string; name: string; icon: string; slot: 'main' | 'side'; description: string; columns: ModuleColumn[] }[] = [
{
key: 'checkliste',
name: 'Checkliste',
icon: 'list-checks',
slot: 'side',
description: 'Einfache Abhak-Liste (z.B. Rückzugskontrolle, Alarmstufen)',
columns: [
{ key: 'label', label: 'Punkt', type: 'text' },
{ key: 'done', label: 'Ok', type: 'check', width: 'w-10' },
],
},
{
key: 'atemschutz',
name: 'Atemschutz-Überwachung',
icon: 'shield',
slot: 'side',
description: 'Trupps mit Druck und Zeit überwachen',
columns: [
{ key: 'trupp', label: 'Trupp', type: 'text' },
{ key: 'druck', label: 'Druck (bar)', type: 'text', width: 'w-20' },
{ key: 'start', label: 'Eingesetzt', type: 'time', width: 'w-16' },
{ key: 'out', label: 'Draussen', type: 'check', width: 'w-10' },
],
},
{
key: 'kraefte',
name: 'Kräfte vor Ort',
icon: 'users',
slot: 'side',
description: 'Wer/was ist am Einsatzort (AdF, Fahrzeuge, Partner)',
columns: [
{ key: 'name', label: 'Name / Mittel', type: 'text' },
{ key: 'funktion', label: 'Funktion', type: 'text', width: 'w-24' },
{ key: 'seit', label: 'Vor Ort seit', type: 'time', width: 'w-16' },
{ key: 'abgemeldet', label: 'Weg', type: 'check', width: 'w-10' },
],
},
{
key: 'lagemeldung',
name: 'Lagemeldungen',
icon: 'radio',
slot: 'main',
description: 'Strukturierte Lagemeldungen (Zeit / Lage / Massnahmen / Bedarf)',
columns: [
{ key: 'zeit', label: 'Zeit', type: 'time', width: 'w-16' },
{ key: 'lage', label: 'Lage', type: 'text' },
{ key: 'massnahmen', label: 'Massnahmen', type: 'text' },
{ key: 'bedarf', label: 'Bedarf', type: 'text' },
],
},
{
key: 'leer',
name: 'Eigenes Modul',
icon: 'table',
slot: 'side',
description: 'Leere Tabelle — Spalten komplett selbst definieren',
columns: [
{ key: 'text', label: 'Text', type: 'text' },
],
},
]
/**
* Konfiguration validieren/reparieren: eingebaute Module sicherstellen,
* Unbekanntes verwerfen, stabil sortieren.
*/
export function normalizeModules(raw: unknown): CockpitModule[] {
if (!Array.isArray(raw) || raw.length === 0) return DEFAULT_MODULES
const seen = new Set<string>()
const result: CockpitModule[] = []
for (const m of raw as any[]) {
if (!m || typeof m.id !== 'string' || seen.has(m.id)) continue
const type: ModuleType = ['journal', 'soma', 'pendenzen', 'table'].includes(m.type) ? m.type : 'table'
if (type === 'table' && (!Array.isArray(m.columns) || m.columns.length === 0)) continue
seen.add(m.id)
result.push({
id: m.id,
type,
name: typeof m.name === 'string' && m.name.trim() ? m.name.trim().slice(0, 60) : 'Modul',
icon: typeof m.icon === 'string' ? m.icon : 'table',
slot: m.slot === 'main' ? 'main' : 'side',
enabled: m.enabled !== false,
sortOrder: typeof m.sortOrder === 'number' ? m.sortOrder : result.length,
columns: type === 'table'
? (m.columns as any[]).slice(0, 8).map((c, i) => ({
key: typeof c.key === 'string' && c.key ? c.key : `col${i}`,
label: typeof c.label === 'string' ? c.label.slice(0, 40) : `Spalte ${i + 1}`,
type: (['text', 'check', 'time'] as const).includes(c.type) ? c.type : 'text',
width: typeof c.width === 'string' ? c.width : undefined,
}))
: undefined,
})
}
// Eingebaute Module ergänzen, falls sie in der Config fehlen (dann deaktiviert lassen? → aktiv,
// damit ein kaputter Config-Stand nie das Journal verschwinden lässt)
for (const def of DEFAULT_MODULES) {
if (!seen.has(def.id)) result.push({ ...def, sortOrder: result.length })
}
return result.sort((a, b) => a.sortOrder - b.sortOrder)
}

156
src/lib/offline-tiles.ts Normal file
View File

@@ -0,0 +1,156 @@
/**
* Offline-Karten: Kacheln des Einsatzgebiets vorladen.
*
* Der Service Worker (public/sw.js) cacht Karten-Kacheln bereits Cache-First —
* aber nur solche, die man schon einmal angeschaut hat. Diese Bibliothek lädt
* beim Öffnen eines Einsatzes das Gebiet rund um den Einsatzort proaktiv,
* damit die Karte auch ohne Empfang (Keller, Funkloch) vollständig da ist.
*
* Wichtig: MapLibre wählt die OSM-Subdomain deterministisch über
* (x + y) % anzahl — das Vorladen nutzt exakt dieselbe Formel, damit die
* Cache-Keys übereinstimmen.
*/
// Muss mit TILE_CACHE in public/sw.js übereinstimmen (Fallback, falls der
// Service Worker die Seite (noch) nicht kontrolliert)
const TILE_CACHE = 'lageplan-tiles-v3'
const OSM_SUBDOMAINS = ['a', 'b', 'c']
/** Web-Mercator: Länge/Breite → Kachel-Koordinaten */
function lonToTileX(lon: number, z: number): number {
return Math.floor(((lon + 180) / 360) * Math.pow(2, z))
}
function latToTileY(lat: number, z: number): number {
const rad = (lat * Math.PI) / 180
return Math.floor(((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * Math.pow(2, z))
}
/** Kachelbreite in Metern am gegebenen Breitengrad */
function tileSizeMeters(lat: number, z: number): number {
return (40075016.686 * Math.cos((lat * Math.PI) / 180)) / Math.pow(2, z)
}
export interface PreloadResult {
total: number
cached: number
failed: number
skipped: boolean
}
export interface PreloadOptions {
/** Radius um den Einsatzort in Metern (Standard 800) */
radiusM?: number
/** Zoom-Bereich (Standard 1318) */
minZoom?: number
maxZoom?: number
/** Obergrenze Kachelanzahl als Schutz (Standard 400) */
maxTiles?: number
/** Fortschritts-Callback (0..1) */
onProgress?: (done: number, total: number) => void
}
/** Alle Kachel-URLs für das Gebiet berechnen (OSM, MapLibre-Subdomain-Schema) */
function computeTileUrls(lng: number, lat: number, opts: Required<Pick<PreloadOptions, 'radiusM' | 'minZoom' | 'maxZoom' | 'maxTiles'>>): string[] {
const urls: string[] = []
for (let z = opts.minZoom; z <= opts.maxZoom; z++) {
const sizeM = tileSizeMeters(lat, z)
const tileRadius = Math.max(1, Math.ceil(opts.radiusM / sizeM))
const cx = lonToTileX(lng, z)
const cy = latToTileY(lat, z)
const max = Math.pow(2, z) - 1
for (let x = cx - tileRadius; x <= cx + tileRadius; x++) {
for (let y = cy - tileRadius; y <= cy + tileRadius; y++) {
if (x < 0 || y < 0 || x > max || y > max) continue
const sub = OSM_SUBDOMAINS[Math.abs(x + y) % OSM_SUBDOMAINS.length]
urls.push(`https://${sub}.tile.openstreetmap.org/${z}/${x}/${y}.png`)
if (urls.length >= opts.maxTiles) return urls
}
}
}
return urls
}
/**
* Kacheln rund um den Einsatzort vorladen.
* Läuft im Hintergrund mit begrenzter Parallelität; bereits gecachte Kacheln
* werden übersprungen (kein unnötiger Traffic für den OSM-Server).
*/
export async function preloadTilesForArea(lng: number, lat: number, options: PreloadOptions = {}): Promise<PreloadResult> {
const opts = {
radiusM: options.radiusM ?? 800,
minZoom: options.minZoom ?? 13,
maxZoom: options.maxZoom ?? 18,
maxTiles: options.maxTiles ?? 400,
}
if (typeof window === 'undefined' || !('caches' in window) || !navigator.onLine) {
return { total: 0, cached: 0, failed: 0, skipped: true }
}
const urls = computeTileUrls(lng, lat, opts)
const cache = await caches.open(TILE_CACHE)
// Der SW cacht selbst, wenn er die Seite kontrolliert — sonst übernehmen wir das
const swControls = !!navigator.serviceWorker?.controller
let cached = 0
let failed = 0
let done = 0
const worker = async (url: string) => {
try {
const existing = await cache.match(url)
if (existing) {
cached++
return
}
const res = await fetch(url, { mode: 'cors' })
if (res.ok) {
if (!swControls) {
await cache.put(url, res.clone())
}
cached++
} else {
failed++
}
} catch {
failed++
} finally {
done++
options.onProgress?.(done, urls.length)
}
}
// Begrenzte Parallelität (6), um Gerät und Tile-Server zu schonen
const CONCURRENCY = 6
const queue = [...urls]
await Promise.all(
Array.from({ length: CONCURRENCY }, async () => {
while (queue.length > 0) {
const url = queue.shift()
if (!url) break
await worker(url)
}
})
)
return { total: urls.length, cached, failed, skipped: false }
}
/** Marker, damit pro Einsatz nur einmal (bzw. alle 14 Tage) vorgeladen wird */
export function shouldPreloadForProject(projectId: string): boolean {
try {
const raw = localStorage.getItem(`lageplan-tiles-${projectId}`)
if (!raw) return true
const ts = parseInt(raw, 10)
return !Number.isFinite(ts) || Date.now() - ts > 14 * 24 * 60 * 60 * 1000
} catch {
return true
}
}
export function markPreloadedForProject(projectId: string) {
try {
localStorage.setItem(`lageplan-tiles-${projectId}`, String(Date.now()))
} catch { /* ignore */ }
}