v1.3.0: Refactoring Phase 3+4, Symbol-Verwaltung Redesign, Schlauch-Labels Fix
- 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)
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
AlertTriangle, ClipboardList, Loader2, Printer, Pencil, Send, FileText,
|
||||
} from 'lucide-react'
|
||||
import { getSocket } from '@/lib/socket'
|
||||
import { RapportDialog } from '@/components/journal/rapport-dialog'
|
||||
|
||||
interface JournalEntry {
|
||||
id: string
|
||||
@@ -86,7 +87,6 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Rapport creation
|
||||
const [creatingRapport, setCreatingRapport] = useState(false)
|
||||
const [lastRapportLink, setLastRapportLink] = useState<string | null>(null)
|
||||
const [showRapportDialog, setShowRapportDialog] = useState(false)
|
||||
const [rapportForm, setRapportForm] = useState<Record<string, any>>({})
|
||||
@@ -895,210 +895,16 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
|
||||
</div>
|
||||
|
||||
{/* Rapport Dialog */}
|
||||
{showRapportDialog && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center overflow-auto py-8 print:hidden">
|
||||
<div className="bg-white dark:bg-gray-900 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={() => setShowRapportDialog(false)} className="text-gray-400 hover:text-gray-600 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-gray-500 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-gray-500 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-gray-500 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-gray-500 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-gray-500 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-gray-500 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-gray-500 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-gray-500 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-gray-500 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-gray-500 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-gray-500 uppercase mb-1 block">Zeitverlauf</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-400">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-gray-400">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-gray-500 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-gray-500 uppercase">Massnahmen (aus Journal)</label>
|
||||
<div className="border rounded-md p-2 bg-gray-50 dark:bg-gray-800 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-gray-400">Keine Einträge</span>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Bemerkungen */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 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-gray-500 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-gray-500 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={() => setShowRapportDialog(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={creatingRapport}
|
||||
onClick={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()
|
||||
setLastRapportLink(`/rapport/${result.token}`)
|
||||
setShowRapportDialog(false)
|
||||
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)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
{showRapportDialog && projectId && (
|
||||
<RapportDialog
|
||||
projectId={projectId}
|
||||
rapportForm={rapportForm}
|
||||
setRapportForm={setRapportForm}
|
||||
mapRef={mapRef}
|
||||
mapScreenshot={preCapuredScreenshot}
|
||||
onClose={() => setShowRapportDialog(false)}
|
||||
onRapportCreated={(link) => setLastRapportLink(link)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user