Files
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

269 lines
9.0 KiB
TypeScript

'use client'
import { useEffect, useRef, useState, useTransition } from 'react'
import { sectionText } from '@/lib/i18n/cases'
import { useSectionsForm } from './SectionsForm'
import type { SaveSection } from './types'
type Props = {
/** Wohin gespeichert wird - gebundene Server Action der Akte oder des Dokuments. */
save: SaveSection
/** Eindeutig je Besitzer, damit Entwürfe zweier Akten sich nicht mischen. */
scope: 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. Trägt einen Fallabschnitt genauso wie ein
* Feld eines Dokuments - wohin gespeichert wird, kommt von außen.
*
* 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.
*
* Der Knopf steht **am Blatt, nicht am Feld**: dieses Feld meldet sich beim
* umgebenden Formular an und wird mit allen anderen zusammen gespeichert. Nur
* wenn kein Formular da ist, trägt es seinen eigenen Knopf.
*
* 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.scope}:${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)
const formular = useSectionsForm()
// Was zuletzt gespeichert wurde. Daran hängt, ob dieses Feld beim nächsten
// Speichern überhaupt mitgeschickt wird.
const gespeichert = useRef(normalise(props.initialValue, props.type))
const aktuell = useRef(value)
aktuell.current = value
const stand = useRef(version)
stand.current = version
useEffect(() => {
if (!formular) return
return formular.register({
fieldKey: props.fieldKey,
collect: () =>
aktuell.current === gespeichert.current
? null
: {
fieldKey: props.fieldKey,
value: parse(aktuell.current, props.type),
baseVersion: stand.current,
},
apply: (ergebnis) => {
if (ergebnis.ok) {
gespeichert.current = aktuell.current
setVersion(ergebnis.version)
setConflict(null)
setError(null)
setSaved(true)
setDraftKept(false)
formular.changed(props.fieldKey, false)
try {
localStorage.removeItem(speicherSchluessel)
} catch {
// egal
}
return
}
if (ergebnis.conflict) setConflict(ergebnis.current)
else setError(ergebnis.error)
},
})
}, [formular, props.fieldKey, props.type, speicherSchluessel])
// 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)
formular?.changed(props.fieldKey, next !== gespeichert.current)
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 props.save({
fieldKey: props.fieldKey,
value: override ?? parse(value, props.type),
baseVersion,
})
if (result.ok) {
gespeichert.current = override === undefined ? value : normalise(override, props.type)
setVersion(result.version)
setConflict(null)
setSaved(true)
setDraftKept(false)
formular?.changed(props.fieldKey, 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="field"
>
<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) field"
/>
) : (
<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) field"
/>
)}
<div className="mt-2 flex flex-wrap items-center gap-3 text-2xs">
{/* Ohne umgebendes Formular trägt das Feld seinen eigenen Knopf. */}
{formular ? null : (
<button
type="button"
onClick={() => submit()}
disabled={pending}
aria-busy={pending}
className="btn btn-primary"
>
{pending ? sectionText.saving : sectionText.save}
</button>
)}
{version > 0 ? (
<span className="font-mono text-text-dim">{sectionText.version(version)}</span>
) : null}
{saved ? <span className="font-condensed text-text-dim">{sectionText.saved}</span> : null}
{draftKept && !saved ? (
<span className="font-condensed text-text-dim">{sectionText.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">{sectionText.conflict}</p>
<div className="mt-3 grid gap-4 md:grid-cols-2">
<div>
<p className="font-condensed text-2xs text-text-dim">{sectionText.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 btn btn-sm btn-primary"
>
{sectionText.takeMine}
</button>
</div>
<div>
<p className="font-condensed text-2xs text-text-dim">{sectionText.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 btn btn-sm btn-primary"
>
{sectionText.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
}