Compare commits
4 Commits
d758c8c744
...
3a5c7419f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a5c7419f9 | ||
|
|
92e1e65b71 | ||
|
|
0e65721a1c | ||
|
|
d2a1e7fc1e |
@@ -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": {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
})
|
})
|
||||||
if (templates.length === 0 && user.tenantId) {
|
if (templates.length === 0 && user.tenantId) {
|
||||||
templates = await (prisma as any).journalCheckTemplate.findMany({
|
templates = await tx.journalCheckTemplate.findMany({
|
||||||
where: { isActive: true, tenantId: null },
|
where: { isActive: true, tenantId: null },
|
||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const items = await Promise.all(
|
if (templates.length === 0) return []
|
||||||
templates.map((tpl: any, i: number) =>
|
await tx.journalCheckItem.createMany({
|
||||||
(prisma as any).journalCheckItem.create({
|
data: templates.map((tpl: any, i: number) => ({ projectId: id, label: tpl.label, sortOrder: i })),
|
||||||
data: {
|
})
|
||||||
projectId: id,
|
return tx.journalCheckItem.findMany({ where: { projectId: id }, orderBy: { sortOrder: 'asc' } })
|
||||||
label: tpl.label,
|
|
||||||
sortOrder: i,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
)
|
|
||||||
)
|
|
||||||
return NextResponse.json(items)
|
return NextResponse.json(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user