fix(robustheit+ux): atomares Speichern, Offline-Härtung, Einsatz-Beenden, Audit raus
Robustheit / Datensicherheit: - Features-PUT in prisma.$transaction (delete+create atomar) — verhindert Totalverlust bei Abbruch zwischen Löschen und Neuanlegen - localStorage-Features pro Projekt (lageplan-features-<id>) statt globalem Key — verhindert Datenübertrag zwischen Projekten - Sync-Queue verwirft dauerhafte 4xx-Fehler statt Endlos-Retry - beforeunload persistiert auch leere Feature-Listen (Löschungen gehen nicht verloren) UX: - Leitungsrechner-Dropdown: Popover z-[2000] (lag hinter dem Panel), Label gekürzt - Neuer "Einsatz beenden"-Button (speichert, löst Lock, schliesst Einsatz) - Audit-Trail-Einstiege entfernt (Toolbar-Button + Menü-Eintrag) Version 1.4.10 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,15 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross
|
||||
- Live-Sync für Journal existierte bereits (Socket → journal-refresh).
|
||||
- _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)
|
||||
- Offen (🟡 5): Multi-Device-Versionierung — Editor-Lock ist pro Session, nicht pro User;
|
||||
Last-Writer-Wins ohne `updatedAt`-Check. Eigener grösserer Schritt.
|
||||
|
||||
## Zusatz – Leitungsrechner professionalisiert ✅
|
||||
|
||||
- [x] Formel zentralisiert in `src/lib/hose-calc.ts` (eine Quelle, typisiert, testbar)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lageplan",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -127,20 +127,24 @@ export async function PUT(
|
||||
})
|
||||
}
|
||||
|
||||
await (prisma as any).feature.deleteMany({
|
||||
where: { projectId: id },
|
||||
})
|
||||
|
||||
// ATOMAR: löschen + neu anlegen in EINER Transaktion.
|
||||
// Sonst droht bei Abbruch zwischen delete und create Totalverlust aller Elemente.
|
||||
const ops: any[] = [
|
||||
(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 || {},
|
||||
})),
|
||||
})
|
||||
ops.push(
|
||||
(prisma as any).feature.createMany({
|
||||
data: features.map((f: any) => ({
|
||||
projectId: id,
|
||||
type: f.type,
|
||||
geometry: f.geometry,
|
||||
properties: f.properties || {},
|
||||
})),
|
||||
})
|
||||
)
|
||||
}
|
||||
await (prisma as any).$transaction(ops)
|
||||
|
||||
const updatedFeatures = await (prisma as any).feature.findMany({
|
||||
where: { projectId: id },
|
||||
|
||||
@@ -437,7 +437,7 @@ export default function AppPage() {
|
||||
if (apiFeatures.length > 0) {
|
||||
setFeatures(apiFeatures)
|
||||
} else {
|
||||
const savedFeatures = localStorage.getItem('lageplan-features')
|
||||
const savedFeatures = localStorage.getItem(`lageplan-features-${proj.id}`)
|
||||
if (savedFeatures) setFeatures(JSON.parse(savedFeatures))
|
||||
}
|
||||
})
|
||||
@@ -445,14 +445,14 @@ export default function AppPage() {
|
||||
// Project not accessible (different tenant, deleted, etc.) — clear
|
||||
console.log('[Restore] Project not accessible for current user, clearing')
|
||||
localStorage.removeItem('lageplan-project')
|
||||
localStorage.removeItem('lageplan-features')
|
||||
localStorage.removeItem(`lageplan-features-${proj.id}`)
|
||||
setCurrentProject(null)
|
||||
setFeatures([])
|
||||
}
|
||||
}).catch(() => {
|
||||
// Network error — use cached data as fallback
|
||||
// Network error — use cached data as fallback (nur Features DESSELBEN Projekts)
|
||||
setCurrentProject(proj)
|
||||
const savedFeatures = localStorage.getItem('lageplan-features')
|
||||
const savedFeatures = localStorage.getItem(`lageplan-features-${proj.id}`)
|
||||
if (savedFeatures) setFeatures(JSON.parse(savedFeatures))
|
||||
})
|
||||
}
|
||||
@@ -508,7 +508,7 @@ export default function AppPage() {
|
||||
undoStackRef.current = []
|
||||
redoStackRef.current = []
|
||||
localStorage.removeItem('lageplan-project')
|
||||
localStorage.removeItem('lageplan-features')
|
||||
localStorage.removeItem(`lageplan-features-${deletedId}`)
|
||||
}
|
||||
addAudit(`Einsatz gelöscht`)
|
||||
toast({
|
||||
@@ -588,6 +588,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[]) => {
|
||||
undoStackRef.current.push(featuresRef.current)
|
||||
redoStackRef.current = []
|
||||
@@ -798,6 +814,7 @@ export default function AppPage() {
|
||||
<Topbar
|
||||
project={currentProject}
|
||||
onNewProject={handleNewProject}
|
||||
onEndProject={handleEndProject}
|
||||
onSaveProject={handleSaveProject}
|
||||
onLoadProject={handleProjectLoaded}
|
||||
onDeleteProject={handleDeleteProject}
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
HelpCircle,
|
||||
Lock,
|
||||
Unlock,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react'
|
||||
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
|
||||
import type { Project, DrawFeature, ProjectMode } from '@/types'
|
||||
@@ -51,6 +52,7 @@ import { Logo } from '@/components/ui/logo'
|
||||
interface TopbarProps {
|
||||
project: Project | null
|
||||
onNewProject: (mode?: ProjectMode) => void
|
||||
onEndProject?: () => void
|
||||
onSaveProject: () => void
|
||||
onLoadProject: (project: Project, features: DrawFeature[]) => void
|
||||
onDeleteProject?: (projectId: string) => void
|
||||
@@ -76,6 +78,7 @@ interface TopbarProps {
|
||||
export function Topbar({
|
||||
project,
|
||||
onNewProject,
|
||||
onEndProject,
|
||||
onSaveProject,
|
||||
onLoadProject,
|
||||
onDeleteProject,
|
||||
@@ -285,19 +288,17 @@ export function Topbar({
|
||||
{isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={isAuditOpen ? 'default' : 'outline'}
|
||||
className="hidden md:flex h-9 px-2 relative"
|
||||
onClick={onToggleAudit}
|
||||
title="Audit Trail"
|
||||
>
|
||||
<ClipboardList className="w-4 h-4" />
|
||||
{auditLog.length > 0 && (
|
||||
<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">
|
||||
{auditLog.length > 99 ? '99' : auditLog.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{project && onEndProject && (
|
||||
<Button
|
||||
variant="outline"
|
||||
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"
|
||||
onClick={onEndProject}
|
||||
title="Einsatz speichern und schliessen"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 lg:mr-1" />
|
||||
<span className="hidden lg:inline">Einsatz beenden</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Desktop: User menu dropdown */}
|
||||
{userName && onLogout && (
|
||||
@@ -366,10 +367,6 @@ export function Topbar({
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Einstellungen
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onToggleAudit}>
|
||||
<ClipboardList className="w-4 h-4 mr-2" />
|
||||
Audit Trail {auditLog.length > 0 && `(${auditLog.length})`}
|
||||
</DropdownMenuItem>
|
||||
{onLogout && (
|
||||
<DropdownMenuItem onClick={onLogout} className="text-destructive">
|
||||
<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">
|
||||
<Select value={selectedHoseName} onValueChange={setSelectedHoseName}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Schlauchtyp" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectTrigger className="h-8 text-xs min-w-0"><SelectValue placeholder="Schlauchtyp" /></SelectTrigger>
|
||||
<SelectContent className="z-[2000]">
|
||||
{hoseTypes.map(h => (
|
||||
<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>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={nozzlePressure} onValueChange={setNozzlePressure}>
|
||||
<SelectTrigger className="h-8 text-xs w-[92px]" title="Druck am Strahlrohr/Verteiler"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectTrigger className="h-8 text-xs w-[92px] shrink-0" title="Druck am Strahlrohr/Verteiler"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="z-[2000]">
|
||||
{['4', '5', '6', '8'].map(p => <SelectItem key={p} value={p}>{p} bar</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -21,10 +21,12 @@ export function useAutoSave({
|
||||
isEditingByMe,
|
||||
setSyncQueueCount,
|
||||
}: 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(() => {
|
||||
localStorage.setItem('lageplan-features', JSON.stringify(features))
|
||||
}, [features])
|
||||
if (!currentProject?.id) return
|
||||
localStorage.setItem(`lageplan-features-${currentProject.id}`, JSON.stringify(features))
|
||||
}, [features, currentProject?.id])
|
||||
|
||||
// Auto-save to API — debounced 2s after every feature change + fallback interval
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -81,7 +83,9 @@ export function useAutoSave({
|
||||
// Also save on page unload / tab switch
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
if (currentProject?.id && featuresRef.current.length > 0) {
|
||||
// Nur der aktive Bearbeiter persistiert beim Schliessen — auch eine leere Liste,
|
||||
// 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 })
|
||||
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) {
|
||||
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 {
|
||||
// Server error — keep in queue for retry
|
||||
// Server-Fehler (5xx) — später erneut versuchen
|
||||
remaining.push(item)
|
||||
failed++
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user