Files
Lageplan/src/components/journal/journal-view.tsx
Pepe Ziberi 92e1e65b71 fix(journal/rapport): SOMA atomar + Rapport-Korrektheit + Export-dpr (v1.6.5)
- 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>
2026-07-22 21:38:16 +02:00

868 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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)}
/>
)}
</>
)
}