Compare commits
2 Commits
ddb7c63600
...
947a757d9b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
947a757d9b | ||
|
|
8b5f4a6778 |
11
ROADMAP.md
11
ROADMAP.md
@@ -52,6 +52,17 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross
|
|||||||
- Live-Sync für Journal existierte bereits (Socket → journal-refresh).
|
- Live-Sync für Journal existierte bereits (Socket → journal-refresh).
|
||||||
- _Offen (Kür): echtes gleichzeitiges Zeichnen (Co-Drawing) — bewusst nicht gebaut._
|
- _Offen (Kür): echtes gleichzeitiges Zeichnen (Co-Drawing) — bewusst nicht gebaut._
|
||||||
|
|
||||||
|
## Zusatz – Robustheit / Datensicherheit ✅
|
||||||
|
|
||||||
|
- [x] Features-Speichern atomar (`prisma.$transaction`) — kein Totalverlust-Fenster mehr
|
||||||
|
- [x] localStorage-Features pro Projekt (`lageplan-features-<id>`) statt globalem Key
|
||||||
|
- [x] Sync-Queue verwirft dauerhafte 4xx-Fehler (kein Endlos-Retry/verstopfte Queue)
|
||||||
|
- [x] `beforeunload` persistiert auch leere Listen (gelöschte Elemente gehen nicht verloren)
|
||||||
|
- [x] **5: Optimistische Nebenläufigkeit** — `Project.featuresVersion` + Versions-Check
|
||||||
|
beim Speichern. Ein veralteter (z.B. offline) Stand überschreibt neuere Änderungen nicht
|
||||||
|
mehr still, sondern erzeugt 409 → Client übernimmt Server-Stand + Warnung. Offline-Queue
|
||||||
|
verwirft konfliktierende 4xx. (Merge pro Element / echtes Co-Drawing weiterhin offen.)
|
||||||
|
|
||||||
## Zusatz – Leitungsrechner professionalisiert ✅
|
## Zusatz – Leitungsrechner professionalisiert ✅
|
||||||
|
|
||||||
- [x] Formel zentralisiert in `src/lib/hose-calc.ts` (eine Quelle, typisiert, testbar)
|
- [x] Formel zentralisiert in `src/lib/hose-calc.ts` (eine Quelle, typisiert, testbar)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.4.9",
|
"version": "1.4.11",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ async function migrate() {
|
|||||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "journalfuehrer" TEXT`,
|
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "journalfuehrer" TEXT`,
|
||||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "mode" TEXT NOT NULL DEFAULT 'EINSATZ'`,
|
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "mode" TEXT NOT NULL DEFAULT 'EINSATZ'`,
|
||||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "exerciseEvaluation" TEXT`,
|
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "exerciseEvaluation" TEXT`,
|
||||||
|
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "featuresVersion" INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingById" TEXT`,
|
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingById" TEXT`,
|
||||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingUserName" TEXT`,
|
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingUserName" TEXT`,
|
||||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingSessionId" TEXT`,
|
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingSessionId" TEXT`,
|
||||||
|
|||||||
@@ -166,6 +166,10 @@ model Project {
|
|||||||
mapCenter Json @default("{\"lng\": 8.5417, \"lat\": 47.3769}")
|
mapCenter Json @default("{\"lng\": 8.5417, \"lat\": 47.3769}")
|
||||||
mapZoom Float @default(15)
|
mapZoom Float @default(15)
|
||||||
isLocked Boolean @default(false)
|
isLocked Boolean @default(false)
|
||||||
|
// Optimistische Nebenläufigkeit: wird bei jedem Features-Speichern hochgezählt.
|
||||||
|
// Verhindert, dass ein veralteter (z.B. offline zwischengespeicherter) Stand
|
||||||
|
// neuere Änderungen stillschweigend überschreibt.
|
||||||
|
featuresVersion Int @default(0)
|
||||||
|
|
||||||
// Live editing lock (session-based for same-account multi-device)
|
// Live editing lock (session-based for same-account multi-device)
|
||||||
editingById String?
|
editingById String?
|
||||||
|
|||||||
@@ -20,12 +20,15 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const features = await (prisma as any).feature.findMany({
|
const [features, proj] = await Promise.all([
|
||||||
where: { projectId: id },
|
(prisma as any).feature.findMany({
|
||||||
orderBy: { createdAt: 'asc' },
|
where: { projectId: id },
|
||||||
})
|
orderBy: { createdAt: 'asc' },
|
||||||
|
}),
|
||||||
|
(prisma as any).project.findUnique({ where: { id }, select: { featuresVersion: true } }),
|
||||||
|
])
|
||||||
|
|
||||||
return NextResponse.json({ features })
|
return NextResponse.json({ features, featuresVersion: proj?.featuresVersion ?? 0 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching features:', error)
|
console.error('Error fetching features:', error)
|
||||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||||
@@ -113,40 +116,74 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { features, mapCenter, mapZoom } = body as {
|
const { features, mapCenter, mapZoom, baseVersion } = body as {
|
||||||
features: Array<{ id?: string; type: string; geometry: object; properties?: object }>
|
features: Array<{ id?: string; type: string; geometry: object; properties?: object }>
|
||||||
mapCenter?: { lng: number; lat: number }
|
mapCenter?: { lng: number; lat: number }
|
||||||
mapZoom?: number
|
mapZoom?: number
|
||||||
|
baseVersion?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist map viewport alongside features (if provided)
|
try {
|
||||||
if (mapCenter && mapZoom !== undefined) {
|
// ATOMAR + optimistischer Lock in EINER interaktiven Transaktion:
|
||||||
await (prisma as any).project.update({
|
// - Version prüfen/hochzählen (verhindert Überschreiben durch veralteten Stand)
|
||||||
where: { id },
|
// - Features löschen + neu anlegen (kein Totalverlust-Fenster)
|
||||||
data: { mapCenter, mapZoom },
|
const result = await (prisma as any).$transaction(async (tx: any) => {
|
||||||
})
|
if (typeof baseVersion === 'number') {
|
||||||
|
// Nur speichern, wenn die Version noch stimmt (atomar über WHERE-Bedingung)
|
||||||
|
const bump = await tx.project.updateMany({
|
||||||
|
where: { id, featuresVersion: baseVersion },
|
||||||
|
data: { featuresVersion: { increment: 1 } },
|
||||||
|
})
|
||||||
|
if (bump.count === 0) {
|
||||||
|
const conflict: any = new Error('VERSION_CONFLICT')
|
||||||
|
conflict.code = 'VERSION_CONFLICT'
|
||||||
|
throw conflict
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Kein baseVersion mitgeschickt (Alt-Client/Beacon) → nur hochzählen
|
||||||
|
await tx.project.update({ where: { id }, data: { featuresVersion: { increment: 1 } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapCenter && mapZoom !== undefined) {
|
||||||
|
await tx.project.update({ where: { id }, data: { mapCenter, mapZoom } })
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.feature.deleteMany({ where: { projectId: id } })
|
||||||
|
if (features && features.length > 0) {
|
||||||
|
await tx.feature.createMany({
|
||||||
|
data: features.map((f: any) => ({
|
||||||
|
projectId: id,
|
||||||
|
type: f.type,
|
||||||
|
geometry: f.geometry,
|
||||||
|
properties: f.properties || {},
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedFeatures, proj] = await Promise.all([
|
||||||
|
tx.feature.findMany({ where: { projectId: id } }),
|
||||||
|
tx.project.findUnique({ where: { id }, select: { featuresVersion: true } }),
|
||||||
|
])
|
||||||
|
return { features: updatedFeatures, featuresVersion: proj?.featuresVersion ?? 0 }
|
||||||
|
}, { timeout: 15000, maxWait: 5000 })
|
||||||
|
|
||||||
|
return NextResponse.json(result)
|
||||||
|
} catch (txError: any) {
|
||||||
|
if (txError?.code === 'VERSION_CONFLICT') {
|
||||||
|
// Konflikt: aktuellen Server-Stand zurückgeben, damit der Client abgleichen kann
|
||||||
|
const [currentFeatures, proj] = await Promise.all([
|
||||||
|
(prisma as any).feature.findMany({ where: { projectId: id }, orderBy: { createdAt: 'asc' } }),
|
||||||
|
(prisma as any).project.findUnique({ where: { id }, select: { featuresVersion: true } }),
|
||||||
|
])
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Der Einsatz wurde zwischenzeitlich geändert.',
|
||||||
|
conflict: true,
|
||||||
|
features: currentFeatures,
|
||||||
|
featuresVersion: proj?.featuresVersion ?? 0,
|
||||||
|
}, { status: 409 })
|
||||||
|
}
|
||||||
|
throw txError
|
||||||
}
|
}
|
||||||
|
|
||||||
await (prisma as any).feature.deleteMany({
|
|
||||||
where: { projectId: id },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (features && features.length > 0) {
|
|
||||||
await (prisma as any).feature.createMany({
|
|
||||||
data: features.map((f: any) => ({
|
|
||||||
projectId: id,
|
|
||||||
type: f.type,
|
|
||||||
geometry: f.geometry,
|
|
||||||
properties: f.properties || {},
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedFeatures = await (prisma as any).feature.findMany({
|
|
||||||
where: { projectId: id },
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ features: updatedFeatures })
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating features:', error)
|
console.error('Error updating features:', error)
|
||||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export default function AppPage() {
|
|||||||
const featuresRef = useRef<DrawFeature[]>(features)
|
const featuresRef = useRef<DrawFeature[]>(features)
|
||||||
useEffect(() => { featuresRef.current = features }, [features])
|
useEffect(() => { featuresRef.current = features }, [features])
|
||||||
|
|
||||||
|
// Optimistischer Lock: Features-Version des aktuellen Projekts mitführen
|
||||||
|
const featuresVersionRef = useRef<number>(0)
|
||||||
|
|
||||||
// Ref for undo-draw-point (removes last point during line drawing)
|
// Ref for undo-draw-point (removes last point during line drawing)
|
||||||
const undoDrawPointRef = useRef<(() => boolean) | null>(null)
|
const undoDrawPointRef = useRef<(() => boolean) | null>(null)
|
||||||
|
|
||||||
@@ -385,6 +388,24 @@ export default function AppPage() {
|
|||||||
const canEdit = canEditMap // Abwärtskompatibel für Karten-bezogene Stellen
|
const canEdit = canEditMap // Abwärtskompatibel für Karten-bezogene Stellen
|
||||||
const isReadOnly = !!editingBy && !isEditingByMe
|
const isReadOnly = !!editingBy && !isEditingByMe
|
||||||
|
|
||||||
|
// Features-Version bei Projektwechsel initialisieren
|
||||||
|
useEffect(() => {
|
||||||
|
featuresVersionRef.current = (currentProject as any)?.featuresVersion ?? 0
|
||||||
|
}, [currentProject?.id])
|
||||||
|
|
||||||
|
// Speicher-Konflikt: Server-Stand übernehmen und den Nutzer warnen
|
||||||
|
const handleSaveConflict = useCallback((serverFeatures: DrawFeature[], serverVersion: number) => {
|
||||||
|
setFeatures(serverFeatures)
|
||||||
|
featuresVersionRef.current = serverVersion
|
||||||
|
undoStackRef.current = []
|
||||||
|
redoStackRef.current = []
|
||||||
|
toast({
|
||||||
|
title: 'Konflikt erkannt',
|
||||||
|
description: 'Der Einsatz wurde von jemand anderem geändert — der aktuelle Stand wurde geladen. Bitte prüfe deine Änderungen und mache sie ggf. erneut.',
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
// Auto-save: localStorage persistence + debounced API save + beacon on unload
|
// Auto-save: localStorage persistence + debounced API save + beacon on unload
|
||||||
useAutoSave({
|
useAutoSave({
|
||||||
currentProject,
|
currentProject,
|
||||||
@@ -394,6 +415,8 @@ export default function AppPage() {
|
|||||||
socketRef,
|
socketRef,
|
||||||
isEditingByMe,
|
isEditingByMe,
|
||||||
setSyncQueueCount,
|
setSyncQueueCount,
|
||||||
|
featuresVersionRef,
|
||||||
|
onConflict: handleSaveConflict,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fullscreen toggle
|
// Fullscreen toggle
|
||||||
@@ -437,7 +460,7 @@ export default function AppPage() {
|
|||||||
if (apiFeatures.length > 0) {
|
if (apiFeatures.length > 0) {
|
||||||
setFeatures(apiFeatures)
|
setFeatures(apiFeatures)
|
||||||
} else {
|
} else {
|
||||||
const savedFeatures = localStorage.getItem('lageplan-features')
|
const savedFeatures = localStorage.getItem(`lageplan-features-${proj.id}`)
|
||||||
if (savedFeatures) setFeatures(JSON.parse(savedFeatures))
|
if (savedFeatures) setFeatures(JSON.parse(savedFeatures))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -445,14 +468,14 @@ export default function AppPage() {
|
|||||||
// Project not accessible (different tenant, deleted, etc.) — clear
|
// Project not accessible (different tenant, deleted, etc.) — clear
|
||||||
console.log('[Restore] Project not accessible for current user, clearing')
|
console.log('[Restore] Project not accessible for current user, clearing')
|
||||||
localStorage.removeItem('lageplan-project')
|
localStorage.removeItem('lageplan-project')
|
||||||
localStorage.removeItem('lageplan-features')
|
localStorage.removeItem(`lageplan-features-${proj.id}`)
|
||||||
setCurrentProject(null)
|
setCurrentProject(null)
|
||||||
setFeatures([])
|
setFeatures([])
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// Network error — use cached data as fallback
|
// Network error — use cached data as fallback (nur Features DESSELBEN Projekts)
|
||||||
setCurrentProject(proj)
|
setCurrentProject(proj)
|
||||||
const savedFeatures = localStorage.getItem('lageplan-features')
|
const savedFeatures = localStorage.getItem(`lageplan-features-${proj.id}`)
|
||||||
if (savedFeatures) setFeatures(JSON.parse(savedFeatures))
|
if (savedFeatures) setFeatures(JSON.parse(savedFeatures))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -508,7 +531,7 @@ export default function AppPage() {
|
|||||||
undoStackRef.current = []
|
undoStackRef.current = []
|
||||||
redoStackRef.current = []
|
redoStackRef.current = []
|
||||||
localStorage.removeItem('lageplan-project')
|
localStorage.removeItem('lageplan-project')
|
||||||
localStorage.removeItem('lageplan-features')
|
localStorage.removeItem(`lageplan-features-${deletedId}`)
|
||||||
}
|
}
|
||||||
addAudit(`Einsatz gelöscht`)
|
addAudit(`Einsatz gelöscht`)
|
||||||
toast({
|
toast({
|
||||||
@@ -522,7 +545,7 @@ export default function AppPage() {
|
|||||||
|
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
const saveBody: any = { features }
|
const saveBody: any = { features, baseVersion: featuresVersionRef.current }
|
||||||
if (mapRef.current) {
|
if (mapRef.current) {
|
||||||
const c = mapRef.current.getCenter()
|
const c = mapRef.current.getCenter()
|
||||||
saveBody.mapCenter = { lng: c.lng, lat: c.lat }
|
saveBody.mapCenter = { lng: c.lng, lat: c.lat }
|
||||||
@@ -554,19 +577,34 @@ export default function AppPage() {
|
|||||||
}
|
}
|
||||||
const created = await createRes.json()
|
const created = await createRes.json()
|
||||||
setCurrentProject(created.project)
|
setCurrentProject(created.project)
|
||||||
|
featuresVersionRef.current = created.project?.featuresVersion ?? 0
|
||||||
// Retry save with new project ID
|
// Retry save with new project ID
|
||||||
res = await fetch(`/api/projects/${created.project.id}/features`, {
|
res = await fetch(`/api/projects/${created.project.id}/features`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ features }),
|
body: JSON.stringify({ features, baseVersion: featuresVersionRef.current }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Versionskonflikt: Server-Stand übernehmen statt zu überschreiben
|
||||||
|
if (res.status === 409) {
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
if (data?.conflict) {
|
||||||
|
handleSaveConflict(data.features || [], data.featuresVersion ?? featuresVersionRef.current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}))
|
const err = await res.json().catch(() => ({}))
|
||||||
throw new Error(err.error || 'Speichern fehlgeschlagen')
|
throw new Error(err.error || 'Speichern fehlgeschlagen')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const okData = await res.json().catch(() => null)
|
||||||
|
if (okData && typeof okData.featuresVersion === 'number') {
|
||||||
|
featuresVersionRef.current = okData.featuresVersion
|
||||||
|
}
|
||||||
|
|
||||||
// Broadcast features to other clients
|
// Broadcast features to other clients
|
||||||
socketRef.current?.emit('features-updated', {
|
socketRef.current?.emit('features-updated', {
|
||||||
projectId: currentProject.id,
|
projectId: currentProject.id,
|
||||||
@@ -588,6 +626,22 @@ export default function AppPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Einsatz beenden: speichern, Bearbeitungs-Lock lösen und zurück zur Übersicht.
|
||||||
|
const handleEndProject = async () => {
|
||||||
|
if (!currentProject) return
|
||||||
|
if (!confirm(`Einsatz "${currentProject.title}" beenden? Alle Änderungen werden gespeichert und der Einsatz geschlossen.`)) return
|
||||||
|
const title = currentProject.title
|
||||||
|
try { await handleSaveProject() } catch { /* Save meldet Fehler selbst */ }
|
||||||
|
if (isEditingByMe) { try { await handleStopEditing() } catch { /* ignore */ } }
|
||||||
|
addAudit(`Einsatz "${title}" beendet`)
|
||||||
|
setCurrentProject(null)
|
||||||
|
setFeatures([])
|
||||||
|
undoStackRef.current = []
|
||||||
|
redoStackRef.current = []
|
||||||
|
localStorage.removeItem('lageplan-project')
|
||||||
|
toast({ title: 'Einsatz beendet', description: 'Gespeichert und geschlossen.' })
|
||||||
|
}
|
||||||
|
|
||||||
const handleFeaturesChange = useCallback((newFeatures: DrawFeature[]) => {
|
const handleFeaturesChange = useCallback((newFeatures: DrawFeature[]) => {
|
||||||
undoStackRef.current.push(featuresRef.current)
|
undoStackRef.current.push(featuresRef.current)
|
||||||
redoStackRef.current = []
|
redoStackRef.current = []
|
||||||
@@ -798,6 +852,7 @@ export default function AppPage() {
|
|||||||
<Topbar
|
<Topbar
|
||||||
project={currentProject}
|
project={currentProject}
|
||||||
onNewProject={handleNewProject}
|
onNewProject={handleNewProject}
|
||||||
|
onEndProject={handleEndProject}
|
||||||
onSaveProject={handleSaveProject}
|
onSaveProject={handleSaveProject}
|
||||||
onLoadProject={handleProjectLoaded}
|
onLoadProject={handleProjectLoaded}
|
||||||
onDeleteProject={handleDeleteProject}
|
onDeleteProject={handleDeleteProject}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
Lock,
|
Lock,
|
||||||
Unlock,
|
Unlock,
|
||||||
|
CheckCircle2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
|
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
|
||||||
import type { Project, DrawFeature, ProjectMode } from '@/types'
|
import type { Project, DrawFeature, ProjectMode } from '@/types'
|
||||||
@@ -51,6 +52,7 @@ import { Logo } from '@/components/ui/logo'
|
|||||||
interface TopbarProps {
|
interface TopbarProps {
|
||||||
project: Project | null
|
project: Project | null
|
||||||
onNewProject: (mode?: ProjectMode) => void
|
onNewProject: (mode?: ProjectMode) => void
|
||||||
|
onEndProject?: () => void
|
||||||
onSaveProject: () => void
|
onSaveProject: () => void
|
||||||
onLoadProject: (project: Project, features: DrawFeature[]) => void
|
onLoadProject: (project: Project, features: DrawFeature[]) => void
|
||||||
onDeleteProject?: (projectId: string) => void
|
onDeleteProject?: (projectId: string) => void
|
||||||
@@ -76,6 +78,7 @@ interface TopbarProps {
|
|||||||
export function Topbar({
|
export function Topbar({
|
||||||
project,
|
project,
|
||||||
onNewProject,
|
onNewProject,
|
||||||
|
onEndProject,
|
||||||
onSaveProject,
|
onSaveProject,
|
||||||
onLoadProject,
|
onLoadProject,
|
||||||
onDeleteProject,
|
onDeleteProject,
|
||||||
@@ -285,19 +288,17 @@ export function Topbar({
|
|||||||
{isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
|
{isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
{project && onEndProject && (
|
||||||
variant={isAuditOpen ? 'default' : 'outline'}
|
<Button
|
||||||
className="hidden md:flex h-9 px-2 relative"
|
variant="outline"
|
||||||
onClick={onToggleAudit}
|
className="hidden md:flex h-9 px-2 md:px-3 border-red-300 text-red-600 hover:bg-red-50 hover:text-red-700 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-950/40"
|
||||||
title="Audit Trail"
|
onClick={onEndProject}
|
||||||
>
|
title="Einsatz speichern und schliessen"
|
||||||
<ClipboardList className="w-4 h-4" />
|
>
|
||||||
{auditLog.length > 0 && (
|
<CheckCircle2 className="w-4 h-4 lg:mr-1" />
|
||||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-primary text-primary-foreground text-[10px] rounded-full flex items-center justify-center font-bold">
|
<span className="hidden lg:inline">Einsatz beenden</span>
|
||||||
{auditLog.length > 99 ? '99' : auditLog.length}
|
</Button>
|
||||||
</span>
|
)}
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Desktop: User menu dropdown */}
|
{/* Desktop: User menu dropdown */}
|
||||||
{userName && onLogout && (
|
{userName && onLogout && (
|
||||||
@@ -366,10 +367,6 @@ export function Topbar({
|
|||||||
<Settings className="w-4 h-4 mr-2" />
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
Einstellungen
|
Einstellungen
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={onToggleAudit}>
|
|
||||||
<ClipboardList className="w-4 h-4 mr-2" />
|
|
||||||
Audit Trail {auditLog.length > 0 && `(${auditLog.length})`}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{onLogout && (
|
{onLogout && (
|
||||||
<DropdownMenuItem onClick={onLogout} className="text-destructive">
|
<DropdownMenuItem onClick={onLogout} className="text-destructive">
|
||||||
<LogOut className="w-4 h-4 mr-2" />
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
|
|||||||
@@ -103,18 +103,18 @@ export function MeasurePanel({ measurement, hoseTypes, onClose }: MeasurePanelPr
|
|||||||
|
|
||||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||||
<Select value={selectedHoseName} onValueChange={setSelectedHoseName}>
|
<Select value={selectedHoseName} onValueChange={setSelectedHoseName}>
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Schlauchtyp" /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs min-w-0"><SelectValue placeholder="Schlauchtyp" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="z-[2000]">
|
||||||
{hoseTypes.map(h => (
|
{hoseTypes.map(h => (
|
||||||
<SelectItem key={h.name} value={h.name}>
|
<SelectItem key={h.name} value={h.name}>
|
||||||
{h.name} · {h.diameterMm}mm · {h.flowRateLpm} l/min{h.isDefault ? ' ★' : ''}
|
{h.name}{h.isDefault ? ' ★' : ''} · {h.flowRateLpm} l/min
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={nozzlePressure} onValueChange={setNozzlePressure}>
|
<Select value={nozzlePressure} onValueChange={setNozzlePressure}>
|
||||||
<SelectTrigger className="h-8 text-xs w-[92px]" title="Druck am Strahlrohr/Verteiler"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs w-[92px] shrink-0" title="Druck am Strahlrohr/Verteiler"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="z-[2000]">
|
||||||
{['4', '5', '6', '8'].map(p => <SelectItem key={p} value={p}>{p} bar</SelectItem>)}
|
{['4', '5', '6', '8'].map(p => <SelectItem key={p} value={p}>{p} bar</SelectItem>)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ interface UseAutoSaveOptions {
|
|||||||
socketRef: React.MutableRefObject<any>
|
socketRef: React.MutableRefObject<any>
|
||||||
isEditingByMe: boolean
|
isEditingByMe: boolean
|
||||||
setSyncQueueCount: (count: number) => void
|
setSyncQueueCount: (count: number) => void
|
||||||
|
/** Aktuelle Features-Version des Projekts (optimistischer Lock) */
|
||||||
|
featuresVersionRef: React.MutableRefObject<number>
|
||||||
|
/** Wird bei einem Speicher-Konflikt (409) mit dem Server-Stand aufgerufen */
|
||||||
|
onConflict: (serverFeatures: DrawFeature[], serverVersion: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAutoSave({
|
export function useAutoSave({
|
||||||
@@ -20,11 +24,15 @@ export function useAutoSave({
|
|||||||
socketRef,
|
socketRef,
|
||||||
isEditingByMe,
|
isEditingByMe,
|
||||||
setSyncQueueCount,
|
setSyncQueueCount,
|
||||||
|
featuresVersionRef,
|
||||||
|
onConflict,
|
||||||
}: UseAutoSaveOptions) {
|
}: UseAutoSaveOptions) {
|
||||||
// Persist features to localStorage on change (including empty array to reflect deletions)
|
// Persist features to localStorage on change (per project — verhindert, dass Features
|
||||||
|
// eines Projekts in ein anderes "bluten"). Inkl. leerem Array (spiegelt Löschungen).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('lageplan-features', JSON.stringify(features))
|
if (!currentProject?.id) return
|
||||||
}, [features])
|
localStorage.setItem(`lageplan-features-${currentProject.id}`, JSON.stringify(features))
|
||||||
|
}, [features, currentProject?.id])
|
||||||
|
|
||||||
// Auto-save to API — debounced 2s after every feature change + fallback interval
|
// Auto-save to API — debounced 2s after every feature change + fallback interval
|
||||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
@@ -32,7 +40,7 @@ export function useAutoSave({
|
|||||||
if (!currentProject?.id) return
|
if (!currentProject?.id) return
|
||||||
const url = `/api/projects/${currentProject.id}/features`
|
const url = `/api/projects/${currentProject.id}/features`
|
||||||
const mapInstance = mapRef.current
|
const mapInstance = mapRef.current
|
||||||
const body: any = { features: featuresRef.current }
|
const body: any = { features: featuresRef.current, baseVersion: featuresVersionRef.current }
|
||||||
if (mapInstance) {
|
if (mapInstance) {
|
||||||
const c = mapInstance.getCenter()
|
const c = mapInstance.getCenter()
|
||||||
body.mapCenter = { lng: c.lng, lat: c.lat }
|
body.mapCenter = { lng: c.lng, lat: c.lat }
|
||||||
@@ -54,11 +62,22 @@ export function useAutoSave({
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
if (data && typeof data.featuresVersion === 'number') {
|
||||||
|
featuresVersionRef.current = data.featuresVersion
|
||||||
|
}
|
||||||
console.log('[Auto-Save] Features gespeichert')
|
console.log('[Auto-Save] Features gespeichert')
|
||||||
socketRef.current?.emit('features-updated', {
|
socketRef.current?.emit('features-updated', {
|
||||||
projectId: currentProject.id,
|
projectId: currentProject.id,
|
||||||
features: featuresRef.current,
|
features: featuresRef.current,
|
||||||
})
|
})
|
||||||
|
} else if (res.status === 409) {
|
||||||
|
// Konflikt: jemand anderes hat zwischenzeitlich gespeichert → Server-Stand übernehmen
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
if (data?.conflict) {
|
||||||
|
console.warn('[Auto-Save] Versionskonflikt — Server-Stand übernehmen')
|
||||||
|
onConflict(data.features || [], data.featuresVersion ?? featuresVersionRef.current)
|
||||||
|
}
|
||||||
} else if (res.status === 404) {
|
} else if (res.status === 404) {
|
||||||
console.warn('[Auto-Save] Projekt nicht in DB')
|
console.warn('[Auto-Save] Projekt nicht in DB')
|
||||||
}
|
}
|
||||||
@@ -68,7 +87,7 @@ export function useAutoSave({
|
|||||||
setSyncQueueCount(getSyncQueue().length)
|
setSyncQueueCount(getSyncQueue().length)
|
||||||
console.warn('[Auto-Save] Netzwerkfehler — in Sync-Queue:', e)
|
console.warn('[Auto-Save] Netzwerkfehler — in Sync-Queue:', e)
|
||||||
}
|
}
|
||||||
}, [currentProject, mapRef, featuresRef, socketRef, setSyncQueueCount])
|
}, [currentProject, mapRef, featuresRef, socketRef, setSyncQueueCount, featuresVersionRef, onConflict])
|
||||||
|
|
||||||
// Debounced save on every feature change (2s delay)
|
// Debounced save on every feature change (2s delay)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -81,8 +100,10 @@ export function useAutoSave({
|
|||||||
// Also save on page unload / tab switch
|
// Also save on page unload / tab switch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleBeforeUnload = () => {
|
const handleBeforeUnload = () => {
|
||||||
if (currentProject?.id && featuresRef.current.length > 0) {
|
// Nur der aktive Bearbeiter persistiert beim Schliessen — auch eine leere Liste,
|
||||||
const payload = JSON.stringify({ features: featuresRef.current })
|
// damit "alles gelöscht + Tab zu" nicht die alten Elemente in der DB stehen lässt.
|
||||||
|
if (currentProject?.id && isEditingByMe) {
|
||||||
|
const payload = JSON.stringify({ features: featuresRef.current, baseVersion: featuresVersionRef.current })
|
||||||
navigator.sendBeacon(`/api/projects/${currentProject.id}/features`, new Blob([payload], { type: 'application/json' }))
|
navigator.sendBeacon(`/api/projects/${currentProject.id}/features`, new Blob([payload], { type: 'application/json' }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,12 @@ export async function flushSyncQueue(): Promise<{ success: number; failed: numbe
|
|||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
success++
|
success++
|
||||||
|
} else if (res.status >= 400 && res.status < 500) {
|
||||||
|
// Client-Fehler (403/404/...) = dauerhaft. Retry bringt nichts → verwerfen,
|
||||||
|
// damit die Queue nicht verstopft und nicht ewig "Sync-Fehler" meldet.
|
||||||
|
failed++
|
||||||
} else {
|
} else {
|
||||||
// Server error — keep in queue for retry
|
// Server-Fehler (5xx) — später erneut versuchen
|
||||||
remaining.push(item)
|
remaining.push(item)
|
||||||
failed++
|
failed++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface Project {
|
|||||||
mapCenter: { lng: number; lat: number }
|
mapCenter: { lng: number; lat: number }
|
||||||
mapZoom: number
|
mapZoom: number
|
||||||
isLocked: boolean
|
isLocked: boolean
|
||||||
|
featuresVersion?: number
|
||||||
editingById?: string | null
|
editingById?: string | null
|
||||||
editingUserName?: string | null
|
editingUserName?: string | null
|
||||||
editingStartedAt?: string | null
|
editingStartedAt?: string | null
|
||||||
|
|||||||
Reference in New Issue
Block a user