Compare commits
6 Commits
703265f47e
...
796ce53db3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
796ce53db3 | ||
|
|
2dbb2204f6 | ||
|
|
6eab656ae6 | ||
|
|
b1451b665a | ||
|
|
efedbdba4d | ||
|
|
2556a5c4a4 |
23
ROADMAP.md
23
ROADMAP.md
@@ -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 13–18, ~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** 🟡 ✅
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
56
src/app/api/projects/[id]/modules/[moduleId]/items/route.ts
Normal file
56
src/app/api/projects/[id]/modules/[moduleId]/items/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
57
src/app/api/tenant/modules/route.ts
Normal file
57
src/app/api/tenant/modules/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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,30 +954,37 @@ export default function AppPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Übungs-Banner — verhindert Verwechslung mit echtem Einsatz */}
|
||||
{/* 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' && (
|
||||
<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 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>
|
||||
</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>
|
||||
<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 {
|
||||
@@ -945,46 +997,12 @@ export default function AppPage() {
|
||||
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"
|
||||
className="ml-1 underline hover:no-underline"
|
||||
>
|
||||
Erneut senden
|
||||
senden
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -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,6 +329,7 @@ export function ProjectDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor="project-location">
|
||||
Einsatzort
|
||||
{selectedCoords && (
|
||||
@@ -277,6 +338,17 @@ export function ProjectDialog({
|
||||
</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>
|
||||
{locateError ? (
|
||||
<p className="text-xs text-amber-600">{locateError} — Adresse bitte manuell eingeben.</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Adresse suchen — die Karte springt automatisch zum Einsatzort
|
||||
Standort wird beim Öffnen vorgeschlagen — oder Adresse suchen. Die Karte springt automatisch zum Einsatzort.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -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,10 +559,14 @@ 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">
|
||||
{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>
|
||||
@@ -589,7 +609,7 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2 py-1.5 print:py-1 text-muted-foreground truncate">
|
||||
{entry.who || '\u2013'}
|
||||
{entry.who || '–'}
|
||||
</div>
|
||||
<div className="px-1 py-1.5 print:py-1 flex items-center justify-center">
|
||||
{canEdit && !(entry as any).isCorrected ? (
|
||||
@@ -736,178 +756,67 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Right column: SOMA + Pendenzen */}
|
||||
{/* 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">
|
||||
{/* 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]"
|
||||
{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}
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={addCheckItem} disabled={!newCheckLabel.trim()} className="h-6 px-1.5">
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
{/* 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>}
|
||||
</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]"
|
||||
{/* Modul-Verwaltung */}
|
||||
<ModuleManagerDialog
|
||||
open={showModuleManager}
|
||||
onOpenChange={setShowModuleManager}
|
||||
modules={modules}
|
||||
onSaved={setModules}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rapport Dialog */}
|
||||
{showRapportDialog && projectId && (
|
||||
|
||||
261
src/components/journal/module-manager-dialog.tsx
Normal file
261
src/components/journal/module-manager-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
src/components/journal/modules/pendenzen-module.tsx
Normal file
108
src/components/journal/modules/pendenzen-module.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
99
src/components/journal/modules/soma-module.tsx
Normal file
99
src/components/journal/modules/soma-module.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
209
src/components/journal/modules/table-module.tsx
Normal file
209
src/components/journal/modules/table-module.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
src/components/journal/types.ts
Normal file
40
src/components/journal/types.ts
Normal 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' })
|
||||
}
|
||||
@@ -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 totalSymbols = groups.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)
|
||||
}
|
||||
|
||||
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 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)
|
||||
}, [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.
|
||||
{!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]">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>
|
||||
)}
|
||||
<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
|
||||
{/* 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={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})`}
|
||||
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 md:w-4 md:h-4" />
|
||||
<span className="hidden lg:inline max-w-[70px] truncate">{cat.name}</span>
|
||||
<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>
|
||||
{/* 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">
|
||||
{currentCategory.symbols.map((symbol) => (
|
||||
{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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
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
152
src/lib/modules.ts
Normal 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
156
src/lib/offline-tiles.ts
Normal 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 13–18) */
|
||||
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 */ }
|
||||
}
|
||||
Reference in New Issue
Block a user