From 92e1e65b71d5e4f047bcd2d651b19afa62195a42 Mon Sep 17 00:00:00 2001 From: Pepe Ziberi Date: Wed, 22 Jul 2026 21:38:16 +0200 Subject: [PATCH] fix(journal/rapport): SOMA atomar + Rapport-Korrektheit + Export-dpr (v1.6.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- package.json | 2 +- .../[id]/journal/check-items/route.ts | 49 +++++++++---------- src/app/rapport/[token]/page.tsx | 2 +- src/components/journal/journal-view.tsx | 10 +++- src/hooks/use-map-export.ts | 6 ++- 5 files changed, 38 insertions(+), 31 deletions(-) diff --git a/package.json b/package.json index 00cffab..508461a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.6.4", + "version": "1.6.5", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "private": true, "scripts": { diff --git a/src/app/api/projects/[id]/journal/check-items/route.ts b/src/app/api/projects/[id]/journal/check-items/route.ts index 6f803a6..2c6eaff 100644 --- a/src/app/api/projects/[id]/journal/check-items/route.ts +++ b/src/app/api/projects/[id]/journal/check-items/route.ts @@ -16,36 +16,33 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: 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) { - const existing = await (prisma as any).journalCheckItem.findMany({ - where: { projectId: id }, - }) - if (existing.length > 0) { - return NextResponse.json(existing) - } - // Prefer tenant-specific templates; fall back to global (tenantId=null) if none exist - let templates = await (prisma as any).journalCheckTemplate.findMany({ - 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 }, + const items = await (prisma as any).$transaction(async (tx: any) => { + // Serialisiert Init-Aufrufe desselben Projekts (bis Transaktionsende) + await tx.$executeRawUnsafe(`SELECT pg_advisory_xact_lock(hashtext($1))`, `checkitems:${id}`) + + const existing = await tx.journalCheckItem.findMany({ where: { projectId: id } }) + if (existing.length > 0) return existing + + let templates = await tx.journalCheckTemplate.findMany({ + where: { isActive: true, tenantId: user.tenantId || null }, orderBy: { sortOrder: 'asc' }, }) - } - const items = await Promise.all( - templates.map((tpl: any, i: number) => - (prisma as any).journalCheckItem.create({ - data: { - projectId: id, - label: tpl.label, - sortOrder: i, - }, + if (templates.length === 0 && user.tenantId) { + templates = await tx.journalCheckTemplate.findMany({ + where: { isActive: true, tenantId: null }, + orderBy: { sortOrder: 'asc' }, }) - ) - ) + } + 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) } diff --git a/src/app/rapport/[token]/page.tsx b/src/app/rapport/[token]/page.tsx index 7db092f..993f4ff 100644 --- a/src/app/rapport/[token]/page.tsx +++ b/src/app/rapport/[token]/page.tsx @@ -225,7 +225,7 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token: {s.confirmed ? '✓' : '—'} {s.ok ? '✓' : '—'} {s.label} - {s.confirmedAt || ''} + {s.confirmedAt || s.okAt || ''} ))} diff --git a/src/components/journal/journal-view.tsx b/src/components/journal/journal-view.tsx index 7e11a6d..92698ee 100644 --- a/src/components/journal/journal-view.tsx +++ b/src/components/journal/journal-view.tsx @@ -169,11 +169,12 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei if (res.ok) { const items = await res.json() setCheckItems(items) + notifyJournalChanged() // andere offene Clients über die neue SOMA-Liste informieren } } catch (err) { console.error('Failed to init check items:', err) } - }, [projectId]) + }, [projectId, notifyJournalChanged]) useEffect(() => { initDoneRef.current = false @@ -455,12 +456,17 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei zeitAusruecken: '', zeitEintreffen: '', zeitBereit: '', zeitKontrolle: '', zeitAus: '', zeitEinruecken: '', zeitEnde: '', 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 => ({ label: c.label, confirmed: c.confirmed, ok: c.ok, confirmedAt: c.confirmedAt ? formatTime(c.confirmedAt) : null, + okAt: c.okAt ? formatTime(c.okAt) : null, })), pendenzenItems: pendenzen.map(p => ({ what: p.what, diff --git a/src/hooks/use-map-export.ts b/src/hooks/use-map-export.ts index 245f1a1..55aa3e0 100644 --- a/src/hooks/use-map-export.ts +++ b/src/hooks/use-map-export.ts @@ -43,7 +43,11 @@ export function useMapExport({ const currentFeatures = featuresRef.current // Derive actual pixel ratio from canvas vs container (more reliable than window.devicePixelRatio) 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() // Symbol sizing: match the map rendering logic exactly // In map-view.tsx: size = baseSize * scale * Math.pow(2, currentZoom - placementZoom)