Eine Vorlage ist eine geordnete Liste von Bausteinen: Eingabefelder und feste Textbausteine mit Platzhaltern. Damit trägt dieselbe Struktur den Vorfallsbericht und den Sonderarbeitsvertrag. Die Startvorlagen bilden jetzt den echten Bericht der Abteilung ab, samt Berichtsart, Gefahreneinschätzung und Foundation-Freigabe — letztere als gewöhnliches Feld ohne Wirkung auf den Zugriff. Abschnitte werden gerendert und bearbeitet, Entwürfe alle zwei Sekunden lokal gesichert. Gespeichert wird auf Knopfdruck: ein Abschnitt ist der Sachstand der Akte, kein Chatbeitrag. Die geladene Version geht mit; weicht sie ab, gibt es kein stilles Überschreiben, sondern beide Fassungen nebeneinander mit "Meine übernehmen" und "Fremde übernehmen". Felder über der Stufe des Lesers werden serverseitig durch einen Platzhalter ersetzt, Schwärzungen im Fliesstext kommen als Segmente. Beides so, dass der echte Wert den Server nicht verlässt — nicht als Ausblenden im Client. Der Wortdiff ist selbst geschrieben statt eingekauft: ein Dutzend Zeilen gegen 30 kB im Bündel für etwas, das nur serverseitig läuft. Beim Bauen des Vorlagen-Editors riss das Bündel-Budget auf 127,9 kB, weil zod über das Vorlagenmodul in den Client geriet. Typen und Validierung sind jetzt getrennt: fields.ts ohne zod für Client und Server, schema.ts nur serverseitig. Wieder bei 111,9 kB von 120. 85 Tests, 22 E2E. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
211 lines
7.0 KiB
TypeScript
211 lines
7.0 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef, useState, useTransition } from 'react'
|
|
import { de } from '@/lib/i18n/de'
|
|
import { saveSectionAction } from './sectionActions'
|
|
|
|
type Props = {
|
|
caseNumber: string
|
|
fieldKey: string
|
|
label: string
|
|
type: string
|
|
options?: string[]
|
|
help?: string
|
|
requiredToClose?: boolean
|
|
initialValue: unknown
|
|
initialVersion: number
|
|
readOnly: boolean
|
|
}
|
|
|
|
const AUTOSAVE_MS = 2000
|
|
|
|
/**
|
|
* Ein Abschnitt zum Bearbeiten.
|
|
*
|
|
* Entwürfe werden alle zwei Sekunden **lokal** gesichert, damit ein
|
|
* versehentlicher Tab-Wechsel keinen Text kostet. Gespeichert wird erst auf
|
|
* Knopfdruck: ein Abschnitt ist der Sachstand der Akte, nicht ein Chatbeitrag.
|
|
* Siehe docs/09-performance-und-ux.md.
|
|
*
|
|
* Beim Speichern geht die geladene Version mit. Weicht sie ab, gibt es kein
|
|
* stilles Überschreiben, sondern beide Fassungen nebeneinander.
|
|
*/
|
|
export function SectionEditor(props: Props) {
|
|
const speicherSchluessel = `afis:abschnitt:${props.caseNumber}:${props.fieldKey}`
|
|
const [value, setValue] = useState(() => normalise(props.initialValue, props.type))
|
|
const [version, setVersion] = useState(props.initialVersion)
|
|
const [conflict, setConflict] = useState<{ value: unknown; version: number } | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [saved, setSaved] = useState(false)
|
|
const [draftKept, setDraftKept] = useState(false)
|
|
const [pending, startTransition] = useTransition()
|
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
// Entwurf aus dem letzten Besuch übernehmen, falls er neuer ist.
|
|
useEffect(() => {
|
|
try {
|
|
const stored = localStorage.getItem(speicherSchluessel)
|
|
if (stored !== null && stored !== normalise(props.initialValue, props.type)) {
|
|
setValue(stored)
|
|
setDraftKept(true)
|
|
}
|
|
} catch {
|
|
// Privates Fenster oder gesperrter Speicher: dann eben ohne Entwurf.
|
|
}
|
|
}, [speicherSchluessel, props.initialValue, props.type])
|
|
|
|
function change(next: string) {
|
|
setValue(next)
|
|
setSaved(false)
|
|
if (timer.current) clearTimeout(timer.current)
|
|
timer.current = setTimeout(() => {
|
|
try {
|
|
localStorage.setItem(speicherSchluessel, next)
|
|
setDraftKept(true)
|
|
} catch {
|
|
// still ignorieren, der Entwurf ist eine Bequemlichkeit
|
|
}
|
|
}, AUTOSAVE_MS)
|
|
}
|
|
|
|
function submit(override?: unknown, baseVersion = version) {
|
|
setError(null)
|
|
startTransition(async () => {
|
|
const result = await saveSectionAction({
|
|
caseNumber: props.caseNumber,
|
|
fieldKey: props.fieldKey,
|
|
value: override ?? parse(value, props.type),
|
|
baseVersion,
|
|
})
|
|
|
|
if (result.ok) {
|
|
setVersion(result.version)
|
|
setConflict(null)
|
|
setSaved(true)
|
|
setDraftKept(false)
|
|
try {
|
|
localStorage.removeItem(speicherSchluessel)
|
|
} catch {
|
|
// egal
|
|
}
|
|
return
|
|
}
|
|
|
|
if (result.conflict) setConflict(result.current)
|
|
else setError(result.error)
|
|
})
|
|
}
|
|
|
|
if (props.readOnly) {
|
|
return (
|
|
<p className="mt-1 max-w-(--container-measure) text-sm whitespace-pre-wrap">
|
|
{String(value || '—')}
|
|
</p>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="mt-1.5">
|
|
{props.type === 'select' ? (
|
|
<select
|
|
value={value}
|
|
onChange={(event) => change(event.target.value)}
|
|
className="border border-line bg-raised px-3 py-2 text-sm"
|
|
>
|
|
<option value="">—</option>
|
|
{props.options?.map((option) => (
|
|
<option key={option} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : props.type === 'longtext' ? (
|
|
<textarea
|
|
value={value}
|
|
onChange={(event) => change(event.target.value)}
|
|
rows={6}
|
|
className="block w-full max-w-(--container-measure) border border-line bg-raised px-3 py-2 text-sm"
|
|
/>
|
|
) : (
|
|
<input
|
|
type={props.type === 'date' ? 'date' : props.type === 'number' ? 'number' : 'text'}
|
|
value={value}
|
|
onChange={(event) => change(event.target.value)}
|
|
className="block w-full max-w-(--container-measure) border border-line bg-raised px-3 py-2 text-sm"
|
|
/>
|
|
)}
|
|
|
|
<div className="mt-2 flex flex-wrap items-center gap-3 text-2xs">
|
|
<button
|
|
type="button"
|
|
onClick={() => submit()}
|
|
disabled={pending}
|
|
className="border border-line bg-raised px-3 py-1 font-condensed text-accent disabled:text-text-dim"
|
|
>
|
|
{pending ? de.section.saving : de.section.save}
|
|
</button>
|
|
{version > 0 ? (
|
|
<span className="font-mono text-text-dim">{de.section.version(version)}</span>
|
|
) : null}
|
|
{saved ? <span className="font-condensed text-text-dim">{de.section.saved}</span> : null}
|
|
{draftKept && !saved ? (
|
|
<span className="font-condensed text-text-dim">{de.section.draftKept}</span>
|
|
) : null}
|
|
{error ? <span className="font-condensed text-stamp-4">{error}</span> : null}
|
|
</div>
|
|
|
|
{conflict ? (
|
|
<div className="mt-3 border border-stamp-4 p-3">
|
|
<p className="font-condensed text-2xs text-stamp-4">{de.section.conflict}</p>
|
|
<div className="mt-3 grid gap-4 md:grid-cols-2">
|
|
<div>
|
|
<p className="font-condensed text-2xs text-text-dim">{de.section.mine}</p>
|
|
<p className="mt-1 border-l-(length:--spacing-edge) border-steel pl-3 text-sm whitespace-pre-wrap">
|
|
{value}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => submit(parse(value, props.type), conflict.version)}
|
|
className="mt-2 font-condensed text-2xs text-accent"
|
|
>
|
|
{de.section.takeMine}
|
|
</button>
|
|
</div>
|
|
<div>
|
|
<p className="font-condensed text-2xs text-text-dim">{de.section.theirs}</p>
|
|
<p className="mt-1 border-l-(length:--spacing-edge) border-line pl-3 text-sm whitespace-pre-wrap">
|
|
{String(conflict.value ?? '')}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setValue(normalise(conflict.value, props.type))
|
|
setVersion(conflict.version)
|
|
setConflict(null)
|
|
}}
|
|
className="mt-2 font-condensed text-2xs text-accent"
|
|
>
|
|
{de.section.takeTheirs}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function normalise(value: unknown, _type: string): string {
|
|
if (value === null || value === undefined) return ''
|
|
if (Array.isArray(value)) return value.join(', ')
|
|
return String(value)
|
|
}
|
|
|
|
function parse(value: string, type: string): unknown {
|
|
if (type === 'number') return value === '' ? null : Number(value)
|
|
if (type === 'multiselect' || type === 'checklist') {
|
|
return value.split(',').map((part) => part.trim()).filter(Boolean)
|
|
}
|
|
return value === '' ? null : value
|
|
}
|