Vier der acht Änderungswünsche. **Knöpfe.** Rund vierzig der siebzig Knöpfe waren blosser Text in Akzentfarbe, von einem Link nicht zu unterscheiden. Jeder trägt jetzt Rahmen und Grundfläche, über die Klasse .btn an einer Stelle definiert statt siebzigmal als Klassenkette. Beim Drücken zieht der Knopf sich zusammen und bekommt einen inneren Ring - er wirkt kurz dicker, ohne dass die Rahmenbreite sich ändert und das Umfeld springt; die Rückmeldung setzt ohne Verzögerung ein und klingt langsam ab, sonst bliebe ein kurzer Klick unsichtbar. Dazu die neue Regel: Flächen bleiben eckig, alles Bedienbare ist rund. Damit ist die Rundung eine Auskunft und keine Verzierung. **Verwaltung.** Rollen, Vorlagen und Änderungsrichtlinie waren drei Einträge in der Kopfzeile für etwas, das man selten und dann zusammenhängend braucht. Jetzt ein Eintrag /admin mit Reiterleiste, dazu eine gemeinsame Tabs-Komponente, die auch der Fallakte zeigt, welcher Reiter offen ist. **Kanäle.** Ersatzlos entfernt, mitsamt Tabelle. Der Chat gehört dorthin, wo der Vorgang steht; ein eigener Reiter mit freien Räumen war der Rückweg nach Discord. Die Personalakte hat jetzt denselben Chat wie die Fallakte - ohne „als Vermerk übernehmen", denn dafür braucht es einen Verlauf, und Meldungen gehen nur an Erwähnte, nie in den allgemeinen Kanal: dass es zu jemandem eine Personalakte gibt, ist selbst eine Auskunft. Ein CHECK erzwingt jetzt, dass eine Nachricht zu genau einem Ort gehört. **Kopfzeile.** Suchbalken statt Suchlink, als gewöhnliches Formular ohne ein Byte im Bündel. Thema, Benachrichtigungen und Abmelden sitzen hinter dem Profilbild, das von Discord kommt; wer keins hat, bekommt seine Initialen. Nebenbei: Aktenzeichen in Tests waren nur auf vier Stellen eindeutig und kollidierten zwischen Läufen, weil Akten nicht löschbar sind.
214 lines
7.1 KiB
TypeScript
214 lines
7.1 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef, useState, useTransition } from 'react'
|
|
import { sectionText } from '@/lib/i18n/cases'
|
|
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.
|
|
*
|
|
* 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)
|
|
|
|
// 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 props.save({
|
|
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="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">
|
|
<button
|
|
type="button"
|
|
onClick={() => submit()}
|
|
disabled={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
|
|
}
|