5 Commits

Author SHA1 Message Date
Pepe Ziberi
e9e8f3fa8e fix(tour): Anker repariert + robuste Hervorhebung (v1.5.8)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 33m4s
Nach dem Topbar-Umbau zeigte die Tour auf nicht (mehr) vorhandene bzw.
grössenlose Elemente:
- data-tour="toolbar" lag auf einem display:contents-Div (0×0) → jetzt auf der
  echten Werkzeug-<aside>
- data-tour="sidebar" wurde als Prop an RightSidebar gegeben, aber nie ins DOM
  durchgereicht → jetzt direkt auf der Sidebar-<aside>
- updateHighlight ignoriert fehlende/unsichtbare/off-canvas Ziele (z.B. Sidebar
  auf dem Handy) und zeigt den Tooltip zentriert statt einer kaputten Box
- Tooltip responsiv (max-w statt fix 340px) → kein Überlauf auf schmalen Handys

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:02:19 +02:00
Pepe Ziberi
a4d0a10794 harden(modules): Payload-Grenze + Modul-ID-Prüfung nach Sicherheits-Review (v1.5.7)
Unabhängige Security-Review bestätigte: keine Autorisierungs-/Mandanten-Lücken
(Tenant-Isolation via getProjectWithTenantCheck, VIEWER-Schreibsperre, keine
Injection). Zwei Härtungspunkte umgesetzt:

- module_items data-Payload auf 20 KB pro Zeile begrenzt (POST+PUT) → kein
  DB-Bloat/DoS durch beliebig grosse JSON-Objekte
- moduleId-Länge auf 64 Zeichen begrenzt (verhindert pathologische Keys)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:55:01 +02:00
Pepe Ziberi
e02ea21c93 feat(exercise): Übungs-Cockpit nutzt den Modul-Baukasten + Testplan (v1.5.6)
- Übungen zeigen jetzt zusätzlich zu Zielen/Auswertung dieselben eigenen
  Tabellen-Module wie Einsätze (z.B. Atemschutz, Kräfte vor Ort)
- Live-Sync der Modul-Zeilen auch in der Übung (journal-updated Socket)
- docs/TESTPLAN.md: strukturierter Handy-/Einsatz-Testplan

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:53:37 +02:00
Pepe Ziberi
0a6c9cbcf2 feat(rapport): eigene Cockpit-Module erscheinen im Einsatzrapport (v1.5.5)
- Rapport öffnen sammelt jetzt auch die Daten der eigenen Tabellen-Module
  (Atemschutz, Kräfte vor Ort, Lagemeldungen, eigene Checklisten)
- Neue Rapport-Sektion rendert jede Modul-Tabelle mit ihren Spalten
  (Text / Haken / Zeit) im PDF unter Pendenzen
- Vorschau der Module auch im Rapport-Dialog
- Zeit-Spalten: ISO → HH:MM, Haken → ✓/—

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:50:02 +02:00
Pepe Ziberi
bbf6fbdd13 feat(field): Symbole am Stück setzen + eigene GPS-Position (v1.5.4)
- Tap-to-place bleibt scharf: mehrere gleiche Symbole nacheinander setzen
  (z.B. 4 Hydranten), Hinweis zeigt Anzahl + "Fertig"-Knopf
- Zeichen-Werkzeug wählen beendet den Platzier-Modus
- GPS: eigene Position dauerhaft mit Genauigkeitskreis + Blickrichtung
- Beim Öffnen bleibt die Karte auf dem Einsatzort (springt nicht mehr auf
  die eigene Position, wenn ein Einsatzort gesetzt ist)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:46:43 +02:00
13 changed files with 292 additions and 69 deletions

49
docs/TESTPLAN.md Normal file
View File

@@ -0,0 +1,49 @@
# Handy-/Einsatz-Testplan
Kurzer, strukturierter Durchlauf nach jedem grösseren Deploy — am besten einmal
am **Handy** und einmal am **Desktop**. Abhaken, was funktioniert.
## 1. Einsatz anlegen
- [ ] 🔥 **Neu → Neuer Einsatz**: Dialog öffnet, Standort wird automatisch als Ort vorgeschlagen
- [ ] Titel eingeben → **Erstellen** → Karte startet am Einsatzort
- [ ] Kopfzeile zeigt den Einsatz mittig (Status-Punkt rot, Nr, Titel, Ort), Uhr rechts
## 2. Karte & Symbole (der wichtige Handy-Test)
- [ ] Symbol **antippen** → Hinweis erscheint → **auf Karte tippen** platziert es
- [ ] Mehrere gleiche Symbole nacheinander setzen → Zähler steigt → **Fertig**
- [ ] Symbol antippen (auf der Karte) → drehen/skalieren/löschen
- [ ] Zeichnen: Pfeil/Linie ziehen → Dialog fragt **Normal / Leitung / Rettungsachse**
- [ ] Eigene **GPS-Position** sichtbar (Punkt + Genauigkeit), Karte bleibt am Einsatzort
- [ ] **Vollbild-Button** (oben links) blendet die Browser-Leiste aus
## 3. Auto-Bearbeitung
- [ ] Ohne „Bearbeitung starten" direkt zeichnen können (Lock wird automatisch übernommen)
- [ ] Zweites Gerät/Person: sieht Änderungen live, Karte ist dort gesperrt
- [ ] Statuszeile zeigt „Sie bearbeiten" + **freigeben**
## 4. Cockpit-Module (Baukasten)
- [ ] Journal-Tab → **Module** (als Admin): Modul an/aus, Reihenfolge, eigenes Modul erstellen
- [ ] Neues Modul (z.B. Atemschutz) erscheint im Cockpit, Zeilen anlegen/haken/löschen
- [ ] Änderungen live auf zweitem Gerät sichtbar
## 5. Journal & Rapport
- [ ] Journal-Eintrag erfassen (mit Vorschlags-Leiste), Erledigt-Haken, Korrektur
- [ ] SOMA/Pendenzen bedienen
- [ ] **Rapport** erzeugen → PDF enthält Journal, SOMA, Pendenzen **und die eigenen Module**
- [ ] Rapport per E-Mail / Drucken
## 6. Übung
- [ ] 🎓 **Neu → Neue Übung** → Übungs-Cockpit mit Zielen + Auswertung
- [ ] Übungsziele mit Status (Erreicht/Teilweise/…), Auswertung speichert automatisch
- [ ] Eigene Module erscheinen auch in der Übung
## 7. Offline
- [ ] Einsatz öffnen → Toast „Karte offline verfügbar" abwarten
- [ ] Flugmodus an → Karte im Einsatzgebiet bleibt sichtbar, zeichnen möglich
- [ ] Flugmodus aus → Änderungen werden synchronisiert (Statuszeile „Offline" verschwindet)
## 8. Beenden
- [ ] **Einsatz beenden** (Kopfzeile/Menü) → In-App-Bestätigung → gespeichert & geschlossen
---
_Bei Problemen: betroffenen Schritt notieren + ggf. Screenshot, dann gezielt fixen._

View File

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

View File

@@ -25,9 +25,15 @@ export async function PUT(
const body = await request.json()
const patch = body && typeof body.data === 'object' && body.data !== null ? body.data : {}
const merged = { ...(existing.data || {}), ...patch }
// Payload-Härtung: Zeilengrösse begrenzen (DB-Bloat/DoS vermeiden)
if (JSON.stringify(merged).length > 20000) {
return NextResponse.json({ error: 'Eintrag zu gross' }, { status: 413 })
}
const item = await (prisma as any).moduleItem.update({
where: { id: itemId },
data: { data: { ...(existing.data || {}), ...patch } },
data: { data: merged },
})
return NextResponse.json(item)
} catch (error) {

View File

@@ -38,11 +38,17 @@ export async function POST(
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
if (moduleId.length > 64) return NextResponse.json({ error: 'Ungültige Modul-ID' }, { status: 400 })
const project = await getProjectWithTenantCheck(id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const body = await request.json()
const data = body && typeof body.data === 'object' && body.data !== null ? body.data : {}
// Payload-Härtung: eine Tabellenzeile bleibt klein — verhindert DB-Bloat/DoS
if (JSON.stringify(data).length > 20000) {
return NextResponse.json({ error: 'Eintrag zu gross' }, { status: 413 })
}
const count = await (prisma as any).moduleItem.count({ where: { projectId: id, moduleId } })
const item = await (prisma as any).moduleItem.create({

View File

@@ -19,7 +19,7 @@ import { useAuth } from '@/components/providers/auth-provider'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { JournalView } from '@/components/journal/journal-view'
import { Lock, Eye, AlertTriangle, WifiOff, GraduationCap, Map as MapIcon, ClipboardList, LayoutGrid, X } from 'lucide-react'
import { Lock, Eye, AlertTriangle, WifiOff, GraduationCap, Map as MapIcon, ClipboardList, LayoutGrid } from 'lucide-react'
import { CustomDragLayer } from '@/components/map/custom-drag-layer'
import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour'
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
@@ -51,6 +51,8 @@ export default function AppPage() {
const [isEndConfirmOpen, setIsEndConfirmOpen] = useState(false)
// Tap-to-place: angetipptes Symbol wartet auf Platzierung per Karten-Tipp (Mobil)
const [pendingSymbol, setPendingSymbol] = useState<{ id: string; imageUrl: string } | null>(null)
// Feld-Tempo: bleibt scharf, um mehrere gleiche Symbole am Stück zu setzen
const [placedCount, setPlacedCount] = useState(0)
const [isSaving, setIsSaving] = useState(false)
const [isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen] = useState(false)
const [isFullscreen, setIsFullscreen] = useState(false)
@@ -403,7 +405,11 @@ export default function AppPage() {
// Werkzeugwechsel auf ein Zeichen-Tool übernimmt vorab den Lock (rechtzeitig vor dem Zeichnen)
const handleDrawModeChange = useCallback((mode: DrawMode) => {
if (mode !== 'select') ensureEditing()
if (mode !== 'select') {
ensureEditing()
setPendingSymbol(null) // ein Zeichen-Werkzeug beendet das Symbol-Platzieren
setPlacedCount(0)
}
setDrawMode(mode)
}, [ensureEditing, setDrawMode])
@@ -769,10 +775,11 @@ export default function AppPage() {
const handleSymbolSelect = useCallback((symbol: { id: string; imageUrl: string }) => {
ensureEditing() // Lock schon beim Auswählen übernehmen, damit das Platzieren sofort greift
setPendingSymbol(symbol)
setPlacedCount(0)
setIsSidebarOpen(false)
setActiveTab('map')
setDrawMode('select')
toast({ title: 'Symbol platzieren', description: 'Tippe auf die Karte, um das Symbol zu setzen.' })
toast({ title: 'Symbol platzieren', description: 'Auf die Karte tippen — mehrere möglich, dann „Fertig".' })
}, [setIsSidebarOpen, setActiveTab, toast, ensureEditing, setDrawMode])
const handleTextPlace = useCallback((coordinates: [number, number]) => {
@@ -1008,7 +1015,7 @@ export default function AppPage() {
<div className="flex-1 flex overflow-hidden">
{/* Map view — always mounted, hidden via CSS to preserve state */}
<div data-tour="toolbar" className={`contents ${activeTab !== 'map' ? 'hidden' : ''}`}>
<div className={`contents ${activeTab !== 'map' ? 'hidden' : ''}`}>
<LeftToolbar
drawMode={drawMode || 'select'}
onDrawModeChange={handleDrawModeChange}
@@ -1038,7 +1045,7 @@ export default function AppPage() {
onDrawModeChange={setDrawMode}
undoDrawPointRef={undoDrawPointRef}
pendingSymbol={pendingSymbol ? { iconId: pendingSymbol.id, imageUrl: pendingSymbol.imageUrl } : null}
onSymbolPlaced={() => setPendingSymbol(null)}
onSymbolPlaced={() => setPlacedCount((n) => n + 1)}
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
/>
@@ -1078,7 +1085,6 @@ export default function AppPage() {
{/* Right sidebar — always visible, contains Karte/Journal tabs */}
<RightSidebar
data-tour="sidebar"
onSymbolDrop={handleSymbolDrop}
onSymbolSelect={handleSymbolSelect}
canEdit={canEdit}
@@ -1093,13 +1099,20 @@ export default function AppPage() {
/>
</div>
{/* Tap-to-place Hinweis — schwebt über der Bottom-Nav, mit Abbrechen */}
{/* Tap-to-place Hinweis — schwebt über der Bottom-Nav; mehrere setzen, dann „Fertig" */}
{pendingSymbol && (
<div className="fixed left-1/2 -translate-x-1/2 z-40 bottom-[76px] md:bottom-6 flex items-center gap-2 px-3 py-2 rounded-full bg-primary text-primary-foreground shadow-lg text-sm">
<div className="fixed left-1/2 -translate-x-1/2 z-40 bottom-[76px] md:bottom-6 flex items-center gap-2 pl-3 pr-2 py-2 rounded-full bg-primary text-primary-foreground shadow-lg text-sm">
<img src={pendingSymbol.imageUrl} alt="" className="w-5 h-5 object-contain bg-white rounded" crossOrigin="anonymous" />
<span>Auf die Karte tippen zum Platzieren</span>
<button onClick={() => setPendingSymbol(null)} className="ml-1 p-0.5 rounded-full hover:bg-white/20" title="Abbrechen">
<X className="w-4 h-4" />
<span>
{placedCount > 0
? `Auf Karte tippen · ${placedCount} gesetzt`
: 'Auf die Karte tippen zum Platzieren'}
</span>
<button
onClick={() => { setPendingSymbol(null); setPlacedCount(0) }}
className="ml-1 px-2.5 py-1 rounded-full bg-white/20 hover:bg-white/30 text-xs font-semibold"
>
Fertig
</button>
</div>
)}

View File

@@ -260,6 +260,38 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
</Section>
)}
{/* 6b. Eigene Cockpit-Module (Modul-Baukasten) */}
{Array.isArray(d.moduleTables) && d.moduleTables.map((mt: any, ti: number) => (
Array.isArray(mt.rows) && mt.rows.length > 0 ? (
<Section key={`mod-${ti}`} num="•" title={mt.name}>
<table className="w-full border-collapse border rounded text-xs">
<thead>
<tr className="bg-gray-900 text-white">
{(mt.columns || []).map((c: any) => (
<th key={c.key} className={`p-1.5 font-semibold uppercase tracking-wider text-[7pt] ${c.type === 'check' ? 'text-center w-10' : c.type === 'time' ? 'text-left w-16' : 'text-left'}`}>{c.label}</th>
))}
</tr>
</thead>
<tbody>
{mt.rows.map((row: any, ri: number) => (
<tr key={ri} className={ri % 2 === 1 ? 'bg-gray-50' : ''}>
{(mt.columns || []).map((c: any) => (
<td key={c.key} className={`p-1.5 border-b border-gray-100 ${c.type === 'check' ? 'text-center font-bold' : ''} ${c.type === 'time' ? 'font-mono text-[8pt] text-gray-500' : ''}`}>
{c.type === 'check'
? (row[c.key] ? '✓' : '—')
: c.type === 'time'
? formatCellTime(row[c.key] || row._createdAt)
: (row[c.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</Section>
) : null
))}
{/* 7. Eingesetzte Mittel */}
{d.fahrzeuge?.length > 0 && (
<Section num="7" title="Eingesetzte Mittel">
@@ -330,6 +362,16 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
)
}
// Zeit-Zellen der Modul-Tabellen: ISO-Datum → HH:MM, sonst Rohwert
function formatCellTime(value: any): string {
if (!value) return ''
const d = new Date(value)
if (!isNaN(d.getTime()) && typeof value === 'string' && value.includes('T')) {
return d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
}
return String(value)
}
function Section({ num, title, children }: { num: string; title: string; children: React.ReactNode }) {
return (
<div className="mb-4">

View File

@@ -4,6 +4,9 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { getSocket } from '@/lib/socket'
import { TableModule } from '@/components/journal/modules/table-module'
import type { CockpitModule } from '@/lib/modules'
import {
GraduationCap,
Plus,
@@ -15,6 +18,7 @@ import {
Trash2,
ClipboardCheck,
Loader2,
Blocks,
} from 'lucide-react'
type GoalStatus = 'OPEN' | 'REACHED' | 'PARTIAL' | 'MISSED'
@@ -74,6 +78,24 @@ export function ExerciseCockpit({ projectId, projectTitle, canEdit, initialEvalu
const evalDebounce = useRef<NodeJS.Timeout | null>(null)
const { toast } = useToast()
// Modul-Baukasten: dieselben eigenen Tabellen-Module wie im Einsatz
const [tableModules, setTableModules] = useState<CockpitModule[]>([])
useEffect(() => {
fetch('/api/tenant/modules')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.modules) {
setTableModules(data.modules.filter((m: CockpitModule) => m.enabled && m.type === 'table' && m.columns?.length))
}
})
.catch(() => {})
}, [])
const notifyModuleChanged = useCallback(() => {
if (!projectId) return
try { getSocket().emit('journal-updated', { projectId }) } catch { /* offline */ }
}, [projectId])
// Ziele laden
const loadGoals = useCallback(async () => {
if (!projectId) {
@@ -299,6 +321,19 @@ export function ExerciseCockpit({ projectId, projectTitle, canEdit, initialEvalu
/>
<p className="text-xs text-muted-foreground mt-1">Wird automatisch gespeichert.</p>
</section>
{/* Eigene Cockpit-Module (Modul-Baukasten) — z.B. Atemschutz, Kräfte vor Ort */}
{projectId && tableModules.map((mod) => (
<section key={mod.id}>
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3 text-foreground/80">
<Blocks className="w-4 h-4 text-blue-600" />
{mod.name}
</h3>
<div className="border rounded-lg bg-white dark:bg-card overflow-hidden">
<TableModule projectId={projectId} module={mod} canEdit={canEdit} notifyChanged={notifyModuleChanged} />
</div>
</section>
))}
</div>
</div>
)

View File

@@ -414,6 +414,75 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
window.print()
}, [])
// Rapport öffnen: Journal + SOMA + Pendenzen UND die eigenen Tabellen-Module
// einsammeln, damit alles im PDF-Rapport erscheint.
const [rapportLoading, setRapportLoading] = useState(false)
const handleOpenRapport = useCallback(async () => {
if (!projectId) return
setRapportLoading(true)
try {
// Daten der aktiven eigenen Module (type 'table') laden
const tableModules = modules.filter(m => m.enabled && m.type === 'table' && m.columns?.length)
const moduleTables = await Promise.all(tableModules.map(async (mod) => {
try {
const res = await fetch(`/api/projects/${projectId}/modules/${mod.id}/items`)
const data = res.ok ? await res.json() : { items: [] }
return {
name: mod.name,
columns: mod.columns,
rows: (data.items || []).map((it: any) => ({ ...it.data, _createdAt: it.createdAt })),
}
} catch {
return { name: mod.name, columns: mod.columns, rows: [] }
}
}))
const now = new Date()
setRapportForm({
organisation: tenantName || '',
abteilung: '',
datum: now.toLocaleDateString('de-CH'),
uhrzeit: now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }),
einsatzNr: einsatzNr || '',
alarmzeit: entries.length > 0 ? formatTime(entries[0].time) : '',
prioritaet: '',
einsatzort: projectLocation || '',
koordinaten: '',
objekt: '',
alarmierungsart: '',
stichwort: projectTitle || '',
zeitAlarm: entries.length > 0 ? formatTime(entries[0].time) : '',
zeitAusruecken: '', zeitEintreffen: '', zeitBereit: '',
zeitKontrolle: '', zeitAus: '', zeitEinruecken: '', zeitEnde: '',
lageEintreffen: '',
massnahmen: entries.map(e => `${formatTime(e.time)} ${e.what}${e.who ? ` (${e.who})` : ''}`),
somaItems: checkItems.map(c => ({
label: c.label,
confirmed: c.confirmed,
ok: c.ok,
confirmedAt: c.confirmedAt ? formatTime(c.confirmedAt) : null,
})),
pendenzenItems: pendenzen.map(p => ({
what: p.what,
who: p.who || '',
whenHow: p.whenHow || '',
done: p.done,
doneAt: p.doneAt ? formatTime(p.doneAt) : null,
})),
moduleTables,
fahrzeuge: [] as any[],
bemerkungen: '',
einsatzleiter: einsatzleiter || '',
rapporteur: journalfuehrer || '',
reportNumber: '',
logoUrl: tenantLogoUrl || '',
})
setShowRapportDialog(true)
} finally {
setRapportLoading(false)
}
}, [projectId, modules, tenantName, einsatzNr, entries, projectLocation, projectTitle, checkItems, pendenzen, einsatzleiter, journalfuehrer, tenantLogoUrl])
if (!projectId) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
@@ -456,53 +525,10 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
<Button
variant="outline"
size="sm"
disabled={!projectId}
onClick={() => {
if (!projectId) return
const now = new Date()
// Pre-fill form with available data
setRapportForm({
organisation: tenantName || '',
abteilung: '',
datum: now.toLocaleDateString('de-CH'),
uhrzeit: now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }),
einsatzNr: einsatzNr || '',
alarmzeit: entries.length > 0 ? formatTime(entries[0].time) : '',
prioritaet: '',
einsatzort: projectLocation || '',
koordinaten: '',
objekt: '',
alarmierungsart: '',
stichwort: projectTitle || '',
zeitAlarm: entries.length > 0 ? formatTime(entries[0].time) : '',
zeitAusruecken: '', zeitEintreffen: '', zeitBereit: '',
zeitKontrolle: '', zeitAus: '', zeitEinruecken: '', zeitEnde: '',
lageEintreffen: '',
massnahmen: entries.map(e => `${formatTime(e.time)} ${e.what}${e.who ? ` (${e.who})` : ''}`),
somaItems: checkItems.map(c => ({
label: c.label,
confirmed: c.confirmed,
ok: c.ok,
confirmedAt: c.confirmedAt ? formatTime(c.confirmedAt) : null,
})),
pendenzenItems: pendenzen.map(p => ({
what: p.what,
who: p.who || '',
whenHow: p.whenHow || '',
done: p.done,
doneAt: p.doneAt ? formatTime(p.doneAt) : null,
})),
fahrzeuge: [] as any[],
bemerkungen: '',
einsatzleiter: einsatzleiter || '',
rapporteur: journalfuehrer || '',
reportNumber: '',
logoUrl: tenantLogoUrl || '',
})
setShowRapportDialog(true)
}}
disabled={!projectId || rapportLoading}
onClick={handleOpenRapport}
>
<FileText className="w-4 h-4 mr-1.5" />
{rapportLoading ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <FileText className="w-4 h-4 mr-1.5" />}
Rapport
</Button>
<Button variant="outline" size="sm" onClick={() => setShowSendDialog(!showSendDialog)}>

View File

@@ -235,6 +235,24 @@ export function RapportDialog({
</div>
</div>
)}
{/* Eigene Cockpit-Module (read-only, aus dem Journal) */}
{Array.isArray(rapportForm.moduleTables) && rapportForm.moduleTables.filter((mt: any) => mt.rows?.length > 0).map((mt: any, i: number) => (
<div key={i}>
<label className="text-xs font-semibold text-muted-foreground uppercase">{mt.name}</label>
<div className="border rounded-md p-2 bg-muted text-sm max-h-32 overflow-auto">
{mt.rows.map((row: any, ri: number) => (
<div key={ri} className="py-0.5 flex flex-wrap gap-x-3 text-xs">
{(mt.columns || []).filter((c: any) => c.type !== 'time').map((c: any) => (
<span key={c.key} className="text-muted-foreground">
<span className="font-medium text-foreground">{c.label}:</span>{' '}
{c.type === 'check' ? (row[c.key] ? '✓' : '—') : (row[c.key] || '—')}
</span>
))}
</div>
))}
</div>
</div>
))}
{/* Bemerkungen */}
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Bemerkungen</label>

View File

@@ -75,7 +75,7 @@ export function LeftToolbar({
}: LeftToolbarProps) {
return (
<TooltipProvider delayDuration={300}>
<aside className="w-14 lg:w-20 border-r border-border bg-card flex flex-col items-center py-1.5 lg:py-2 shrink-0 overflow-y-auto overflow-x-hidden z-10">
<aside data-tour="toolbar" className="w-14 lg:w-20 border-r border-border bg-card flex flex-col items-center py-1.5 lg:py-2 shrink-0 overflow-y-auto overflow-x-hidden z-10">
{/* Draw Tools — Touch-Ziele mind. 44px auf Mobil/Tablet */}
<div className="flex flex-col gap-0.5">
{drawTools.map((tool) => (

View File

@@ -261,7 +261,7 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
</aside>
)}
<aside className={`
<aside data-tour="sidebar" className={`
w-72 md:w-48 lg:w-56 xl:w-72 border-l border-border bg-card flex flex-col shrink-0
md:relative md:translate-x-0 md:z-auto
fixed right-0 top-0 bottom-0 z-50 transition-transform duration-200

View File

@@ -706,16 +706,34 @@ export function MapView({
map.current.addControl(new maplibregl.NavigationControl(), 'bottom-right')
map.current.addControl(new maplibregl.ScaleControl(), 'bottom-left')
// Geolocation: center map on user's position
// Geolocation: eigene Position dauerhaft anzeigen (Punkt + Genauigkeit + Blickrichtung)
const geolocate = new maplibregl.GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: true,
showAccuracyCircle: false,
})
showAccuracyCircle: true,
// showUserHeading zeigt die Blickrichtung — in den Typen dieser Version (noch)
// nicht deklariert, zur Laufzeit aber vorhanden.
showUserHeading: true,
} as any)
map.current.addControl(geolocate, 'bottom-right')
// Auto-trigger geolocation when map loads to center on user's real position
map.current.on('load', () => { geolocate.trigger() })
// Position beim Laden aktivieren (Punkt erscheint), aber bei gesetztem Einsatzort
// auf dem Einsatzort bleiben statt auf die eigene Position zu springen.
const einsatzortCenter = project?.mapCenter
const hasEinsatzort = !!einsatzortCenter &&
!(einsatzortCenter.lng === 8.5417 && einsatzortCenter.lat === 47.3769)
map.current.on('load', () => {
geolocate.trigger()
if (hasEinsatzort && einsatzortCenter) {
geolocate.once('geolocate', () => {
map.current?.easeTo({
center: [einsatzortCenter.lng, einsatzortCenter.lat],
zoom: project?.mapZoom || 17,
duration: 600,
})
})
}
})
map.current.on('load', () => {
const m = map.current

View File

@@ -137,10 +137,20 @@ export function OnboardingTour({ forceShow = false, onComplete }: OnboardingTour
if (step.targetSelector) {
const el = document.querySelector(step.targetSelector)
if (el) {
setHighlightRect(el.getBoundingClientRect())
const rect = el.getBoundingClientRect()
// Nur hervorheben, wenn das Element echte Grösse hat UND im Viewport sichtbar
// ist. Fängt display:contents (0×0), ausgeblendete oder off-canvas-Elemente
// (z.B. Sidebar auf dem Handy) ab → Tooltip zentriert statt kaputte Box.
const visible =
rect.width > 4 && rect.height > 4 &&
rect.bottom > 0 && rect.right > 0 &&
rect.top < window.innerHeight && rect.left < window.innerWidth
if (visible) {
setHighlightRect(rect)
return
}
}
}
setHighlightRect(null)
}, [currentStep])
@@ -244,7 +254,7 @@ export function OnboardingTour({ forceShow = false, onComplete }: OnboardingTour
{/* Tooltip card */}
<div
className="z-[100000] w-[340px] bg-card border border-border rounded-xl shadow-2xl p-5"
className="z-[100000] w-[340px] max-w-[calc(100vw-32px)] bg-card border border-border rounded-xl shadow-2xl p-5"
style={getTooltipStyle()}
>
<div className="flex items-start justify-between mb-2">