Die restlichen zwei Änderungswünsche. **Protokoll.** „Wer was wo macht" stand seit dem ersten Tag in AuditLog — harte Regel 3 sorgt dafür. Was fehlte, war die Ansicht darauf. /admin/protokoll ist genau das und keine zweite Aufzeichnung: filterbar nach Person, Aktion, Art und Zeitraum, rückwärts blätternd. Nur für die Leitung. Eine Zeile nennt Aktenzeichen und Namen, und schon die Existenz einer Zeile verrät, dass es einen Vorgang gibt; ein gefiltertes Protokoll für alle wäre ein Weg, die Sichtbarkeitsregeln von hinten aufzurollen. Aus demselben Grund werden Kennungen ohne Sichtbarkeitsfilter zu Aktenzeichen aufgelöst — ein Protokoll aus UUIDs liest niemand, und eines, das niemand liest, ist keine Kontrolle. **Speichern.** Jedes Feld trug seinen eigenen Knopf: ein Arbeitsvertrag mit zwölf Feldern hiess zwölfmal speichern. Der Knopf steht jetzt am Blatt. Geschrieben wird trotzdem je Feld und nicht als Transaktion, und das folgt aus dem Datenmodell: Abschnitte sind einzeln versioniert, weil mehrere Leute gleichzeitig an einer Akte arbeiten. Als Transaktion blockierte ein einziges zwischenzeitlich geändertes Feld das ganze Blatt — elf gute Änderungen verloren, weil jemand das zwölfte angefasst hat. So geht durch, was durchgeht, und die Rückmeldung sagt, was offen blieb und wo. Lichtbilder bleiben ausgenommen: ein Upload mit Prüfkette ist keine Feldänderung. lib/audit.ts liegt jetzt als lib/audit/write.ts neben lib/audit/query.ts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
|
|
}
|