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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
269 lines
9.0 KiB
TypeScript
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
|
|
}
|