Compare commits
5 Commits
03914083df
...
e9e8f3fa8e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9e8f3fa8e | ||
|
|
a4d0a10794 | ||
|
|
e02ea21c93 | ||
|
|
0a6c9cbcf2 | ||
|
|
bbf6fbdd13 |
49
docs/TESTPLAN.md
Normal file
49
docs/TESTPLAN.md
Normal 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._
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -137,8 +137,18 @@ export function OnboardingTour({ forceShow = false, onComplete }: OnboardingTour
|
||||
if (step.targetSelector) {
|
||||
const el = document.querySelector(step.targetSelector)
|
||||
if (el) {
|
||||
setHighlightRect(el.getBoundingClientRect())
|
||||
return
|
||||
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)
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user