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",
|
"name": "lageplan",
|
||||||
"version": "1.5.3",
|
"version": "1.5.8",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -25,9 +25,15 @@ export async function PUT(
|
|||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const patch = body && typeof body.data === 'object' && body.data !== null ? body.data : {}
|
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({
|
const item = await (prisma as any).moduleItem.update({
|
||||||
where: { id: itemId },
|
where: { id: itemId },
|
||||||
data: { data: { ...(existing.data || {}), ...patch } },
|
data: { data: merged },
|
||||||
})
|
})
|
||||||
return NextResponse.json(item)
|
return NextResponse.json(item)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ export async function POST(
|
|||||||
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 })
|
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)
|
const project = await getProjectWithTenantCheck(id, user)
|
||||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const data = body && typeof body.data === 'object' && body.data !== null ? body.data : {}
|
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 count = await (prisma as any).moduleItem.count({ where: { projectId: id, moduleId } })
|
||||||
const item = await (prisma as any).moduleItem.create({
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { JournalView } from '@/components/journal/journal-view'
|
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 { CustomDragLayer } from '@/components/map/custom-drag-layer'
|
||||||
import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour'
|
import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour'
|
||||||
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
|
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
|
||||||
@@ -51,6 +51,8 @@ export default function AppPage() {
|
|||||||
const [isEndConfirmOpen, setIsEndConfirmOpen] = useState(false)
|
const [isEndConfirmOpen, setIsEndConfirmOpen] = useState(false)
|
||||||
// Tap-to-place: angetipptes Symbol wartet auf Platzierung per Karten-Tipp (Mobil)
|
// Tap-to-place: angetipptes Symbol wartet auf Platzierung per Karten-Tipp (Mobil)
|
||||||
const [pendingSymbol, setPendingSymbol] = useState<{ id: string; imageUrl: string } | null>(null)
|
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 [isSaving, setIsSaving] = useState(false)
|
||||||
const [isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen] = useState(false)
|
const [isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen] = useState(false)
|
||||||
const [isFullscreen, setIsFullscreen] = 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)
|
// Werkzeugwechsel auf ein Zeichen-Tool übernimmt vorab den Lock (rechtzeitig vor dem Zeichnen)
|
||||||
const handleDrawModeChange = useCallback((mode: DrawMode) => {
|
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)
|
setDrawMode(mode)
|
||||||
}, [ensureEditing, setDrawMode])
|
}, [ensureEditing, setDrawMode])
|
||||||
|
|
||||||
@@ -769,10 +775,11 @@ export default function AppPage() {
|
|||||||
const handleSymbolSelect = useCallback((symbol: { id: string; imageUrl: string }) => {
|
const handleSymbolSelect = useCallback((symbol: { id: string; imageUrl: string }) => {
|
||||||
ensureEditing() // Lock schon beim Auswählen übernehmen, damit das Platzieren sofort greift
|
ensureEditing() // Lock schon beim Auswählen übernehmen, damit das Platzieren sofort greift
|
||||||
setPendingSymbol(symbol)
|
setPendingSymbol(symbol)
|
||||||
|
setPlacedCount(0)
|
||||||
setIsSidebarOpen(false)
|
setIsSidebarOpen(false)
|
||||||
setActiveTab('map')
|
setActiveTab('map')
|
||||||
setDrawMode('select')
|
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])
|
}, [setIsSidebarOpen, setActiveTab, toast, ensureEditing, setDrawMode])
|
||||||
|
|
||||||
const handleTextPlace = useCallback((coordinates: [number, number]) => {
|
const handleTextPlace = useCallback((coordinates: [number, number]) => {
|
||||||
@@ -1008,7 +1015,7 @@ export default function AppPage() {
|
|||||||
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* Map view — always mounted, hidden via CSS to preserve state */}
|
{/* 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
|
<LeftToolbar
|
||||||
drawMode={drawMode || 'select'}
|
drawMode={drawMode || 'select'}
|
||||||
onDrawModeChange={handleDrawModeChange}
|
onDrawModeChange={handleDrawModeChange}
|
||||||
@@ -1038,7 +1045,7 @@ export default function AppPage() {
|
|||||||
onDrawModeChange={setDrawMode}
|
onDrawModeChange={setDrawMode}
|
||||||
undoDrawPointRef={undoDrawPointRef}
|
undoDrawPointRef={undoDrawPointRef}
|
||||||
pendingSymbol={pendingSymbol ? { iconId: pendingSymbol.id, imageUrl: pendingSymbol.imageUrl } : null}
|
pendingSymbol={pendingSymbol ? { iconId: pendingSymbol.id, imageUrl: pendingSymbol.imageUrl } : null}
|
||||||
onSymbolPlaced={() => setPendingSymbol(null)}
|
onSymbolPlaced={() => setPlacedCount((n) => n + 1)}
|
||||||
isFullscreen={isFullscreen}
|
isFullscreen={isFullscreen}
|
||||||
onToggleFullscreen={toggleFullscreen}
|
onToggleFullscreen={toggleFullscreen}
|
||||||
/>
|
/>
|
||||||
@@ -1078,7 +1085,6 @@ export default function AppPage() {
|
|||||||
|
|
||||||
{/* Right sidebar — always visible, contains Karte/Journal tabs */}
|
{/* Right sidebar — always visible, contains Karte/Journal tabs */}
|
||||||
<RightSidebar
|
<RightSidebar
|
||||||
data-tour="sidebar"
|
|
||||||
onSymbolDrop={handleSymbolDrop}
|
onSymbolDrop={handleSymbolDrop}
|
||||||
onSymbolSelect={handleSymbolSelect}
|
onSymbolSelect={handleSymbolSelect}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
@@ -1093,13 +1099,20 @@ export default function AppPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{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" />
|
<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>
|
<span>
|
||||||
<button onClick={() => setPendingSymbol(null)} className="ml-1 p-0.5 rounded-full hover:bg-white/20" title="Abbrechen">
|
{placedCount > 0
|
||||||
<X className="w-4 h-4" />
|
? `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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -260,6 +260,38 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
|
|||||||
</Section>
|
</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 */}
|
{/* 7. Eingesetzte Mittel */}
|
||||||
{d.fahrzeuge?.length > 0 && (
|
{d.fahrzeuge?.length > 0 && (
|
||||||
<Section num="7" title="Eingesetzte Mittel">
|
<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 }) {
|
function Section({ num, title, children }: { num: string; title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { useState, useEffect, useRef, useCallback } 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 { useToast } from '@/components/ui/use-toast'
|
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 {
|
import {
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -15,6 +18,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Blocks,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
type GoalStatus = 'OPEN' | 'REACHED' | 'PARTIAL' | 'MISSED'
|
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 evalDebounce = useRef<NodeJS.Timeout | null>(null)
|
||||||
const { toast } = useToast()
|
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
|
// Ziele laden
|
||||||
const loadGoals = useCallback(async () => {
|
const loadGoals = useCallback(async () => {
|
||||||
if (!projectId) {
|
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>
|
<p className="text-xs text-muted-foreground mt-1">Wird automatisch gespeichert.</p>
|
||||||
</section>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -414,6 +414,75 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
|||||||
window.print()
|
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) {
|
if (!projectId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!projectId}
|
disabled={!projectId || rapportLoading}
|
||||||
onClick={() => {
|
onClick={handleOpenRapport}
|
||||||
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)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<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
|
Rapport
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowSendDialog(!showSendDialog)}>
|
<Button variant="outline" size="sm" onClick={() => setShowSendDialog(!showSendDialog)}>
|
||||||
|
|||||||
@@ -235,6 +235,24 @@ export function RapportDialog({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Bemerkungen */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Bemerkungen</label>
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Bemerkungen</label>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function LeftToolbar({
|
|||||||
}: LeftToolbarProps) {
|
}: LeftToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={300}>
|
<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 */}
|
{/* Draw Tools — Touch-Ziele mind. 44px auf Mobil/Tablet */}
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
{drawTools.map((tool) => (
|
{drawTools.map((tool) => (
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
|
|||||||
</aside>
|
</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
|
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
|
md:relative md:translate-x-0 md:z-auto
|
||||||
fixed right-0 top-0 bottom-0 z-50 transition-transform duration-200
|
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.NavigationControl(), 'bottom-right')
|
||||||
map.current.addControl(new maplibregl.ScaleControl(), 'bottom-left')
|
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({
|
const geolocate = new maplibregl.GeolocateControl({
|
||||||
positionOptions: { enableHighAccuracy: true },
|
positionOptions: { enableHighAccuracy: true },
|
||||||
trackUserLocation: 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')
|
map.current.addControl(geolocate, 'bottom-right')
|
||||||
|
|
||||||
// Auto-trigger geolocation when map loads to center on user's real position
|
// Position beim Laden aktivieren (Punkt erscheint), aber bei gesetztem Einsatzort
|
||||||
map.current.on('load', () => { geolocate.trigger() })
|
// 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', () => {
|
map.current.on('load', () => {
|
||||||
const m = map.current
|
const m = map.current
|
||||||
|
|||||||
@@ -137,10 +137,20 @@ export function OnboardingTour({ forceShow = false, onComplete }: OnboardingTour
|
|||||||
if (step.targetSelector) {
|
if (step.targetSelector) {
|
||||||
const el = document.querySelector(step.targetSelector)
|
const el = document.querySelector(step.targetSelector)
|
||||||
if (el) {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
setHighlightRect(null)
|
setHighlightRect(null)
|
||||||
}, [currentStep])
|
}, [currentStep])
|
||||||
|
|
||||||
@@ -244,7 +254,7 @@ export function OnboardingTour({ forceShow = false, onComplete }: OnboardingTour
|
|||||||
|
|
||||||
{/* Tooltip card */}
|
{/* Tooltip card */}
|
||||||
<div
|
<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()}
|
style={getTooltipStyle()}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user