feat(cockpit): Modul-Baukasten – Einsatz-Cockpit frei zusammenstellbar (v1.5.0)
Jede Feuerwehr stellt ihr Cockpit selbst zusammen (Wunsch: modular & einfach): M0 – Zerlegt: - SOMA + Pendenzen als eigenständige Modul-Komponenten, gemeinsame Typen (journal/types.ts), einheitliche ModuleCard-Hülle M1 – Registry & Verwaltung: - Modul-Registry src/lib/modules.ts, Konfiguration pro Mandant in tenants.modulesConfig (JSONB, idempotente Migration) - API GET/PUT /api/tenant/modules (normalisiert + validiert via normalizeModules) - "Module"-Button im Journal (Admins): an/aus, Reihenfolge hoch/runter, eigene Module löschen M2 – Eigene Module: - Generisches Tabellen-Modul: frei definierbare Spalten (Text / Haken mit Zeitstempel / Auto-Zeit), max 8 Spalten - Generische Daten-API /api/projects/[id]/modules/[moduleId]/items (+ [itemId]), neue Tabelle module_items, Live-Sync über journal-refresh Events M3 – Feld-Vorlagen: - Checkliste, Atemschutz-Überwachung (Trupp/Druck/Zeit/Draussen), Kräfte vor Ort (Name/Funktion/seit/Weg), Lagemeldungen (Hauptbereich), leeres Modul mit Spalten-Editor Eingebaute Module (Journal/SOMA/Pendenzen) bleiben ohne Konfiguration exakt wie bisher — kaputte Configs fallen sicher auf den Standard zurück. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,42 +3,19 @@
|
||||
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,
|
||||
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'
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
@@ -56,14 +33,38 @@ interface JournalViewProps {
|
||||
mapScreenshot?: string
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||
/** 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,
|
||||
}
|
||||
|
||||
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' })
|
||||
/** 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) {
|
||||
@@ -74,18 +75,15 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
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('')
|
||||
|
||||
// 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
|
||||
@@ -147,6 +145,17 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
}
|
||||
}, [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
|
||||
@@ -290,24 +299,23 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
}, [projectId, notifyJournalChanged])
|
||||
|
||||
// Add custom check item
|
||||
const addCheckItem = useCallback(async () => {
|
||||
if (!projectId || !newCheckLabel.trim()) return
|
||||
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: newCheckLabel.trim(), sortOrder: checkItems.length }),
|
||||
body: JSON.stringify({ label: label.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])
|
||||
}, [projectId, checkItems.length, notifyJournalChanged])
|
||||
|
||||
// Delete check item
|
||||
const deleteCheckItem = useCallback(async (itemId: string) => {
|
||||
@@ -322,26 +330,23 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
}, [projectId, notifyJournalChanged])
|
||||
|
||||
// Add pendenz
|
||||
const addPendenz = useCallback(async () => {
|
||||
if (!projectId || !newPendWhat.trim()) return
|
||||
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: newPendWhat.trim(), who: newPendWho.trim() || null, whenHow: newPendWhen.trim() || null }),
|
||||
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])
|
||||
setNewPendWhat('')
|
||||
setNewPendWho('')
|
||||
setNewPendWhen('')
|
||||
notifyJournalChanged()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add pendenz:', err)
|
||||
}
|
||||
}, [projectId, newPendWhat, newPendWho, newPendWhen, notifyJournalChanged])
|
||||
}, [projectId, notifyJournalChanged])
|
||||
|
||||
// Toggle pendenz done
|
||||
const togglePendenzDone = useCallback(async (p: JournalPendenz) => {
|
||||
@@ -417,6 +422,11 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
)
|
||||
}
|
||||
|
||||
// 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 */}
|
||||
@@ -437,6 +447,12 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
{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"
|
||||
@@ -502,19 +518,19 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
<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>
|
||||
<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 || '\u2013'}</p>
|
||||
<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 || '\u2013'}</p>
|
||||
<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 || '\u2013'}</p>
|
||||
<p className="font-semibold truncate">{journalfuehrer || '–'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -543,372 +559,265 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content: side-by-side on large screens */}
|
||||
{/* Main content: side-by-side on large screens — Module gemäss Mandanten-Konfiguration */}
|
||||
<div className="flex flex-col lg:flex-row print:flex-row">
|
||||
{/* Journal entries */}
|
||||
{/* Hauptbereich: Journal + weitere main-Module */}
|
||||
<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>
|
||||
{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 || '\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>
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
{/* 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>
|
||||
)}
|
||||
<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>}
|
||||
{/* 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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
// 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>
|
||||
)
|
||||
})}
|
||||
</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
|
||||
|
||||
Reference in New Issue
Block a user