'use client' import { useState, useEffect, useCallback, useRef, MutableRefObject } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { Plus, Trash2, Check, Clock, CheckSquare, 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 time: string what: string who: string | null done: boolean doneAt: string | null isCorrected?: boolean correctionOfId?: string | null } interface JournalCheckItem { id: string label: string confirmed: boolean confirmedAt: string | null ok: boolean okAt: string | null } interface JournalPendenz { id: string what: string who: string | null whenHow: string | null done: boolean doneAt: string | null } interface JournalViewProps { projectId: string | null projectTitle: string projectLocation: string einsatzleiter: string journalfuehrer: string canEdit: boolean tenantId?: string | null einsatzNr?: string tenantName?: string tenantLogoUrl?: string | null mapRef?: MutableRefObject mapScreenshot?: string } function formatTime(dateStr: string) { return new Date(dateStr).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) } function formatDateTime(dateStr: string) { const d = new Date(dateStr) return d.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) + ' ' + d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) } export function JournalView({ projectId, projectTitle, projectLocation, einsatzleiter, journalfuehrer, canEdit, tenantId, einsatzNr, tenantName, tenantLogoUrl, mapRef, mapScreenshot: preCapuredScreenshot }: JournalViewProps) { const [entries, setEntries] = useState([]) const [checkItems, setCheckItems] = useState([]) const [pendenzen, setPendenzen] = useState([]) const [isLoading, setIsLoading] = useState(false) const initDoneRef = useRef(false) // New entry form const [newWhat, setNewWhat] = useState('') const [newWho, setNewWho] = useState('') // New pendenz form const [newPendWhat, setNewPendWhat] = useState('') const [newPendWho, setNewPendWho] = useState('') const [newPendWhen, setNewPendWhen] = useState('') // New check item const [newCheckLabel, setNewCheckLabel] = useState('') const scrollRef = useRef(null) // Rapport creation const [lastRapportLink, setLastRapportLink] = useState(null) const [showRapportDialog, setShowRapportDialog] = useState(false) const [rapportForm, setRapportForm] = useState>({}) // Journal suggestions (word library per tenant) const [suggestions, setSuggestions] = useState([]) const [filteredSuggestions, setFilteredSuggestions] = useState([]) const [showSuggestions, setShowSuggestions] = useState(false) const [selectedSuggestionIdx, setSelectedSuggestionIdx] = useState(-1) const suggestionsRef = useRef(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]) // 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) } } catch (err) { console.error('Failed to init check items:', err) } }, [projectId]) 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(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 () => { if (!projectId || !newCheckLabel.trim()) return try { const res = await fetch(`/api/projects/${projectId}/journal/check-items`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label: newCheckLabel.trim(), sortOrder: checkItems.length }), }) if (res.ok) { const item = await res.json() setCheckItems(prev => [...prev, item]) setNewCheckLabel('') notifyJournalChanged() } } catch (err) { console.error('Failed to add check item:', err) } }, [projectId, newCheckLabel, 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 () => { if (!projectId || !newPendWhat.trim()) return try { const res = await fetch(`/api/projects/${projectId}/journal/pendenzen`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ what: newPendWhat.trim(), who: newPendWho.trim() || null, whenHow: newPendWhen.trim() || null }), }) if (res.ok) { const item = await res.json() setPendenzen(prev => [...prev, item]) setNewPendWhat('') setNewPendWho('') setNewPendWhen('') notifyJournalChanged() } } catch (err) { console.error('Failed to add pendenz:', err) } }, [projectId, newPendWhat, newPendWho, newPendWhen, 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() }, []) if (!projectId) { return (

Bitte zuerst einen Einsatz erstellen oder laden

) } return ( <> {/* Print styles: landscape, compact */}
{/* Header */}

Einsatz-Journal

Einsatz

{einsatzNr && {einsatzNr}}{projectTitle || '\u2013'}

Standort

{projectLocation || '\u2013'}

Einsatzleiter

{einsatzleiter || '\u2013'}

Journalführer

{journalfuehrer || '\u2013'}

{/* Send report dialog */} {showSendDialog && (

Einsatzrapport per E-Mail versenden:

setReportEmail(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && sendReport()} className="flex-1 h-8 text-xs" autoFocus />
)}
{/* Main content: side-by-side on large screens */}
{/* Journal entries */}
{/* Table header */}
Zeit
Was
Wer
Ok
{isLoading ? (
Lade Journal...
) : entries.length === 0 ? (
Noch keine Einträge.
) : (
{entries.map((entry, idx) => (
{formatTime(entry.time)}
{entry.what} {(entry as any).isCorrected && ( (korrigiert) )} {entry.done && entry.doneAt && ( (erledigt um {formatTime(entry.doneAt)}) )}
{entry.who || '\u2013'}
{canEdit && !(entry as any).isCorrected ? ( ) : ( entry.done ? : (entry as any).isCorrected ? : null )}
{canEdit && !(entry as any).isCorrected && !(entry as any).correctionOfId && ( )}
))}
)} {/* Correction inline form */} {correctionEntryId && (

Korrektur erstellen:

setCorrectionText(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && correctEntry()} className="flex-1 h-7 text-xs border-amber-300" autoFocus />
)} {/* New entry form */} {canEdit && (
{new Date().toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}
{/* iPhone-style suggestion bar */} {showSuggestions && filteredSuggestions.length > 0 && (
{filteredSuggestions.map((s, i) => { const lower = newWhat.toLowerCase() const idx = s.toLowerCase().indexOf(lower) return ( ) })}
)} { 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" />
setNewWho(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addEntry()} className="w-16 md:w-20 h-7 text-xs" />
)}
{/* Right column: SOMA + Pendenzen */}
{/* SOMA Checklist */}

SOMA ({checkItems.filter(c => c.confirmed).length}/{checkItems.length})

{/* SOMA table with column headers */}
JA
Ok
{checkItems.map((item, idx) => (
{item.label} {item.confirmedAt && item.confirmed && ( {formatTime(item.confirmedAt)} )} {canEdit && ( )}
))}
{canEdit && (
setNewCheckLabel(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addCheckItem()} className="h-6 text-[11px]" />
)}
{/* Pendenzen */}

Pendenzen ({pendenzen.filter(p => p.done).length}/{pendenzen.length})

{pendenzen.length === 0 ? (
Keine Pendenzen
) : (
{pendenzen.map((p) => (

{p.what} {p.done && p.doneAt && ( (erledigt um {formatTime(p.doneAt)}) )}

{p.who && Wer: {p.who}} {p.whenHow && Wann: {p.whenHow}}
{canEdit && ( )}
))}
)} {canEdit && (
setNewPendWhat(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addPendenz()} className="flex-1 h-6 text-[11px]" /> setNewPendWho(e.target.value)} className="w-14 h-6 text-[11px]" /> setNewPendWhen(e.target.value)} className="w-14 h-6 text-[11px]" />
)}
{/* Rapport Dialog */} {showRapportDialog && projectId && ( setShowRapportDialog(false)} onRapportCreated={(link) => setLastRapportLink(link)} /> )} ) }