4 Commits

Author SHA1 Message Date
Pepe Ziberi
3a5c7419f9 fix(ux): Gefahrenzone-Werkzeug + Linienstärke + Text-Reset (v1.6.6)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 33m35s
Aus interner UX-Review:
- Gefahrenzone-Werkzeug (Shortcut D) fehlte komplett in der Werkzeugleiste →
  am Handy (ohne Tastatur) gar nicht auslösbar. Jetzt mit AlertTriangle-Icon drin.
- Linienstärke-Regler ergänzt (Dünn/Mittel/Dick) — die Props selectedWidth/
  onWidthChange waren vorhanden, aber nirgends bedienbar.
- Text-Dialog setzt sich beim Öffnen zurück (kein alter Text nach Abbrechen mehr).
- Einsatz-Beschreibung als mehrzeiliges Textfeld statt einzeiligem Input.

Hinweis: Audit-Trail-Trigger bewusst NICHT wieder ergänzt — war auf ausdrücklichen
Wunsch aus dem Menü entfernt worden.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:41:20 +02:00
Pepe Ziberi
92e1e65b71 fix(journal/rapport): SOMA atomar + Rapport-Korrektheit + Export-dpr (v1.6.5)
- SOMA-Init atomar: pg_advisory_xact_lock pro Projekt in Transaktion +
  createMany → keine doppelten SOMA-Listen mehr, wenn zwei Geräte denselben
  frischen Einsatz gleichzeitig öffnen. initCheckItems meldet Änderung an
  andere Clients (journal-refresh).
- Rapport: korrigierte (durchgestrichene) Original-Einträge werden nicht mehr
  als gültige Massnahmen übernommen; Erledigt-Status ("— erledigt HH:MM")
  erscheint jetzt im Rapport; SOMA zeigt okAt, falls confirmedAt fehlt.
- Export: dpr-Fallback auf devicePixelRatio, wenn die Karte ausgeblendet ist
  (Journal-Tab) — vorher Infinity → NaN-Grössen, Export defekt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:38:16 +02:00
Pepe Ziberi
0e65721a1c fix(sync): Editier-Lock-Versionierung + Zeichnen-Race + Mess-Race (v1.6.4)
- handleStopEditing speichert jetzt MIT baseVersion und aktualisiert
  featuresVersionRef aus der Antwort (409 → Server-Stand übernehmen). Verhindert
  Fehl-Konflikte/Konflikt-Schleifen beim erneuten Bearbeiten nach dem Freigeben.
- Lock-Ablehnung (zwei zeichnen bei freier Karte gleichzeitig): der abgelehnte
  Client lädt den echten Sperr-Halter nach → Karte wird lokal read-only, keine
  weiteren nicht-speicherbaren Änderungen mehr.
- Mess-Höhenabfrage: Generation-Zähler bricht veraltete asynchrone Abfragen ab
  (Neue Messung/Verlassen) → keine falschen Höhen-/Druckwerte der alten Messung.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:36:00 +02:00
Pepe Ziberi
d2a1e7fc1e fix(security): Mandanten-Isolation + Rechte-Ausweitung geschlossen (v1.6.3)
Aus interner Sicherheits-Review (verifiziert):

- Rapporte (GET+POST /api/rapports): Tenant-Check via getProjectWithTenantCheck
  ergänzt — vorher konnte jeder eingeloggte Nutzer mit fremder projectId
  Rapporte fremder Mandanten auflisten (inkl. Token → voller Inhalt) bzw.
  anlegen. POST blockt jetzt zusätzlich VIEWER.
- Plan-Bild (GET .../plan-image/serve): globaler Lookup → getProjectWithTenantCheck.
- Journal-Suggestions (GET /api/tenants/[tenantId]/suggestions): Membership-Check
  ergänzt (kein Cross-Tenant-Lesen mehr).
- Admin-User-Anlegen: Nicht-SERVER_ADMIN kann nur noch im EIGENEN Tenant anlegen
  (mitgeschickte tenantId aus dem Body wird ignoriert) — verhindert Rechte-
  Ausweitung in fremde Organisationen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:32:33 +02:00
15 changed files with 142 additions and 50 deletions

View File

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

View File

@@ -69,7 +69,11 @@ export async function POST(req: NextRequest) {
} }
const hashedPassword = await hashPassword(data.password) const hashedPassword = await hashPassword(data.password)
const tenantId = data.tenantId || session.tenantId // Rechte-Ausweitung verhindern: Nicht-SERVER_ADMIN darf NUR im eigenen Tenant
// anlegen — der mitgeschickte tenantId aus dem Body wird ignoriert.
const tenantId = session.role === 'SERVER_ADMIN'
? (data.tenantId || session.tenantId)
: session.tenantId
const user = await (prisma as any).user.create({ const user = await (prisma as any).user.create({
data: { data: {

View File

@@ -16,36 +16,33 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const body = await req.json() const body = await req.json()
// If 'initFromTemplates' is true, create check items from templates (only if none exist) // If 'initFromTemplates' is true, create check items from templates (only if none exist).
// ATOMAR: Advisory-Lock pro Projekt verhindert doppelte SOMA-Listen, wenn zwei Geräte
// denselben frischen Einsatz gleichzeitig öffnen (Client-Guard schützt nur pro Client).
if (body.initFromTemplates) { if (body.initFromTemplates) {
const existing = await (prisma as any).journalCheckItem.findMany({ const items = await (prisma as any).$transaction(async (tx: any) => {
where: { projectId: id }, // Serialisiert Init-Aufrufe desselben Projekts (bis Transaktionsende)
}) await tx.$executeRawUnsafe(`SELECT pg_advisory_xact_lock(hashtext($1))`, `checkitems:${id}`)
if (existing.length > 0) {
return NextResponse.json(existing) const existing = await tx.journalCheckItem.findMany({ where: { projectId: id } })
} if (existing.length > 0) return existing
// Prefer tenant-specific templates; fall back to global (tenantId=null) if none exist
let templates = await (prisma as any).journalCheckTemplate.findMany({ let templates = await tx.journalCheckTemplate.findMany({
where: { isActive: true, tenantId: user.tenantId || null }, where: { isActive: true, tenantId: user.tenantId || null },
orderBy: { sortOrder: 'asc' },
})
if (templates.length === 0 && user.tenantId) {
templates = await (prisma as any).journalCheckTemplate.findMany({
where: { isActive: true, tenantId: null },
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: 'asc' },
}) })
} if (templates.length === 0 && user.tenantId) {
const items = await Promise.all( templates = await tx.journalCheckTemplate.findMany({
templates.map((tpl: any, i: number) => where: { isActive: true, tenantId: null },
(prisma as any).journalCheckItem.create({ orderBy: { sortOrder: 'asc' },
data: {
projectId: id,
label: tpl.label,
sortOrder: i,
},
}) })
) }
) if (templates.length === 0) return []
await tx.journalCheckItem.createMany({
data: templates.map((tpl: any, i: number) => ({ projectId: id, label: tpl.label, sortOrder: i })),
})
return tx.journalCheckItem.findMany({ where: { projectId: id }, orderBy: { sortOrder: 'asc' } })
})
return NextResponse.json(items) return NextResponse.json(items)
} }

View File

@@ -1,20 +1,17 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
import { getFileStream } from '@/lib/minio' import { getFileStream } from '@/lib/minio'
// Serve plan image (authenticated users only) // Serve plan image (only for projects of the user's tenant)
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try { try {
const { id } = await params const { id } = await params
const user = await getSession() const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const project = await (prisma as any).project.findUnique({ // Mandanten-Isolation statt globalem Lookup
where: { id }, const project = await getProjectWithTenantCheck(id, user)
select: { planImageKey: true },
})
if (!project?.planImageKey) { if (!project?.planImageKey) {
return NextResponse.json({ error: 'Kein Plan vorhanden' }, { status: 404 }) return NextResponse.json({ error: 'Kein Plan vorhanden' }, { status: 404 })
} }

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db' import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth' import { getSession, isAdmin } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
import QRCode from 'qrcode' import QRCode from 'qrcode'
// Helper: create a rapport record and return JSON response // Helper: create a rapport record and return JSON response
@@ -73,6 +74,12 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'projectId erforderlich' }, { status: 400 }) return NextResponse.json({ error: 'projectId erforderlich' }, { status: 400 })
} }
// Mandanten-Isolation: nur Rapporte von Projekten des eigenen Tenants
const project = await getProjectWithTenantCheck(projectId, user)
if (!project) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
const rapports = await (prisma as any).rapport.findMany({ const rapports = await (prisma as any).rapport.findMany({
where: { projectId }, where: { projectId },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
@@ -98,6 +105,7 @@ export async function POST(req: NextRequest) {
try { try {
const user = await getSession() const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
let body: any let body: any
try { try {
@@ -113,11 +121,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: `Felder fehlen (projectId=${!!projectId}, data=${!!data})` }, { status: 400 }) return NextResponse.json({ error: `Felder fehlen (projectId=${!!projectId}, data=${!!data})` }, { status: 400 })
} }
// Resolve tenantId: project → user session → membership lookup // Mandanten-Isolation: Rapport nur für ein Projekt des eigenen Tenants anlegen
const project = await (prisma as any).project.findUnique({ const project = await getProjectWithTenantCheck(projectId, user)
where: { id: projectId }, if (!project) {
select: { tenantId: true }, return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}) }
let tenantId = project?.tenantId || user.tenantId || null let tenantId = project?.tenantId || user.tenantId || null
if (!tenantId) { if (!tenantId) {

View File

@@ -12,6 +12,11 @@ export async function GET(
const user = await getSession() const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
// Mandanten-Isolation: nur der eigene Tenant (SERVER_ADMIN darf alle)
if (user.role !== 'SERVER_ADMIN' && user.tenantId !== tenantId) {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
// Fetch from new Dictionary model (global + tenant) // Fetch from new Dictionary model (global + tenant)
const [globalWords, tenantWords, tenant] = await Promise.all([ const [globalWords, tenantWords, tenant] = await Promise.all([
(prisma as any).dictionaryEntry.findMany({ (prisma as any).dictionaryEntry.findMany({

View File

@@ -97,6 +97,7 @@ export default function AppPage() {
featuresRef, featuresRef,
setFeatures, setFeatures,
toast: toast as any, toast: toast as any,
featuresVersionRef,
}) })
// Capture map screenshot when switching to journal tab (coordinate-based rendering) // Capture map screenshot when switching to journal tab (coordinate-based rendering)

View File

@@ -225,7 +225,7 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
<span className="text-center font-bold">{s.confirmed ? '✓' : '—'}</span> <span className="text-center font-bold">{s.confirmed ? '✓' : '—'}</span>
<span className="text-center font-bold">{s.ok ? '✓' : '—'}</span> <span className="text-center font-bold">{s.ok ? '✓' : '—'}</span>
<span className="font-medium">{s.label}</span> <span className="font-medium">{s.label}</span>
<span className="text-right text-[8pt] text-gray-500 font-mono">{s.confirmedAt || ''}</span> <span className="text-right text-[8pt] text-gray-500 font-mono">{s.confirmedAt || s.okAt || ''}</span>
</div> </div>
))} ))}
</div> </div>

View File

@@ -419,12 +419,14 @@ export function ProjectDialog({
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="project-description">Beschreibung</Label> <Label htmlFor="project-description">Beschreibung</Label>
<Input <textarea
id="project-description" id="project-description"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="Optionale Notizen zum Einsatz" placeholder="Optionale Notizen zum Einsatz"
disabled={isCreating} disabled={isCreating}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60 resize-y"
/> />
</div> </div>

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@@ -23,6 +23,11 @@ export function TextDialog({ open, onOpenChange, onConfirm, selectedColor }: Tex
const [text, setText] = useState('') const [text, setText] = useState('')
const [fontSize, setFontSize] = useState(16) const [fontSize, setFontSize] = useState(16)
// Beim Öffnen zurücksetzen — sonst erscheint beim nächsten Text der alte Inhalt
useEffect(() => {
if (open) { setText(''); setFontSize(16) }
}, [open])
const handleConfirm = () => { const handleConfirm = () => {
if (text.trim()) { if (text.trim()) {
onConfirm(text.trim(), fontSize) onConfirm(text.trim(), fontSize)

View File

@@ -169,11 +169,12 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
if (res.ok) { if (res.ok) {
const items = await res.json() const items = await res.json()
setCheckItems(items) setCheckItems(items)
notifyJournalChanged() // andere offene Clients über die neue SOMA-Liste informieren
} }
} catch (err) { } catch (err) {
console.error('Failed to init check items:', err) console.error('Failed to init check items:', err)
} }
}, [projectId]) }, [projectId, notifyJournalChanged])
useEffect(() => { useEffect(() => {
initDoneRef.current = false initDoneRef.current = false
@@ -455,12 +456,17 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
zeitAusruecken: '', zeitEintreffen: '', zeitBereit: '', zeitAusruecken: '', zeitEintreffen: '', zeitBereit: '',
zeitKontrolle: '', zeitAus: '', zeitEinruecken: '', zeitEnde: '', zeitKontrolle: '', zeitAus: '', zeitEinruecken: '', zeitEnde: '',
lageEintreffen: '', lageEintreffen: '',
massnahmen: entries.map(e => `${formatTime(e.time)} ${e.what}${e.who ? ` (${e.who})` : ''}`), // Nur gültige Massnahmen: durchgestrichene Original-Einträge (isCorrected)
// ausfiltern; Erledigt-Status übernehmen.
massnahmen: entries
.filter(e => !(e as any).isCorrected)
.map(e => `${formatTime(e.time)} ${e.what}${e.who ? ` (${e.who})` : ''}${e.done && e.doneAt ? ` — erledigt ${formatTime(e.doneAt)}` : ''}`),
somaItems: checkItems.map(c => ({ somaItems: checkItems.map(c => ({
label: c.label, label: c.label,
confirmed: c.confirmed, confirmed: c.confirmed,
ok: c.ok, ok: c.ok,
confirmedAt: c.confirmedAt ? formatTime(c.confirmedAt) : null, confirmedAt: c.confirmedAt ? formatTime(c.confirmedAt) : null,
okAt: c.okAt ? formatTime(c.okAt) : null,
})), })),
pendenzenItems: pendenzen.map(p => ({ pendenzenItems: pendenzen.map(p => ({
what: p.what, what: p.what,

View File

@@ -21,6 +21,7 @@ import {
MoveRight, MoveRight,
Ruler, Ruler,
Eraser, Eraser,
AlertTriangle,
} from 'lucide-react' } from 'lucide-react'
import type { DrawMode } from '@/types' import type { DrawMode } from '@/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -58,10 +59,17 @@ const drawTools: { mode: DrawMode; icon: typeof MousePointer2; label: string; sh
{ mode: 'freehand', icon: Pencil, label: 'Freihand', shortcut: 'F' }, { mode: 'freehand', icon: Pencil, label: 'Freihand', shortcut: 'F' },
{ mode: 'arrow', icon: MoveRight, label: 'Pfeil / Route', shortcut: 'A' }, { mode: 'arrow', icon: MoveRight, label: 'Pfeil / Route', shortcut: 'A' },
{ mode: 'text', icon: Type, label: 'Text', shortcut: 'T' }, { mode: 'text', icon: Type, label: 'Text', shortcut: 'T' },
{ mode: 'dangerzone', icon: AlertTriangle, label: 'Gefahrenzone', shortcut: 'D' },
{ mode: 'eraser', icon: Eraser, label: 'Radiergummi', shortcut: 'E' }, { mode: 'eraser', icon: Eraser, label: 'Radiergummi', shortcut: 'E' },
{ mode: 'measure', icon: Ruler, label: 'Messen', shortcut: 'M' }, { mode: 'measure', icon: Ruler, label: 'Messen', shortcut: 'M' },
] ]
const widths = [
{ value: 2, label: 'Dünn' },
{ value: 4, label: 'Mittel' },
{ value: 7, label: 'Dick' },
]
export function LeftToolbar({ export function LeftToolbar({
drawMode, drawMode,
onDrawModeChange, onDrawModeChange,
@@ -158,6 +166,30 @@ export function LeftToolbar({
/> />
))} ))}
</div> </div>
<div className="w-8 lg:w-12 h-px bg-border my-1" />
{/* Linienstärke */}
<div className="flex flex-col gap-1">
{widths.map((w) => (
<button
key={w.value}
onClick={() => onWidthChange(w.value)}
disabled={!canEdit}
title={`Linienstärke: ${w.label}`}
className={cn(
'w-11 h-8 lg:w-12 lg:h-8 rounded-md flex items-center justify-center transition-colors',
selectedWidth === w.value ? 'bg-primary/15 ring-1 ring-primary' : 'hover:bg-accent',
!canEdit && 'opacity-50 cursor-not-allowed'
)}
>
<span
className="rounded-full bg-foreground"
style={{ width: '20px', height: `${w.value}px` }}
/>
</button>
))}
</div>
</aside> </aside>
</TooltipProvider> </TooltipProvider>
) )

View File

@@ -126,6 +126,9 @@ export function MapView({
const markerCleanupsRef = useRef<(() => void)[]>([]) const markerCleanupsRef = useRef<(() => void)[]>([])
const measureMarkersRef = useRef<maplibregl.Marker[]>([]) const measureMarkersRef = useRef<maplibregl.Marker[]>([])
const measureCoordsRef = useRef<number[][]>([]) const measureCoordsRef = useRef<number[][]>([])
// Generation-Zähler: bricht veraltete (asynchrone) Höhen-Abfragen ab, wenn
// zwischenzeitlich eine neue Messung gestartet/geleert wurde.
const measureGenRef = useRef(0)
const [isMapLoaded, setIsMapLoaded] = useState(false) const [isMapLoaded, setIsMapLoaded] = useState(false)
const [activeBaseLayer, setActiveBaseLayer] = useState<'osm' | 'satellite' | 'swisstopo'>('osm') const [activeBaseLayer, setActiveBaseLayer] = useState<'osm' | 'satellite' | 'swisstopo'>('osm')
const [layerDropdownOpen, setLayerDropdownOpen] = useState(false) const [layerDropdownOpen, setLayerDropdownOpen] = useState(false)
@@ -454,6 +457,8 @@ export function MapView({
const fetchElevationsAndUpdate = useCallback(async () => { const fetchElevationsAndUpdate = useCallback(async () => {
const coords = measureCoordsRef.current const coords = measureCoordsRef.current
if (coords.length < 2 || !map.current) return if (coords.length < 2 || !map.current) return
// Diese Abfrage gehört zur aktuellen Messung; wird sie überholt, verwerfen wir das Ergebnis.
const gen = measureGenRef.current
// Interpolate extra points along the line (every ~30m) for better elevation accuracy // Interpolate extra points along the line (every ~30m) for better elevation accuracy
const sampledCoords: number[][] = [coords[0]] const sampledCoords: number[][] = [coords[0]]
@@ -560,6 +565,10 @@ export function MapView({
} catch { /* ignore */ } } catch { /* ignore */ }
if (hoseTypes.length === 0) hoseTypes = FALLBACK_HOSE_TYPES if (hoseTypes.length === 0) hoseTypes = FALLBACK_HOSE_TYPES
// Überholt? (neue/geleerte Messung während der async Abfragen) → Ergebnis verwerfen,
// damit keine Höhen-Labels/Druckwerte der ALTEN Messung erscheinen.
if (gen !== measureGenRef.current) return
let totalDist = 0 let totalDist = 0
for (let i = 1; i < coords.length; i++) { for (let i = 1; i < coords.length; i++) {
totalDist += haversineDistance(coords[i - 1], coords[i]) totalDist += haversineDistance(coords[i - 1], coords[i])
@@ -1906,6 +1915,7 @@ export function MapView({
// Reset measurement state when leaving measure mode (but keep info panel — user closes it) // Reset measurement state when leaving measure mode (but keep info panel — user closes it)
useEffect(() => { useEffect(() => {
if (drawMode !== 'measure') { if (drawMode !== 'measure') {
measureGenRef.current++ // pendende Höhen-Abfrage beim Verlassen entwerten
measureMarkersRef.current.forEach(m => m.remove()) measureMarkersRef.current.forEach(m => m.remove())
measureMarkersRef.current = [] measureMarkersRef.current = []
measureCoordsRef.current = [] measureCoordsRef.current = []
@@ -2156,6 +2166,7 @@ export function MapView({
// Messung komplett entfernen (Linie, Marker, Höhen-Labels, Panel, Zustand) // Messung komplett entfernen (Linie, Marker, Höhen-Labels, Panel, Zustand)
const clearMeasurement = useCallback(() => { const clearMeasurement = useCallback(() => {
measureGenRef.current++ // laufende Höhen-Abfragen der alten Messung entwerten
measureMarkersRef.current.forEach(m => m.remove()) measureMarkersRef.current.forEach(m => m.remove())
measureMarkersRef.current = [] measureMarkersRef.current = []
measureCoordsRef.current = [] measureCoordsRef.current = []

View File

@@ -43,7 +43,11 @@ export function useMapExport({
const currentFeatures = featuresRef.current const currentFeatures = featuresRef.current
// Derive actual pixel ratio from canvas vs container (more reliable than window.devicePixelRatio) // Derive actual pixel ratio from canvas vs container (more reliable than window.devicePixelRatio)
const container = mapInstance.getContainer() const container = mapInstance.getContainer()
const dpr = mapCanvas.width / container.offsetWidth // Ist die Karte gerade ausgeblendet (Journal-Tab aktiv), ist offsetWidth 0 →
// dpr würde Infinity und alle Grössen NaN. Dann auf devicePixelRatio zurückfallen.
const dpr = container.offsetWidth > 0
? mapCanvas.width / container.offsetWidth
: (typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1)
const zoom = mapInstance.getZoom() const zoom = mapInstance.getZoom()
// Symbol sizing: match the map rendering logic exactly // Symbol sizing: match the map rendering logic exactly
// In map-view.tsx: size = baseSize * scale * Math.pow(2, currentZoom - placementZoom) // In map-view.tsx: size = baseSize * scale * Math.pow(2, currentZoom - placementZoom)

View File

@@ -8,6 +8,8 @@ interface UseRealtimeSyncOptions {
featuresRef: React.MutableRefObject<DrawFeature[]> featuresRef: React.MutableRefObject<DrawFeature[]>
setFeatures: (features: DrawFeature[] | ((prev: DrawFeature[]) => DrawFeature[])) => void setFeatures: (features: DrawFeature[] | ((prev: DrawFeature[]) => DrawFeature[])) => void
toast: (opts: { title: string; description?: string; variant?: string }) => void toast: (opts: { title: string; description?: string; variant?: string }) => void
/** Optimistischer Lock: aktuelle Features-Version mitführen/aktualisieren */
featuresVersionRef?: React.MutableRefObject<number>
} }
export function useRealtimeSync({ export function useRealtimeSync({
@@ -16,6 +18,7 @@ export function useRealtimeSync({
featuresRef, featuresRef,
setFeatures, setFeatures,
toast, toast,
featuresVersionRef,
}: UseRealtimeSyncOptions) { }: UseRealtimeSyncOptions) {
// Live editing lock state // Live editing lock state
const [editingBy, setEditingBy] = useState<{ id: string; name: string; since: string } | null>(null) const [editingBy, setEditingBy] = useState<{ id: string; name: string; since: string } | null>(null)
@@ -198,7 +201,10 @@ export function useRealtimeSync({
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
toast({ title: 'Gesperrt', description: data.error || 'Bearbeitung nicht möglich', variant: 'destructive' }) toast({ title: 'Gesperrt', description: data.error || 'Ein anderes Gerät bearbeitet gerade die Karte.', variant: 'destructive' })
// Realen Sperr-Halter nachladen → Karte wird lokal read-only, damit keine
// weiteren (nicht speicherbaren) Änderungen mehr gezeichnet werden.
checkEditingStatus(currentProject.id)
return false return false
} }
setIsEditingByMe(true) setIsEditingByMe(true)
@@ -227,13 +233,27 @@ export function useRealtimeSync({
if (!currentProject?.id) return if (!currentProject?.id) return
setEditingLoading(true) setEditingLoading(true)
try { try {
// Save features before releasing lock // Save features before releasing lock — MIT baseVersion (optimistischer Lock),
// damit die Version danach stimmt und der nächste Auto-Save keinen Fehl-Konflikt auslöst.
const currentFeatures = featuresRef.current const currentFeatures = featuresRef.current
await fetch(`/api/projects/${currentProject.id}/features`, { const saveRes = await fetch(`/api/projects/${currentProject.id}/features`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ features: currentFeatures }), body: JSON.stringify({ features: currentFeatures, baseVersion: featuresVersionRef?.current }),
}) })
if (saveRes.ok) {
const data = await saveRes.json().catch(() => null)
if (data && typeof data.featuresVersion === 'number' && featuresVersionRef) {
featuresVersionRef.current = data.featuresVersion
}
} else if (saveRes.status === 409) {
// Zwischenzeitlich geändert: Server-Stand übernehmen statt still zu überschreiben
const data = await saveRes.json().catch(() => null)
if (data?.features) setFeatures(data.features)
if (data && typeof data.featuresVersion === 'number' && featuresVersionRef) {
featuresVersionRef.current = data.featuresVersion
}
}
// Release lock // Release lock
await fetch(`/api/projects/${currentProject.id}/editing`, { await fetch(`/api/projects/${currentProject.id}/editing`, {
method: 'POST', method: 'POST',