Protokoll im Adminbereich, ein Knopf für das ganze Blatt
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.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState, useTransition } from 'react'
|
||||
import { sectionText } from '@/lib/i18n/cases'
|
||||
import { useSectionsForm } from './SectionsForm'
|
||||
import type { SaveSection } from './types'
|
||||
|
||||
type Props = {
|
||||
@@ -31,6 +32,10 @@ const AUTOSAVE_MS = 2000
|
||||
* 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.
|
||||
*/
|
||||
@@ -44,6 +49,49 @@ export function SectionEditor(props: Props) {
|
||||
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(() => {
|
||||
@@ -61,6 +109,7 @@ export function SectionEditor(props: Props) {
|
||||
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 {
|
||||
@@ -82,10 +131,12 @@ export function SectionEditor(props: Props) {
|
||||
})
|
||||
|
||||
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 {
|
||||
@@ -139,14 +190,18 @@ export function SectionEditor(props: Props) {
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-2xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={pending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{pending ? sectionText.saving : sectionText.save}
|
||||
</button>
|
||||
{/* 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}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { de } from '@/lib/i18n/de'
|
||||
import { fillPlaceholders, isInputField } from '@/lib/templates/fields'
|
||||
import { RedactedText } from '@/components/Redacted'
|
||||
import { SectionEditor } from './SectionEditor'
|
||||
import { SectionsForm } from './SectionsForm'
|
||||
import { PhotoField } from '@/components/files/PhotoField'
|
||||
import type { SaveSection } from './types'
|
||||
import type { SaveSection, SaveSections } from './types'
|
||||
import type { RenderedSection } from '@/lib/sections/render'
|
||||
|
||||
/**
|
||||
@@ -13,10 +14,16 @@ import type { RenderedSection } from '@/lib/sections/render'
|
||||
* Feste Textbausteine der Vorlage werden gerendert, nicht bearbeitet — ihre
|
||||
* Platzhalter ziehen die Werte der Eingabefelder. Damit trägt dieselbe
|
||||
* Darstellung ein Formular und einen Vertragstext.
|
||||
*
|
||||
* Gespeichert wird das Blatt als Ganzes: ein Knopf unten, nicht einer je Feld.
|
||||
* Lichtbilder sind die Ausnahme — ein Bild wird hochgeladen und geprüft, das
|
||||
* ist ein eigener Vorgang und keine Feldänderung.
|
||||
*/
|
||||
export function Sections({
|
||||
scope,
|
||||
save,
|
||||
saveAll,
|
||||
saveAllLabel,
|
||||
sections,
|
||||
canEdit,
|
||||
documentNumber,
|
||||
@@ -25,6 +32,9 @@ export function Sections({
|
||||
}: {
|
||||
scope: string
|
||||
save: SaveSection
|
||||
/** Alle geänderten Felder in einem Zug. Fehlt sie, trägt jedes Feld seinen Knopf. */
|
||||
saveAll?: SaveSections
|
||||
saveAllLabel?: string
|
||||
sections: RenderedSection[]
|
||||
canEdit: boolean
|
||||
/** Nur bei Dokumenten gesetzt: Ziel für das Lichtbild. */
|
||||
@@ -42,7 +52,7 @@ export function Sections({
|
||||
sections.filter((s) => s.visible).map((s) => [s.fieldKey, s.value]),
|
||||
)
|
||||
|
||||
return (
|
||||
const blatt = (
|
||||
<dl className="space-y-8">
|
||||
{sections.map((section) => {
|
||||
const field = section.field
|
||||
@@ -148,4 +158,12 @@ export function Sections({
|
||||
})}
|
||||
</dl>
|
||||
)
|
||||
|
||||
if (!canEdit || !saveAll) return blatt
|
||||
|
||||
return (
|
||||
<SectionsForm save={saveAll} label={saveAllLabel ?? de.section.saveAll}>
|
||||
{blatt}
|
||||
</SectionsForm>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useRef, useState, type ReactNode } from 'react'
|
||||
import { sectionText } from '@/lib/i18n/cases'
|
||||
import type { SaveSectionResult, SaveSections } from './types'
|
||||
|
||||
/**
|
||||
* Das Formular um die Abschnitte herum: **ein** Knopf für das ganze Blatt.
|
||||
*
|
||||
* Vorher trug jedes Feld seinen eigenen. Einen Arbeitsvertrag auszufüllen
|
||||
* hiess damit zwölfmal speichern, und wer eines vergass, merkte es erst beim
|
||||
* Nachlesen. Jetzt sammelt dieses Formular ein, was sich geändert hat, und
|
||||
* schickt es in einem Zug.
|
||||
*
|
||||
* Gespeichert wird **je Feld** und nicht als Ganzes. Das ist kein Versehen:
|
||||
* Abschnitte sind einzeln versioniert, und wenn eines zwischenzeitlich von
|
||||
* jemand anderem geändert wurde, soll das die übrigen nicht aufhalten. Die
|
||||
* Rückmeldung sagt dann genau, was durchging und was noch offen ist.
|
||||
*
|
||||
* Die Felder melden sich hier an, statt ihren Zustand nach oben zu geben:
|
||||
* so löst ein Tastendruck in einem Feld kein Neuzeichnen des ganzen Blattes
|
||||
* aus.
|
||||
*/
|
||||
export type FieldHandle = {
|
||||
fieldKey: string
|
||||
/** Der geänderte Wert, oder null wenn unverändert. */
|
||||
collect: () => { fieldKey: string; value: unknown; baseVersion: number } | null
|
||||
apply: (result: SaveSectionResult) => void
|
||||
}
|
||||
|
||||
type Kontext = {
|
||||
register: (handle: FieldHandle) => () => void
|
||||
changed: (fieldKey: string, dirty: boolean) => void
|
||||
pending: boolean
|
||||
}
|
||||
|
||||
const SectionsContext = createContext<Kontext | null>(null)
|
||||
|
||||
export function useSectionsForm(): Kontext | null {
|
||||
return useContext(SectionsContext)
|
||||
}
|
||||
|
||||
export function SectionsForm({
|
||||
save,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
save: SaveSections
|
||||
/** „Dokument speichern" oder „Abschnitte speichern" — je nachdem, was es ist. */
|
||||
label: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const felder = useRef(new Map<string, FieldHandle>())
|
||||
const [geaendert, setGeaendert] = useState<Set<string>>(new Set())
|
||||
const [pending, setPending] = useState(false)
|
||||
const [meldung, setMeldung] = useState<string | null>(null)
|
||||
|
||||
const kontext: Kontext = {
|
||||
register(handle) {
|
||||
felder.current.set(handle.fieldKey, handle)
|
||||
return () => {
|
||||
felder.current.delete(handle.fieldKey)
|
||||
}
|
||||
},
|
||||
changed(fieldKey, dirty) {
|
||||
setGeaendert((bisher) => {
|
||||
if (dirty === bisher.has(fieldKey)) return bisher
|
||||
const naechste = new Set(bisher)
|
||||
if (dirty) naechste.add(fieldKey)
|
||||
else naechste.delete(fieldKey)
|
||||
return naechste
|
||||
})
|
||||
},
|
||||
pending,
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
const eintraege = [...felder.current.values()]
|
||||
.map((handle) => handle.collect())
|
||||
.filter((eintrag) => eintrag !== null)
|
||||
|
||||
if (eintraege.length === 0) {
|
||||
setMeldung(sectionText.unchanged)
|
||||
return
|
||||
}
|
||||
|
||||
setPending(true)
|
||||
setMeldung(null)
|
||||
try {
|
||||
const ergebnisse = await save(eintraege)
|
||||
|
||||
let gut = 0
|
||||
let offen = 0
|
||||
for (const [fieldKey, ergebnis] of Object.entries(ergebnisse)) {
|
||||
felder.current.get(fieldKey)?.apply(ergebnis)
|
||||
if (ergebnis.ok) gut++
|
||||
else offen++
|
||||
}
|
||||
|
||||
setMeldung(offen === 0 ? sectionText.savedCount(gut) : sectionText.partly(gut, offen))
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionsContext.Provider value={kontext}>
|
||||
{children}
|
||||
|
||||
<div className="sticky bottom-0 mt-8 flex flex-wrap items-center gap-4 border-t border-line bg-surface py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void speichern()}
|
||||
disabled={pending}
|
||||
aria-busy={pending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{pending ? sectionText.saving : label}
|
||||
</button>
|
||||
<span className="font-condensed text-2xs text-text-dim">
|
||||
{geaendert.size > 0 ? sectionText.changedCount(geaendert.size) : sectionText.unchanged}
|
||||
</span>
|
||||
{meldung ? <span className="font-condensed text-2xs text-accent">{meldung}</span> : null}
|
||||
</div>
|
||||
</SectionsContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -14,3 +14,25 @@ export type SaveSection = (input: {
|
||||
value: unknown
|
||||
baseVersion: number
|
||||
}) => Promise<SaveSectionResult>
|
||||
|
||||
/**
|
||||
* Ein ganzes Formular auf einmal speichern.
|
||||
*
|
||||
* Vorher trug jedes Feld seinen eigenen Knopf: einen Arbeitsvertrag mit zwölf
|
||||
* Feldern zu schreiben hiess zwölfmal speichern. Jetzt sammelt das Formular
|
||||
* die geänderten Felder ein und schickt sie in einem Zug.
|
||||
*
|
||||
* Zurück kommt ein Ergebnis **je Feld**, nicht eines für alles. Abschnitte
|
||||
* sind einzeln versioniert, und wenn ein Feld zwischenzeitlich von jemand
|
||||
* anderem geändert wurde, soll das nicht die übrigen elf aufhalten — es soll
|
||||
* genau an diesem Feld stehen.
|
||||
*/
|
||||
export type SaveSectionsInput = {
|
||||
fieldKey: string
|
||||
value: unknown
|
||||
baseVersion: number
|
||||
}[]
|
||||
|
||||
export type SaveSections = (
|
||||
input: SaveSectionsInput,
|
||||
) => Promise<Record<string, SaveSectionResult>>
|
||||
|
||||
Reference in New Issue
Block a user