- SOMA-Init atomar: pg_advisory_xact_lock pro Projekt in Transaktion +
createMany → keine doppelten SOMA-Listen mehr, wenn zwei Geräte denselben
frischen Einsatz gleichzeitig öffnen. initCheckItems meldet Änderung an
andere Clients (journal-refresh).
- Rapport: korrigierte (durchgestrichene) Original-Einträge werden nicht mehr
als gültige Massnahmen übernommen; Erledigt-Status ("— erledigt HH:MM")
erscheint jetzt im Rapport; SOMA zeigt okAt, falls confirmedAt fehlt.
- Export: dpr-Fallback auf devicePixelRatio, wenn die Karte ausgeblendet ist
(Journal-Tab) — vorher Infinity → NaN-Grössen, Export defekt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
868 lines
39 KiB
TypeScript
868 lines
39 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useCallback, useRef, MutableRefObject } from 'react'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import {
|
||
Plus, Check, Clock, CheckSquare,
|
||
AlertTriangle, ClipboardList, Loader2, Printer, Pencil, Send, FileText,
|
||
Blocks, ListChecks, Shield, Users, Radio, Table as TableIcon,
|
||
} from 'lucide-react'
|
||
import { getSocket } from '@/lib/socket'
|
||
import { RapportDialog } from '@/components/journal/rapport-dialog'
|
||
import { ModuleManagerDialog } from '@/components/journal/module-manager-dialog'
|
||
import { SomaModule } from '@/components/journal/modules/soma-module'
|
||
import { PendenzenModule } from '@/components/journal/modules/pendenzen-module'
|
||
import { TableModule } from '@/components/journal/modules/table-module'
|
||
import { DEFAULT_MODULES, type CockpitModule } from '@/lib/modules'
|
||
import { type JournalEntry, type JournalCheckItem, type JournalPendenz, formatTime } from '@/components/journal/types'
|
||
|
||
interface JournalViewProps {
|
||
projectId: string | null
|
||
projectTitle: string
|
||
projectLocation: string
|
||
mode?: 'EINSATZ' | 'UEBUNG'
|
||
einsatzleiter: string
|
||
journalfuehrer: string
|
||
canEdit: boolean
|
||
tenantId?: string | null
|
||
einsatzNr?: string
|
||
tenantName?: string
|
||
tenantLogoUrl?: string | null
|
||
mapRef?: MutableRefObject<any>
|
||
mapScreenshot?: string
|
||
}
|
||
|
||
/** Lucide-Icons für Modul-Karten (Schlüssel siehe src/lib/modules.ts) */
|
||
const MODULE_ICONS: Record<string, typeof AlertTriangle> = {
|
||
'clipboard-list': ClipboardList,
|
||
'alert-triangle': AlertTriangle,
|
||
'check-square': CheckSquare,
|
||
'list-checks': ListChecks,
|
||
'shield': Shield,
|
||
'users': Users,
|
||
'radio': Radio,
|
||
'table': TableIcon,
|
||
}
|
||
|
||
/** Karten-Hülle für Seitenspalten-Module — einheitliche rote Titelzeile */
|
||
function ModuleCard({ icon, name, count, children }: {
|
||
icon: string
|
||
name: string
|
||
count?: string
|
||
children: React.ReactNode
|
||
}) {
|
||
const Icon = MODULE_ICONS[icon] || TableIcon
|
||
return (
|
||
<div className="border-b border-border">
|
||
<div className="px-2 py-1.5 bg-stone-100 dark:bg-muted/50 border-b-2 border-red-400 dark:border-red-800">
|
||
<h3 className="font-semibold text-xs md:text-sm print:text-[10px] flex items-center gap-1 text-red-800 dark:text-red-400">
|
||
<Icon className="w-3.5 h-3.5" />
|
||
{name}
|
||
{count && <span className="text-[10px] text-muted-foreground font-normal">({count})</span>}
|
||
</h3>
|
||
</div>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function JournalView({ projectId, projectTitle, projectLocation, mode, einsatzleiter, journalfuehrer, canEdit, tenantId, einsatzNr, tenantName, tenantLogoUrl, mapRef, mapScreenshot: preCapuredScreenshot }: JournalViewProps) {
|
||
const isUebung = mode === 'UEBUNG'
|
||
const [entries, setEntries] = useState<JournalEntry[]>([])
|
||
const [checkItems, setCheckItems] = useState<JournalCheckItem[]>([])
|
||
const [pendenzen, setPendenzen] = useState<JournalPendenz[]>([])
|
||
const [isLoading, setIsLoading] = useState(false)
|
||
const initDoneRef = useRef(false)
|
||
|
||
// Modul-Baukasten: Cockpit-Konfiguration des Mandanten
|
||
const [modules, setModules] = useState<CockpitModule[]>(DEFAULT_MODULES)
|
||
const [canManageModules, setCanManageModules] = useState(false)
|
||
const [showModuleManager, setShowModuleManager] = useState(false)
|
||
|
||
// New entry form
|
||
const [newWhat, setNewWhat] = useState('')
|
||
const [newWho, setNewWho] = useState('')
|
||
|
||
const scrollRef = useRef<HTMLDivElement>(null)
|
||
|
||
// Rapport creation
|
||
const [lastRapportLink, setLastRapportLink] = useState<string | null>(null)
|
||
const [showRapportDialog, setShowRapportDialog] = useState(false)
|
||
const [rapportForm, setRapportForm] = useState<Record<string, any>>({})
|
||
|
||
// Journal suggestions (word library per tenant)
|
||
const [suggestions, setSuggestions] = useState<string[]>([])
|
||
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([])
|
||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||
const [selectedSuggestionIdx, setSelectedSuggestionIdx] = useState(-1)
|
||
const suggestionsRef = useRef<HTMLDivElement>(null)
|
||
|
||
// Smart filter: prioritize startsWith, then includes, max 5
|
||
const filterSuggestions = useCallback((val: string) => {
|
||
if (!val || val.length < 1 || suggestions.length === 0) {
|
||
setShowSuggestions(false)
|
||
setFilteredSuggestions([])
|
||
setSelectedSuggestionIdx(-1)
|
||
return
|
||
}
|
||
const lower = val.toLowerCase()
|
||
const starts = suggestions.filter(s => s.toLowerCase().startsWith(lower))
|
||
const includes = suggestions.filter(s => !s.toLowerCase().startsWith(lower) && s.toLowerCase().includes(lower))
|
||
const combined = [...starts, ...includes].slice(0, 5)
|
||
setFilteredSuggestions(combined)
|
||
setShowSuggestions(combined.length > 0)
|
||
setSelectedSuggestionIdx(-1)
|
||
}, [suggestions])
|
||
|
||
// Notify other clients about journal changes via Socket.io
|
||
const notifyJournalChanged = useCallback(() => {
|
||
if (!projectId) return
|
||
try {
|
||
const socket = getSocket()
|
||
socket.emit('journal-updated', { projectId })
|
||
} catch (e) {
|
||
// Socket not available, ignore
|
||
}
|
||
}, [projectId])
|
||
|
||
// Load journal data
|
||
const loadJournal = useCallback(async () => {
|
||
if (!projectId) return
|
||
setIsLoading(true)
|
||
try {
|
||
const res = await fetch(`/api/projects/${projectId}/journal`)
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setEntries(data.entries || [])
|
||
setCheckItems(data.checkItems || [])
|
||
setPendenzen(data.pendenzen || [])
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load journal:', err)
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}, [projectId])
|
||
|
||
// Cockpit-Modul-Konfiguration laden
|
||
useEffect(() => {
|
||
fetch('/api/tenant/modules')
|
||
.then(r => r.ok ? r.json() : null)
|
||
.then(data => {
|
||
if (data?.modules) setModules(data.modules)
|
||
if (data) setCanManageModules(!!data.canManage)
|
||
})
|
||
.catch(() => {})
|
||
}, [tenantId])
|
||
|
||
// Init check items from templates if none exist (guarded against double-call)
|
||
const initCheckItems = useCallback(async () => {
|
||
if (!projectId || initDoneRef.current) return
|
||
initDoneRef.current = true
|
||
try {
|
||
const res = await fetch(`/api/projects/${projectId}/journal/check-items`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ initFromTemplates: true }),
|
||
})
|
||
if (res.ok) {
|
||
const items = await res.json()
|
||
setCheckItems(items)
|
||
notifyJournalChanged() // andere offene Clients über die neue SOMA-Liste informieren
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to init check items:', err)
|
||
}
|
||
}, [projectId, notifyJournalChanged])
|
||
|
||
useEffect(() => {
|
||
initDoneRef.current = false
|
||
loadJournal()
|
||
}, [loadJournal])
|
||
|
||
// Load journal suggestions from dictionary (global + tenant)
|
||
useEffect(() => {
|
||
if (tenantId) {
|
||
// Tenant user: load merged suggestions (global + tenant)
|
||
fetch(`/api/tenants/${tenantId}/suggestions`)
|
||
.then(r => r.ok ? r.json() : null)
|
||
.then(data => { if (data?.suggestions) setSuggestions(data.suggestions) })
|
||
.catch(() => {})
|
||
} else {
|
||
// SERVER_ADMIN (no tenant): load from dictionary API (global words)
|
||
fetch('/api/dictionary')
|
||
.then(r => r.ok ? r.json() : null)
|
||
.then(data => { if (data?.words) setSuggestions(data.words.map((w: any) => w.word)) })
|
||
.catch(() => {})
|
||
}
|
||
}, [tenantId])
|
||
|
||
// Listen for real-time journal refresh events from Socket.io
|
||
useEffect(() => {
|
||
const onRefresh = () => {
|
||
loadJournal()
|
||
}
|
||
window.addEventListener('journal-refresh', onRefresh)
|
||
return () => window.removeEventListener('journal-refresh', onRefresh)
|
||
}, [loadJournal])
|
||
|
||
// After loading, if no check items exist, init from templates
|
||
useEffect(() => {
|
||
if (!isLoading && projectId && checkItems.length === 0 && entries.length === 0) {
|
||
initCheckItems()
|
||
}
|
||
}, [isLoading, projectId, checkItems.length, entries.length, initCheckItems])
|
||
|
||
// Add journal entry
|
||
const addEntry = useCallback(async () => {
|
||
if (!projectId || !newWhat.trim()) return
|
||
try {
|
||
const res = await fetch(`/api/projects/${projectId}/journal/entries`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ what: newWhat.trim(), who: newWho.trim() || null }),
|
||
})
|
||
if (res.ok) {
|
||
const entry = await res.json()
|
||
setEntries(prev => [...prev, entry])
|
||
setNewWhat('')
|
||
setNewWho('')
|
||
notifyJournalChanged()
|
||
// Scroll to bottom
|
||
setTimeout(() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }), 100)
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to add entry:', err)
|
||
}
|
||
}, [projectId, newWhat, newWho, notifyJournalChanged])
|
||
|
||
// Toggle entry done
|
||
const toggleEntryDone = useCallback(async (entry: JournalEntry) => {
|
||
if (!projectId) return
|
||
try {
|
||
const res = await fetch(`/api/projects/${projectId}/journal/entries/${entry.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ done: !entry.done }),
|
||
})
|
||
if (res.ok) {
|
||
const updated = await res.json()
|
||
setEntries(prev => prev.map(e => e.id === updated.id ? updated : e))
|
||
notifyJournalChanged()
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to toggle entry:', err)
|
||
}
|
||
}, [projectId, notifyJournalChanged])
|
||
|
||
// Correction dialog state
|
||
const [correctionEntryId, setCorrectionEntryId] = useState<string | null>(null)
|
||
const [correctionText, setCorrectionText] = useState('')
|
||
|
||
// Create correction for an entry (replaces delete)
|
||
const correctEntry = useCallback(async () => {
|
||
if (!projectId || !correctionEntryId || !correctionText.trim()) return
|
||
try {
|
||
const res = await fetch(`/api/projects/${projectId}/journal/entries/${correctionEntryId}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ what: correctionText.trim() }),
|
||
})
|
||
if (res.ok) {
|
||
setCorrectionEntryId(null)
|
||
setCorrectionText('')
|
||
loadJournal()
|
||
notifyJournalChanged()
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to create correction:', err)
|
||
}
|
||
}, [projectId, correctionEntryId, correctionText, loadJournal, notifyJournalChanged])
|
||
|
||
// Toggle check item confirmed/ok
|
||
const toggleCheck = useCallback(async (item: JournalCheckItem, field: 'confirmed' | 'ok') => {
|
||
if (!projectId) return
|
||
try {
|
||
const res = await fetch(`/api/projects/${projectId}/journal/check-items/${item.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ [field]: !item[field] }),
|
||
})
|
||
if (res.ok) {
|
||
const updated = await res.json()
|
||
setCheckItems(prev => prev.map(c => c.id === updated.id ? updated : c))
|
||
notifyJournalChanged()
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to toggle check:', err)
|
||
}
|
||
}, [projectId, notifyJournalChanged])
|
||
|
||
// Add custom check item
|
||
const addCheckItem = useCallback(async (label: string) => {
|
||
if (!projectId || !label.trim()) return
|
||
try {
|
||
const res = await fetch(`/api/projects/${projectId}/journal/check-items`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ label: label.trim(), sortOrder: checkItems.length }),
|
||
})
|
||
if (res.ok) {
|
||
const item = await res.json()
|
||
setCheckItems(prev => [...prev, item])
|
||
notifyJournalChanged()
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to add check item:', err)
|
||
}
|
||
}, [projectId, checkItems.length, notifyJournalChanged])
|
||
|
||
// Delete check item
|
||
const deleteCheckItem = useCallback(async (itemId: string) => {
|
||
if (!projectId) return
|
||
try {
|
||
await fetch(`/api/projects/${projectId}/journal/check-items/${itemId}`, { method: 'DELETE' })
|
||
setCheckItems(prev => prev.filter(c => c.id !== itemId))
|
||
notifyJournalChanged()
|
||
} catch (err) {
|
||
console.error('Failed to delete check item:', err)
|
||
}
|
||
}, [projectId, notifyJournalChanged])
|
||
|
||
// Add pendenz
|
||
const addPendenz = useCallback(async (what: string, who: string, whenHow: string) => {
|
||
if (!projectId || !what.trim()) return
|
||
try {
|
||
const res = await fetch(`/api/projects/${projectId}/journal/pendenzen`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ what: what.trim(), who: who.trim() || null, whenHow: whenHow.trim() || null }),
|
||
})
|
||
if (res.ok) {
|
||
const item = await res.json()
|
||
setPendenzen(prev => [...prev, item])
|
||
notifyJournalChanged()
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to add pendenz:', err)
|
||
}
|
||
}, [projectId, notifyJournalChanged])
|
||
|
||
// Toggle pendenz done
|
||
const togglePendenzDone = useCallback(async (p: JournalPendenz) => {
|
||
if (!projectId) return
|
||
try {
|
||
const res = await fetch(`/api/projects/${projectId}/journal/pendenzen/${p.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ done: !p.done }),
|
||
})
|
||
if (res.ok) {
|
||
const updated = await res.json()
|
||
setPendenzen(prev => prev.map(x => x.id === updated.id ? updated : x))
|
||
notifyJournalChanged()
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to toggle pendenz:', err)
|
||
}
|
||
}, [projectId, notifyJournalChanged])
|
||
|
||
// Delete pendenz
|
||
const deletePendenz = useCallback(async (pendenzId: string) => {
|
||
if (!projectId) return
|
||
try {
|
||
await fetch(`/api/projects/${projectId}/journal/pendenzen/${pendenzId}`, { method: 'DELETE' })
|
||
setPendenzen(prev => prev.filter(p => p.id !== pendenzId))
|
||
notifyJournalChanged()
|
||
} catch (err) {
|
||
console.error('Failed to delete pendenz:', err)
|
||
}
|
||
}, [projectId, notifyJournalChanged])
|
||
|
||
// Send report via email
|
||
const [showSendDialog, setShowSendDialog] = useState(false)
|
||
const [reportEmail, setReportEmail] = useState('')
|
||
const [sendingReport, setSendingReport] = useState(false)
|
||
|
||
const sendReport = useCallback(async () => {
|
||
if (!projectId || !reportEmail.trim()) return
|
||
setSendingReport(true)
|
||
try {
|
||
const res = await fetch(`/api/projects/${projectId}/journal/send-report`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ recipientEmail: reportEmail.trim() }),
|
||
})
|
||
const data = await res.json()
|
||
if (res.ok) {
|
||
alert(data.message || 'Rapport gesendet!')
|
||
setShowSendDialog(false)
|
||
setReportEmail('')
|
||
} else {
|
||
alert(data.error || 'Fehler beim Senden')
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to send report:', err)
|
||
alert('Fehler beim Senden des Rapports')
|
||
} finally {
|
||
setSendingReport(false)
|
||
}
|
||
}, [projectId, reportEmail])
|
||
|
||
// Print
|
||
const handlePrint = useCallback(() => {
|
||
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: '',
|
||
// Nur gültige Massnahmen: durchgestrichene Original-Einträge (isCorrected)
|
||
// ausfiltern; Erledigt-Status übernehmen.
|
||
massnahmen: entries
|
||
.filter(e => !(e as any).isCorrected)
|
||
.map(e => `${formatTime(e.time)} ${e.what}${e.who ? ` (${e.who})` : ''}${e.done && e.doneAt ? ` — erledigt ${formatTime(e.doneAt)}` : ''}`),
|
||
somaItems: checkItems.map(c => ({
|
||
label: c.label,
|
||
confirmed: c.confirmed,
|
||
ok: c.ok,
|
||
confirmedAt: c.confirmedAt ? formatTime(c.confirmedAt) : null,
|
||
okAt: c.okAt ? formatTime(c.okAt) : 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">
|
||
<p className="text-lg">Bitte zuerst einen Einsatz erstellen oder laden</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Aktivierte Module nach Slot aufteilen (Reihenfolge = sortOrder aus der Konfiguration)
|
||
const enabledModules = modules.filter(m => m.enabled)
|
||
const mainModules = enabledModules.filter(m => m.slot === 'main')
|
||
const sideModules = enabledModules.filter(m => m.slot === 'side')
|
||
|
||
return (
|
||
<>
|
||
{/* Print styles: landscape, compact */}
|
||
<style jsx global>{`
|
||
@media print {
|
||
@page { size: landscape; margin: 10mm; }
|
||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||
.print\:hidden { display: none !important; }
|
||
}
|
||
`}</style>
|
||
|
||
<div className="h-full overflow-auto bg-stone-50 dark:bg-background print:bg-white print:overflow-visible">
|
||
{/* Header */}
|
||
<div className="p-3 md:p-4 border-b-2 border-red-600 dark:border-red-800 bg-white dark:bg-card shadow-sm print:bg-white print:p-2 print:border-b-2 print:border-red-600">
|
||
<div className="flex items-center justify-between mb-2 print:mb-1">
|
||
<h2 className="text-lg md:text-xl font-bold flex items-center gap-2 print:text-base text-red-800 dark:text-red-400">
|
||
<ClipboardList className="w-5 h-5 md:w-6 md:h-6 print:w-4 print:h-4" />
|
||
{isUebung ? 'Übungs-Journal' : 'Einsatz-Journal'}
|
||
</h2>
|
||
<div className="flex gap-1.5 print:hidden">
|
||
{canManageModules && (
|
||
<Button variant="outline" size="sm" onClick={() => setShowModuleManager(true)} title="Cockpit-Module verwalten">
|
||
<Blocks className="w-4 h-4 md:mr-1.5" />
|
||
<span className="hidden md:inline">Module</span>
|
||
</Button>
|
||
)}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={!projectId || rapportLoading}
|
||
onClick={handleOpenRapport}
|
||
>
|
||
{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)}>
|
||
<Send className="w-4 h-4 mr-1.5" />
|
||
Per E-Mail
|
||
</Button>
|
||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||
<Printer className="w-4 h-4 mr-1.5" />
|
||
Drucken
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-1 text-sm print:text-xs">
|
||
<div>
|
||
<span className="text-muted-foreground text-xs print:text-[9px]">Einsatz</span>
|
||
<p className="font-semibold truncate">{einsatzNr && <span className="font-mono text-xs bg-primary/10 text-primary px-1 py-0.5 rounded mr-1">{einsatzNr}</span>}{projectTitle || '–'}</p>
|
||
</div>
|
||
<div>
|
||
<span className="text-muted-foreground text-xs print:text-[9px]">Standort</span>
|
||
<p className="font-semibold truncate">{projectLocation || '–'}</p>
|
||
</div>
|
||
<div>
|
||
<span className="text-muted-foreground text-xs print:text-[9px]">Einsatzleiter</span>
|
||
<p className="font-semibold truncate">{einsatzleiter || '–'}</p>
|
||
</div>
|
||
<div>
|
||
<span className="text-muted-foreground text-xs print:text-[9px]">Journalführer</span>
|
||
<p className="font-semibold truncate">{journalfuehrer || '–'}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Send report dialog */}
|
||
{showSendDialog && (
|
||
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-950/30 rounded-lg border border-blue-200 dark:border-blue-800 print:hidden">
|
||
<p className="text-xs font-semibold text-blue-700 dark:text-blue-400 mb-2">Einsatzrapport per E-Mail versenden:</p>
|
||
<div className="flex gap-1.5">
|
||
<Input
|
||
type="email"
|
||
placeholder="empfaenger@beispiel.ch"
|
||
value={reportEmail}
|
||
onChange={(e) => setReportEmail(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && sendReport()}
|
||
className="flex-1 h-8 text-xs"
|
||
autoFocus
|
||
/>
|
||
<Button size="sm" onClick={sendReport} disabled={!reportEmail.trim() || sendingReport} className="h-8 px-3 bg-blue-600 hover:bg-blue-700">
|
||
{sendingReport ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Send className="w-3.5 h-3.5" />}
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={() => { setShowSendDialog(false); setReportEmail('') }} className="h-8 px-2">
|
||
✕
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Main content: side-by-side on large screens — Module gemäss Mandanten-Konfiguration */}
|
||
<div className="flex flex-col lg:flex-row print:flex-row">
|
||
{/* Hauptbereich: Journal + weitere main-Module */}
|
||
<div className="flex-1 min-w-0">
|
||
{mainModules.map(mod => {
|
||
if (mod.type === 'journal') {
|
||
return (
|
||
<div key={mod.id}>
|
||
{/* Table header */}
|
||
<div className="grid grid-cols-[55px_1fr_70px_40px_30px] md:grid-cols-[70px_1fr_90px_50px_40px] print:grid-cols-[60px_1fr_70px_40px] gap-px text-[11px] md:text-xs font-semibold sticky top-0 z-10 shadow-sm">
|
||
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground border-b border-red-200 dark:border-border">Zeit</div>
|
||
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground border-b border-red-200 dark:border-border">Was</div>
|
||
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground border-b border-red-200 dark:border-border">Wer</div>
|
||
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground text-center border-b border-red-200 dark:border-border">Ok</div>
|
||
<div className="bg-stone-100 dark:bg-muted px-1 py-1.5 print:hidden border-b border-red-200 dark:border-border"></div>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||
Lade Journal...
|
||
</div>
|
||
) : entries.length === 0 ? (
|
||
<div className="text-center text-muted-foreground py-8 text-sm">
|
||
Noch keine Einträge.
|
||
</div>
|
||
) : (
|
||
<div className="divide-y divide-border">
|
||
{entries.map((entry, idx) => (
|
||
<div
|
||
key={entry.id}
|
||
className={`grid grid-cols-[55px_1fr_70px_40px_30px] md:grid-cols-[70px_1fr_90px_50px_40px] print:grid-cols-[60px_1fr_70px_40px] gap-px text-[11px] md:text-xs print:text-[9px] ${entry.done ? 'bg-green-50 dark:bg-green-950/20 print:bg-green-50' : idx % 2 === 0 ? 'bg-white dark:bg-card' : 'bg-stone-50 dark:bg-muted/30'}`}
|
||
>
|
||
<div className="px-2 py-1.5 print:py-1 font-mono tabular-nums text-muted-foreground">
|
||
{formatTime(entry.time)}
|
||
</div>
|
||
<div className={`px-2 py-1.5 print:py-1 break-words ${entry.done ? 'text-muted-foreground' : ''} ${(entry as any).isCorrected ? 'line-through opacity-50' : ''} ${(entry as any).correctionOfId ? 'text-amber-700 dark:text-amber-400 italic' : ''}`}>
|
||
{entry.what}
|
||
{(entry as any).isCorrected && (
|
||
<span className="ml-1.5 text-red-500 text-[10px] print:text-[8px] font-medium no-underline">
|
||
(korrigiert)
|
||
</span>
|
||
)}
|
||
{entry.done && entry.doneAt && (
|
||
<span className="ml-1.5 text-green-600 text-[10px] print:text-[8px] font-medium">
|
||
(erledigt um {formatTime(entry.doneAt)})
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="px-2 py-1.5 print:py-1 text-muted-foreground truncate">
|
||
{entry.who || '–'}
|
||
</div>
|
||
<div className="px-1 py-1.5 print:py-1 flex items-center justify-center">
|
||
{canEdit && !(entry as any).isCorrected ? (
|
||
<button
|
||
onClick={() => toggleEntryDone(entry)}
|
||
className={`w-4 h-4 md:w-5 md:h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||
entry.done
|
||
? 'bg-green-500 border-green-500 text-white'
|
||
: 'border-muted-foreground/30 hover:border-primary'
|
||
}`}
|
||
>
|
||
{entry.done && <Check className="w-2.5 h-2.5 md:w-3 md:h-3" />}
|
||
</button>
|
||
) : (
|
||
entry.done ? <Check className="w-3 h-3 text-green-500 opacity-40" /> : (entry as any).isCorrected ? <span className="w-4 h-4 md:w-5 md:h-5 rounded border-2 border-muted-foreground/20 bg-muted/30" /> : null
|
||
)}
|
||
</div>
|
||
<div className="px-1 py-1.5 flex items-center justify-center print:hidden">
|
||
{canEdit && !(entry as any).isCorrected && !(entry as any).correctionOfId && (
|
||
<button
|
||
onClick={() => { setCorrectionEntryId(entry.id); setCorrectionText(entry.what) }}
|
||
className="text-muted-foreground hover:text-amber-600 p-0.5"
|
||
title="Korrektur erstellen"
|
||
>
|
||
<Pencil className="w-3 h-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Correction inline form */}
|
||
{correctionEntryId && (
|
||
<div className="border-t-2 border-amber-400 p-2 bg-amber-50 dark:bg-amber-950/30 print:hidden">
|
||
<p className="text-xs font-semibold text-amber-700 dark:text-amber-400 mb-1.5">Korrektur erstellen:</p>
|
||
<div className="flex gap-1.5">
|
||
<Input
|
||
placeholder="Korrekturtext..."
|
||
value={correctionText}
|
||
onChange={(e) => setCorrectionText(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && correctEntry()}
|
||
className="flex-1 h-7 text-xs border-amber-300"
|
||
autoFocus
|
||
/>
|
||
<Button size="sm" onClick={correctEntry} disabled={!correctionText.trim()} className="h-7 px-2 bg-amber-600 hover:bg-amber-700">
|
||
<Check className="w-3.5 h-3.5" />
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={() => { setCorrectionEntryId(null); setCorrectionText('') }} className="h-7 px-2">
|
||
✕
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* New entry form */}
|
||
{canEdit && (
|
||
<div className="border-t border-border p-2 bg-white dark:bg-card print:hidden sticky bottom-0 shadow-[0_-2px_4px_rgba(0,0,0,0.05)]">
|
||
<div className="flex gap-1.5">
|
||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground font-mono w-[55px] md:w-[70px] shrink-0">
|
||
<Clock className="w-3 h-3" />
|
||
{new Date().toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}
|
||
</div>
|
||
<div className="flex-1 relative">
|
||
{/* iPhone-style suggestion bar */}
|
||
{showSuggestions && filteredSuggestions.length > 0 && (
|
||
<div ref={suggestionsRef} className="absolute bottom-full left-0 right-0 mb-1 z-50">
|
||
<div className="flex gap-1 overflow-x-auto scrollbar-hide py-1 px-0.5">
|
||
{filteredSuggestions.map((s, i) => {
|
||
const lower = newWhat.toLowerCase()
|
||
const idx = s.toLowerCase().indexOf(lower)
|
||
return (
|
||
<button
|
||
key={i}
|
||
type="button"
|
||
className={`shrink-0 px-2.5 py-1 text-xs rounded-full border transition-all whitespace-nowrap ${
|
||
i === selectedSuggestionIdx
|
||
? 'bg-red-600 text-white border-red-600'
|
||
: 'bg-white dark:bg-card border-border text-foreground hover:bg-red-50 hover:border-red-200 shadow-sm'
|
||
}`}
|
||
onMouseDown={(e) => {
|
||
e.preventDefault()
|
||
setNewWhat(s)
|
||
setShowSuggestions(false)
|
||
setSelectedSuggestionIdx(-1)
|
||
}}
|
||
>
|
||
{idx >= 0 ? (
|
||
<>{s.slice(0, idx)}<strong className="font-bold">{s.slice(idx, idx + lower.length)}</strong>{s.slice(idx + lower.length)}</>
|
||
) : s}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<Input
|
||
placeholder="Was..."
|
||
value={newWhat}
|
||
onChange={(e) => {
|
||
setNewWhat(e.target.value)
|
||
filterSuggestions(e.target.value)
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (showSuggestions && filteredSuggestions.length > 0) {
|
||
if (e.key === 'ArrowRight' || e.key === 'Tab') {
|
||
e.preventDefault()
|
||
setSelectedSuggestionIdx(prev => (prev + 1) % filteredSuggestions.length)
|
||
return
|
||
}
|
||
if (e.key === 'ArrowLeft') {
|
||
e.preventDefault()
|
||
setSelectedSuggestionIdx(prev => prev <= 0 ? filteredSuggestions.length - 1 : prev - 1)
|
||
return
|
||
}
|
||
if (e.key === 'Enter' && selectedSuggestionIdx >= 0) {
|
||
e.preventDefault()
|
||
setNewWhat(filteredSuggestions[selectedSuggestionIdx])
|
||
setShowSuggestions(false)
|
||
setSelectedSuggestionIdx(-1)
|
||
return
|
||
}
|
||
}
|
||
if (e.key === 'Enter') { setShowSuggestions(false); addEntry() }
|
||
if (e.key === 'Escape') { setShowSuggestions(false); setSelectedSuggestionIdx(-1) }
|
||
}}
|
||
onFocus={() => filterSuggestions(newWhat)}
|
||
onBlur={() => setTimeout(() => { setShowSuggestions(false); setSelectedSuggestionIdx(-1) }, 150)}
|
||
className="h-7 text-xs w-full"
|
||
/>
|
||
</div>
|
||
<Input
|
||
placeholder="Wer"
|
||
value={newWho}
|
||
onChange={(e) => setNewWho(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && addEntry()}
|
||
className="w-16 md:w-20 h-7 text-xs"
|
||
/>
|
||
<Button size="sm" onClick={addEntry} disabled={!newWhat.trim()} className="h-7 px-2">
|
||
<Plus className="w-3.5 h-3.5" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
// Weitere main-Module (z.B. Lagemeldungen): breite Tabellen-Karte
|
||
return (
|
||
<div key={mod.id} className="border-t-4 border-stone-200 dark:border-border bg-white dark:bg-card">
|
||
<ModuleCard icon={mod.icon} name={mod.name}>
|
||
<TableModule projectId={projectId} module={mod} canEdit={canEdit} notifyChanged={notifyJournalChanged} />
|
||
</ModuleCard>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Seitenspalte: SOMA, Pendenzen + eigene Module */}
|
||
{sideModules.length > 0 && (
|
||
<div className="w-full lg:w-72 xl:w-80 print:w-[280px] border-t lg:border-t-0 lg:border-l-2 lg:border-l-red-200 dark:lg:border-l-border border-border bg-white dark:bg-card shrink-0 shadow-sm">
|
||
{sideModules.map(mod => {
|
||
if (mod.type === 'soma') {
|
||
return (
|
||
<ModuleCard key={mod.id} icon={mod.icon} name={mod.name} count={`${checkItems.filter(c => c.confirmed).length}/${checkItems.length}`}>
|
||
<SomaModule
|
||
items={checkItems}
|
||
canEdit={canEdit}
|
||
onToggle={toggleCheck}
|
||
onAdd={addCheckItem}
|
||
onDelete={deleteCheckItem}
|
||
/>
|
||
</ModuleCard>
|
||
)
|
||
}
|
||
if (mod.type === 'pendenzen') {
|
||
return (
|
||
<ModuleCard key={mod.id} icon={mod.icon} name={mod.name} count={`${pendenzen.filter(p => p.done).length}/${pendenzen.length}`}>
|
||
<PendenzenModule
|
||
items={pendenzen}
|
||
canEdit={canEdit}
|
||
onAdd={addPendenz}
|
||
onToggle={togglePendenzDone}
|
||
onDelete={deletePendenz}
|
||
/>
|
||
</ModuleCard>
|
||
)
|
||
}
|
||
return (
|
||
<ModuleCard key={mod.id} icon={mod.icon} name={mod.name}>
|
||
<TableModule projectId={projectId} module={mod} canEdit={canEdit} notifyChanged={notifyJournalChanged} />
|
||
</ModuleCard>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Modul-Verwaltung */}
|
||
<ModuleManagerDialog
|
||
open={showModuleManager}
|
||
onOpenChange={setShowModuleManager}
|
||
modules={modules}
|
||
onSaved={setModules}
|
||
/>
|
||
|
||
{/* Rapport Dialog */}
|
||
{showRapportDialog && projectId && (
|
||
<RapportDialog
|
||
projectId={projectId}
|
||
rapportForm={rapportForm}
|
||
setRapportForm={setRapportForm}
|
||
mapRef={mapRef}
|
||
mapScreenshot={preCapuredScreenshot}
|
||
onClose={() => setShowRapportDialog(false)}
|
||
onRapportCreated={(link) => setLastRapportLink(link)}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|