- Refactoring: Error Boundaries, apiFetch Wrapper, Socket Status-Tracking - Refactoring: UI Kontrast (theme-aware colors), unused imports bereinigt - Symbol-Verwaltung: Neues Split-Panel (Meine Symbole + Bibliothek) - Symbol-Verwaltung: Umbenennen (TLF rot/blau), Duplikate erlaubt - Symbol-Verwaltung: Karten-Sidebar zeigt eigene Symbole bevorzugt - Schlauch-Labels: Groessere Schrift (13px/10px), verschiebbar (Drag) - Schema: TenantSymbol customName, sortOrder, unique constraint entfernt - Open Source Referenz entfernt (kostenloses Projekt)
237 lines
11 KiB
TypeScript
237 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useState, MutableRefObject } from 'react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { FileText, Loader2 } from 'lucide-react'
|
|
|
|
interface RapportDialogProps {
|
|
projectId: string
|
|
rapportForm: Record<string, any>
|
|
setRapportForm: React.Dispatch<React.SetStateAction<Record<string, any>>>
|
|
mapRef?: MutableRefObject<any>
|
|
mapScreenshot?: string
|
|
onClose: () => void
|
|
onRapportCreated: (link: string) => void
|
|
}
|
|
|
|
export function RapportDialog({
|
|
projectId,
|
|
rapportForm,
|
|
setRapportForm,
|
|
mapRef,
|
|
mapScreenshot: preCapuredScreenshot,
|
|
onClose,
|
|
onRapportCreated,
|
|
}: RapportDialogProps) {
|
|
const [creatingRapport, setCreatingRapport] = useState(false)
|
|
|
|
const handleCreate = async () => {
|
|
if (!projectId) return
|
|
setCreatingRapport(true)
|
|
try {
|
|
// Capture map screenshot — compress to JPEG and resize for smaller payload
|
|
let mapScreenshot = ''
|
|
const rawScreenshot = preCapuredScreenshot || ''
|
|
if (!rawScreenshot) {
|
|
try {
|
|
if (mapRef?.current) {
|
|
const canvas = mapRef.current.getCanvas()
|
|
if (canvas) {
|
|
// Resize to max 2400px wide and convert to JPEG
|
|
const maxW = 2400
|
|
const ratio = Math.min(1, maxW / canvas.width)
|
|
const offscreen = document.createElement('canvas')
|
|
offscreen.width = Math.round(canvas.width * ratio)
|
|
offscreen.height = Math.round(canvas.height * ratio)
|
|
const ctx = offscreen.getContext('2d')
|
|
if (ctx) {
|
|
ctx.drawImage(canvas, 0, 0, offscreen.width, offscreen.height)
|
|
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
|
|
}
|
|
}
|
|
}
|
|
} catch (e) { console.warn('Map screenshot failed:', e) }
|
|
} else if (rawScreenshot.length > 800000) {
|
|
// Compress pre-captured screenshot if too large
|
|
try {
|
|
const img = new Image()
|
|
img.src = rawScreenshot
|
|
await new Promise(r => { img.onload = r; img.onerror = r })
|
|
const maxW = 2400
|
|
const ratio = Math.min(1, maxW / img.naturalWidth)
|
|
const offscreen = document.createElement('canvas')
|
|
offscreen.width = Math.round(img.naturalWidth * ratio)
|
|
offscreen.height = Math.round(img.naturalHeight * ratio)
|
|
const ctx = offscreen.getContext('2d')
|
|
if (ctx) {
|
|
ctx.drawImage(img, 0, 0, offscreen.width, offscreen.height)
|
|
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
|
|
}
|
|
} catch { mapScreenshot = rawScreenshot }
|
|
} else {
|
|
mapScreenshot = rawScreenshot
|
|
}
|
|
// Convert logo URL to base64 for PDF rendering
|
|
let logoDataUri = ''
|
|
if (rapportForm.logoUrl) {
|
|
try {
|
|
const logoRes = await fetch(rapportForm.logoUrl)
|
|
if (logoRes.ok) {
|
|
const blob = await logoRes.blob()
|
|
logoDataUri = await new Promise<string>((resolve) => {
|
|
const reader = new FileReader()
|
|
reader.onloadend = () => resolve(reader.result as string)
|
|
reader.readAsDataURL(blob)
|
|
})
|
|
}
|
|
} catch (e) { console.warn('Logo fetch failed:', e) }
|
|
}
|
|
const rapportData = { ...rapportForm, mapScreenshot, logoUrl: logoDataUri || rapportForm.logoUrl }
|
|
console.log('[Rapport] Sending request, body size ~', JSON.stringify({ projectId, data: rapportData }).length, 'bytes')
|
|
const res = await fetch('/api/rapports', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ projectId, data: rapportData }),
|
|
})
|
|
if (res.ok) {
|
|
const result = await res.json()
|
|
onRapportCreated(`/rapport/${result.token}`)
|
|
onClose()
|
|
window.open(`/rapport/${result.token}`, '_blank')
|
|
} else {
|
|
const errData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
|
console.error('[Rapport] API error:', res.status, errData)
|
|
alert(`Rapport-Fehler: ${errData.error || 'Unbekannter Fehler (Status ' + res.status + ')'}`)
|
|
}
|
|
} catch (err: any) {
|
|
console.error('Error creating rapport:', err)
|
|
alert('Rapport-Fehler: ' + (err?.message || 'Netzwerkfehler'))
|
|
} finally {
|
|
setCreatingRapport(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center overflow-auto py-8 print:hidden">
|
|
<div className="bg-card rounded-lg shadow-2xl w-full max-w-2xl mx-4">
|
|
<div className="flex items-center justify-between p-4 border-b">
|
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
|
<FileText className="w-5 h-5" />
|
|
Einsatzrapport erstellen
|
|
</h3>
|
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground text-xl leading-none">×</button>
|
|
</div>
|
|
<div className="p-4 space-y-4 max-h-[70vh] overflow-auto">
|
|
{/* Organisation */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Organisation</label>
|
|
<Input value={rapportForm.organisation || ''} onChange={e => setRapportForm(f => ({ ...f, organisation: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Abteilung</label>
|
|
<Input value={rapportForm.abteilung || ''} onChange={e => setRapportForm(f => ({ ...f, abteilung: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
{/* Einsatzdaten */}
|
|
<div className="grid grid-cols-4 gap-3">
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Datum</label>
|
|
<Input value={rapportForm.datum || ''} onChange={e => setRapportForm(f => ({ ...f, datum: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Uhrzeit</label>
|
|
<Input value={rapportForm.uhrzeit || ''} onChange={e => setRapportForm(f => ({ ...f, uhrzeit: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Einsatz-Nr.</label>
|
|
<Input value={rapportForm.einsatzNr || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzNr: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Priorität</label>
|
|
<Input value={rapportForm.prioritaet || ''} onChange={e => setRapportForm(f => ({ ...f, prioritaet: e.target.value }))} placeholder="z.B. Hoch" />
|
|
</div>
|
|
</div>
|
|
{/* Ort */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Einsatzort / Adresse</label>
|
|
<Input value={rapportForm.einsatzort || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzort: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Objekt / Gebäude</label>
|
|
<Input value={rapportForm.objekt || ''} onChange={e => setRapportForm(f => ({ ...f, objekt: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Alarmierungsart</label>
|
|
<Input value={rapportForm.alarmierungsart || ''} onChange={e => setRapportForm(f => ({ ...f, alarmierungsart: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Stichwort / Meldebild</label>
|
|
<Input value={rapportForm.stichwort || ''} onChange={e => setRapportForm(f => ({ ...f, stichwort: e.target.value }))} />
|
|
</div>
|
|
{/* Zeitverlauf */}
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase mb-1 block">Zeitverlauf</label>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-[10px] text-muted-foreground">Alarm</label>
|
|
<Input type="time" className="text-sm h-8" value={rapportForm.zeitAlarm || ''} onChange={e => setRapportForm(f => ({ ...f, zeitAlarm: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] text-muted-foreground">Eintreffen</label>
|
|
<Input type="time" className="text-sm h-8" value={rapportForm.zeitEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, zeitEintreffen: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Lagebild */}
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Lage bei Eintreffen</label>
|
|
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.lageEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, lageEintreffen: e.target.value }))} />
|
|
</div>
|
|
{/* Massnahmen (read-only, from journal) */}
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Massnahmen (aus Journal)</label>
|
|
<div className="border rounded-md p-2 bg-muted text-sm max-h-32 overflow-auto">
|
|
{Array.isArray(rapportForm.massnahmen) && rapportForm.massnahmen.length > 0 ? (
|
|
rapportForm.massnahmen.map((m: string, i: number) => <div key={i} className="py-0.5">• {m}</div>)
|
|
) : <span className="text-muted-foreground">Keine Einträge</span>}
|
|
</div>
|
|
</div>
|
|
{/* Bemerkungen */}
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Bemerkungen</label>
|
|
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.bemerkungen || ''} onChange={e => setRapportForm(f => ({ ...f, bemerkungen: e.target.value }))} />
|
|
</div>
|
|
{/* Unterschriften */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Einsatzleiter/in</label>
|
|
<Input value={rapportForm.einsatzleiter || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzleiter: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Rapporteur</label>
|
|
<Input value={rapportForm.rapporteur || ''} onChange={e => setRapportForm(f => ({ ...f, rapporteur: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-3 p-4 border-t">
|
|
<Button variant="outline" size="sm" onClick={onClose}>Abbrechen</Button>
|
|
<Button
|
|
size="sm"
|
|
disabled={creatingRapport}
|
|
onClick={handleCreate}
|
|
>
|
|
{creatingRapport ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <FileText className="w-4 h-4 mr-1.5" />}
|
|
Rapport generieren
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|