Files
Lageplan/src/components/journal/journal-view.tsx

925 lines
41 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 { 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<any>
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<JournalEntry[]>([])
const [checkItems, setCheckItems] = useState<JournalCheckItem[]>([])
const [pendenzen, setPendenzen] = useState<JournalPendenz[]>([])
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<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])
// 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<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 () => {
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 (
<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>
)
}
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" />
Einsatz-Journal
</h2>
<div className="flex gap-1.5 print:hidden">
<Button
variant="outline"
size="sm"
disabled={!projectId}
onClick={() => {
if (!projectId) return
const now = new Date()
// Pre-fill form with available data
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: '',
massnahmen: entries.map(e => `${formatTime(e.time)} ${e.what}${e.who ? ` (${e.who})` : ''}`),
somaItems: checkItems.map(c => ({
label: c.label,
confirmed: c.confirmed,
ok: c.ok,
confirmedAt: c.confirmedAt ? formatTime(c.confirmedAt) : null,
})),
pendenzenItems: pendenzen.map(p => ({
what: p.what,
who: p.who || '',
whenHow: p.whenHow || '',
done: p.done,
doneAt: p.doneAt ? formatTime(p.doneAt) : null,
})),
fahrzeuge: [] as any[],
bemerkungen: '',
einsatzleiter: einsatzleiter || '',
rapporteur: journalfuehrer || '',
reportNumber: '',
logoUrl: tenantLogoUrl || '',
})
setShowRapportDialog(true)
}}
>
<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 || '\u2013'}</p>
</div>
<div>
<span className="text-muted-foreground text-xs print:text-[9px]">Standort</span>
<p className="font-semibold truncate">{projectLocation || '\u2013'}</p>
</div>
<div>
<span className="text-muted-foreground text-xs print:text-[9px]">Einsatzleiter</span>
<p className="font-semibold truncate">{einsatzleiter || '\u2013'}</p>
</div>
<div>
<span className="text-muted-foreground text-xs print:text-[9px]">Journalführer</span>
<p className="font-semibold truncate">{journalfuehrer || '\u2013'}</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 */}
<div className="flex flex-col lg:flex-row print:flex-row">
{/* Journal entries */}
<div className="flex-1 min-w-0">
{/* 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 || '\u2013'}
</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>
{/* Right column: SOMA + Pendenzen */}
<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">
{/* SOMA Checklist */}
<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">
<AlertTriangle className="w-3.5 h-3.5" />
SOMA
<span className="text-[10px] text-muted-foreground font-normal">
({checkItems.filter(c => c.confirmed).length}/{checkItems.length})
</span>
</h3>
</div>
{/* SOMA table with column headers */}
<div className="grid grid-cols-[1fr_28px_28px] print:grid-cols-[1fr_24px_24px] text-[10px] font-semibold text-muted-foreground border-b border-border/50">
<div className="px-2 py-1"></div>
<div className="px-0.5 py-1 text-center text-red-600">JA</div>
<div className="px-0.5 py-1 text-center text-red-500">Ok</div>
</div>
<div className="divide-y divide-border/50">
{checkItems.map((item, idx) => (
<div key={item.id} className={`grid grid-cols-[1fr_28px_28px] print:grid-cols-[1fr_24px_24px] items-center text-xs md:text-sm print:text-[9px] group ${idx % 2 === 0 ? '' : 'bg-stone-50 dark:bg-muted/20'}`}>
<div className="px-2 py-1.5 flex items-center gap-1 min-w-0">
<span className="truncate">{item.label}</span>
{item.confirmedAt && item.confirmed && (
<span className="text-[9px] text-red-500 shrink-0">{formatTime(item.confirmedAt)}</span>
)}
{canEdit && (
<button
onClick={() => deleteCheckItem(item.id)}
className="ml-auto opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive shrink-0 print:hidden"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
<div className="flex items-center justify-center py-1.5">
<button
onClick={() => canEdit && toggleCheck(item, 'confirmed')}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
item.confirmed
? 'bg-blue-500 border-blue-500 text-white'
: 'border-muted-foreground/30 hover:border-blue-400'
}`}
title={item.confirmedAt ? `JA: ${formatDateTime(item.confirmedAt)}` : 'JA'}
disabled={!canEdit}
>
{item.confirmed && <Check className="w-3 h-3" />}
</button>
</div>
<div className="flex items-center justify-center py-1.5">
<button
onClick={() => canEdit && toggleCheck(item, 'ok')}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
item.ok
? 'bg-green-500 border-green-500 text-white'
: 'border-muted-foreground/30 hover:border-green-400'
}`}
title={item.okAt ? `Ok: ${formatDateTime(item.okAt)}` : 'Ok'}
disabled={!canEdit}
>
{item.ok && <Check className="w-3 h-3" />}
</button>
</div>
</div>
))}
</div>
{canEdit && (
<div className="flex gap-1 px-2 py-1.5 border-t border-border/50 print:hidden">
<Input
placeholder="Neuer Punkt..."
value={newCheckLabel}
onChange={(e) => setNewCheckLabel(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addCheckItem()}
className="h-6 text-[11px]"
/>
<Button size="sm" variant="ghost" onClick={addCheckItem} disabled={!newCheckLabel.trim()} className="h-6 px-1.5">
<Plus className="w-3 h-3" />
</Button>
</div>
)}
</div>
{/* Pendenzen */}
<div>
<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">
<CheckSquare className="w-3.5 h-3.5" />
Pendenzen
<span className="text-[10px] text-muted-foreground font-normal">
({pendenzen.filter(p => p.done).length}/{pendenzen.length})
</span>
</h3>
</div>
{pendenzen.length === 0 ? (
<div className="text-center text-muted-foreground py-4 text-xs">
Keine Pendenzen
</div>
) : (
<div className="divide-y divide-border/50">
{pendenzen.map((p) => (
<div key={p.id} className={`flex items-start gap-1.5 px-2 py-1.5 text-xs print:text-[9px] group ${p.done ? 'bg-green-50 dark:bg-green-950/20 print:bg-green-50' : ''}`}>
<button
onClick={() => canEdit && togglePendenzDone(p)}
className={`w-4 h-4 mt-0.5 rounded border-2 flex items-center justify-center shrink-0 transition-colors ${
p.done
? 'bg-green-500 border-green-500 text-white'
: 'border-muted-foreground/30 hover:border-primary'
}`}
disabled={!canEdit}
>
{p.done && <Check className="w-2.5 h-2.5" />}
</button>
<div className="flex-1 min-w-0">
<p className={`${p.done ? 'text-muted-foreground' : ''}`}>
{p.what}
{p.done && p.doneAt && (
<span className="ml-1 text-green-600 text-[10px] print:text-[8px] font-medium no-underline">
(erledigt um {formatTime(p.doneAt)})
</span>
)}
</p>
<div className="flex gap-2 text-[10px] text-muted-foreground">
{p.who && <span>Wer: {p.who}</span>}
{p.whenHow && <span>Wann: {p.whenHow}</span>}
</div>
</div>
{canEdit && (
<button
onClick={() => deletePendenz(p.id)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive shrink-0 mt-0.5 print:hidden"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
)}
{canEdit && (
<div className="border-t border-border p-1.5 print:hidden">
<div className="flex gap-1">
<Input
placeholder="Was..."
value={newPendWhat}
onChange={(e) => setNewPendWhat(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addPendenz()}
className="flex-1 h-6 text-[11px]"
/>
<Input
placeholder="Wer"
value={newPendWho}
onChange={(e) => setNewPendWho(e.target.value)}
className="w-14 h-6 text-[11px]"
/>
<Input
placeholder="Wann"
value={newPendWhen}
onChange={(e) => setNewPendWhen(e.target.value)}
className="w-14 h-6 text-[11px]"
/>
<Button size="sm" variant="ghost" onClick={addPendenz} disabled={!newPendWhat.trim()} className="h-6 px-1.5">
<Plus className="w-3 h-3" />
</Button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
{/* Rapport Dialog */}
{showRapportDialog && projectId && (
<RapportDialog
projectId={projectId}
rapportForm={rapportForm}
setRapportForm={setRapportForm}
mapRef={mapRef}
mapScreenshot={preCapuredScreenshot}
onClose={() => setShowRapportDialog(false)}
onRapportCreated={(link) => setLastRapportLink(link)}
/>
)}
</>
)
}