925 lines
41 KiB
TypeScript
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)}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|