Phase 3 abgeschlossen: Vorlagen, Abschnitte, Schwärzung, Diff
Eine Vorlage ist eine geordnete Liste von Bausteinen: Eingabefelder und feste Textbausteine mit Platzhaltern. Damit trägt dieselbe Struktur den Vorfallsbericht und den Sonderarbeitsvertrag. Die Startvorlagen bilden jetzt den echten Bericht der Abteilung ab, samt Berichtsart, Gefahreneinschätzung und Foundation-Freigabe — letztere als gewöhnliches Feld ohne Wirkung auf den Zugriff. Abschnitte werden gerendert und bearbeitet, Entwürfe alle zwei Sekunden lokal gesichert. Gespeichert wird auf Knopfdruck: ein Abschnitt ist der Sachstand der Akte, kein Chatbeitrag. Die geladene Version geht mit; weicht sie ab, gibt es kein stilles Überschreiben, sondern beide Fassungen nebeneinander mit "Meine übernehmen" und "Fremde übernehmen". Felder über der Stufe des Lesers werden serverseitig durch einen Platzhalter ersetzt, Schwärzungen im Fliesstext kommen als Segmente. Beides so, dass der echte Wert den Server nicht verlässt — nicht als Ausblenden im Client. Der Wortdiff ist selbst geschrieben statt eingekauft: ein Dutzend Zeilen gegen 30 kB im Bündel für etwas, das nur serverseitig läuft. Beim Bauen des Vorlagen-Editors riss das Bündel-Budget auf 127,9 kB, weil zod über das Vorlagenmodul in den Client geriet. Typen und Validierung sind jetzt getrennt: fields.ts ohne zod für Client und Server, schema.ts nur serverseitig. Wieder bei 111,9 kB von 120. 85 Tests, 22 E2E.
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useTransition } from 'react'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { saveSectionAction } from './sectionActions'
|
||||
|
||||
type Props = {
|
||||
caseNumber: 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.
|
||||
*
|
||||
* 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.caseNumber}:${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 saveSectionAction({
|
||||
caseNumber: props.caseNumber,
|
||||
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="border border-line bg-raised px-3 py-2 text-sm"
|
||||
>
|
||||
<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) border border-line bg-raised px-3 py-2 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<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) border border-line bg-raised px-3 py-2 text-sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-2xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={pending}
|
||||
className="border border-line bg-raised px-3 py-1 font-condensed text-accent disabled:text-text-dim"
|
||||
>
|
||||
{pending ? de.section.saving : de.section.save}
|
||||
</button>
|
||||
{version > 0 ? (
|
||||
<span className="font-mono text-text-dim">{de.section.version(version)}</span>
|
||||
) : null}
|
||||
{saved ? <span className="font-condensed text-text-dim">{de.section.saved}</span> : null}
|
||||
{draftKept && !saved ? (
|
||||
<span className="font-condensed text-text-dim">{de.section.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">{de.section.conflict}</p>
|
||||
<div className="mt-3 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="font-condensed text-2xs text-text-dim">{de.section.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 font-condensed text-2xs text-accent"
|
||||
>
|
||||
{de.section.takeMine}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-condensed text-2xs text-text-dim">{de.section.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 font-condensed text-2xs text-accent"
|
||||
>
|
||||
{de.section.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
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { fillPlaceholders, isInputField } from '@/lib/templates/fields'
|
||||
import { RedactedText } from '@/components/Redacted'
|
||||
import { SectionEditor } from './SectionEditor'
|
||||
import type { RenderedSection } from '@/lib/cases/sectionService'
|
||||
|
||||
/**
|
||||
* Abschnitte einer Fallakte: der aktuelle Sachstand, aus der Vorlage erzeugt.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function Sections({
|
||||
caseNumber,
|
||||
sections,
|
||||
canEdit,
|
||||
}: {
|
||||
caseNumber: string
|
||||
sections: RenderedSection[]
|
||||
canEdit: boolean
|
||||
}) {
|
||||
if (sections.length === 0) {
|
||||
return <p className="text-text-dim">{de.section.empty}</p>
|
||||
}
|
||||
|
||||
const werte = Object.fromEntries(
|
||||
sections.filter((s) => s.visible).map((s) => [s.fieldKey, s.value]),
|
||||
)
|
||||
|
||||
return (
|
||||
<dl className="space-y-8">
|
||||
{sections.map((section) => {
|
||||
const field = section.field
|
||||
|
||||
// Feste Bausteine der Vorlage.
|
||||
if (!isInputField(field)) {
|
||||
if (field.type === 'pagebreak') return <hr key={field.key} className="border-line" />
|
||||
if (field.type === 'signature') {
|
||||
return (
|
||||
<div key={field.key} className="pt-8">
|
||||
<div className="w-64 border-t border-line" />
|
||||
<p className="mt-1 text-2xs text-text-dim">{field.label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const text = fillPlaceholders(field.text ?? '', werte)
|
||||
return field.type === 'heading' ? (
|
||||
<h3 key={field.key} className="font-condensed">
|
||||
{text || field.label}
|
||||
</h3>
|
||||
) : (
|
||||
<p key={field.key} className="max-w-(--container-measure) text-sm whitespace-pre-wrap">
|
||||
{text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<dt className="flex flex-wrap items-baseline gap-x-3">
|
||||
<span className="font-condensed text-xs text-text-dim">{field.label}</span>
|
||||
{field.requiredToClose ? (
|
||||
<span className="font-condensed text-2xs text-stamp-3">
|
||||
{de.section.requiredToClose}
|
||||
</span>
|
||||
) : null}
|
||||
</dt>
|
||||
<dd>
|
||||
{!section.visible ? (
|
||||
// Serverseitig ersetzt: der echte Wert ist gar nicht erst hier.
|
||||
<p className="mt-1.5 flex items-center gap-2">
|
||||
<span className="redaction inline-block h-[1.1rem] w-40" aria-hidden />
|
||||
<span className="font-mono text-2xs text-text-dim">
|
||||
{de.section.lockedField(section.value.requiredLevel)}
|
||||
</span>
|
||||
</p>
|
||||
) : section.segments ? (
|
||||
<div className="mt-1.5">
|
||||
<RedactedText segments={section.segments} />
|
||||
{canEdit ? (
|
||||
<SectionEditor
|
||||
caseNumber={caseNumber}
|
||||
fieldKey={field.key}
|
||||
label={field.label}
|
||||
type={field.type}
|
||||
options={field.options}
|
||||
requiredToClose={field.requiredToClose}
|
||||
initialValue={section.value}
|
||||
initialVersion={section.version}
|
||||
readOnly={false}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<SectionEditor
|
||||
caseNumber={caseNumber}
|
||||
fieldKey={field.key}
|
||||
label={field.label}
|
||||
type={field.type}
|
||||
options={field.options}
|
||||
requiredToClose={field.requiredToClose}
|
||||
initialValue={section.value}
|
||||
initialVersion={section.version}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { requireUser } from '@/lib/auth/current'
|
||||
import { loadVisibleCase } from '@/lib/cases/service'
|
||||
import { loadChangeHistory } from '@/lib/cases/entryService'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { wordDiff } from '@/lib/diff'
|
||||
|
||||
export const metadata: Metadata = { title: de.entry.changesHeading }
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -72,20 +73,23 @@ export default async function ChangesPage({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="font-condensed text-2xs text-text-dim">{de.entry.before}</p>
|
||||
<p className="mt-1 border-l-(length:--spacing-edge) border-line pl-3 text-sm whitespace-pre-wrap">
|
||||
{version.body}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-condensed text-2xs text-text-dim">{de.entry.after}</p>
|
||||
<p className="mt-1 border-l-(length:--spacing-edge) border-steel pl-3 text-sm whitespace-pre-wrap">
|
||||
{version.entry.body}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Wortdiff statt zweier Spalten: bei langen Vermerken sieht man
|
||||
sonst nicht, was sich geändert hat. */}
|
||||
<p className="mt-3 max-w-(--container-measure) text-sm whitespace-pre-wrap">
|
||||
{wordDiff(version.body, version.entry.body).map((teil, index) =>
|
||||
teil.kind === 'gleich' ? (
|
||||
<span key={index}>{teil.text}</span>
|
||||
) : teil.kind === 'entfernt' ? (
|
||||
<del key={index} className="text-stamp-4 decoration-stamp-4">
|
||||
{teil.text}
|
||||
</del>
|
||||
) : (
|
||||
<ins key={index} className="text-stamp-2 no-underline">
|
||||
{teil.text}
|
||||
</ins>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
@@ -10,6 +10,9 @@ import { de } from '@/lib/i18n/de'
|
||||
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
|
||||
import { SkeletonLine } from '@/components/Skeleton'
|
||||
import { EntryComposer } from './EntryComposer'
|
||||
import { Sections } from './Sections'
|
||||
import { loadSections } from '@/lib/cases/sectionService'
|
||||
import { canEditCase } from '@/lib/access/caseWrite'
|
||||
import { CaseActions } from './CaseActions'
|
||||
import { EntryActions } from './EntryActions'
|
||||
import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle'
|
||||
@@ -129,6 +132,23 @@ async function Timeline({
|
||||
)
|
||||
}
|
||||
|
||||
async function SectionBlock({
|
||||
caseId,
|
||||
caseNumber,
|
||||
templateId,
|
||||
permissions,
|
||||
canEdit,
|
||||
}: {
|
||||
caseId: string
|
||||
caseNumber: string
|
||||
templateId: string
|
||||
permissions: EffectivePermissions
|
||||
canEdit: boolean
|
||||
}) {
|
||||
const sections = await loadSections(permissions, caseId, templateId)
|
||||
return <Sections caseNumber={caseNumber} sections={sections} canEdit={canEdit} />
|
||||
}
|
||||
|
||||
export default async function CasePage({
|
||||
params,
|
||||
}: {
|
||||
@@ -192,7 +212,31 @@ export default async function CasePage({
|
||||
/>
|
||||
</header>
|
||||
|
||||
<nav className="mt-8 flex gap-4 border-b border-line pb-2 font-condensed text-xs">
|
||||
<section className="mt-10">
|
||||
<h2 className="font-condensed">{de.section.heading}</h2>
|
||||
<div className="mt-4">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<SkeletonLine width="10rem" />
|
||||
<SkeletonLine />
|
||||
<SkeletonLine width="12rem" />
|
||||
<SkeletonLine />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SectionBlock
|
||||
caseId={akte.id}
|
||||
caseNumber={akte.caseNumber}
|
||||
templateId={akte.templateId}
|
||||
permissions={user.permissions}
|
||||
canEdit={canEditCase(user.permissions, akte).ok}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav className="mt-10 flex gap-4 border-b border-line pb-2 font-condensed text-xs">
|
||||
<span className="text-text-dim">{de.case.timeline}</span>
|
||||
<Link href={`/faelle/${akte.caseNumber}/aenderungen`}>{de.case.changes}</Link>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { z } from 'zod'
|
||||
import { requireUser } from '@/lib/auth/current'
|
||||
import { AccessError } from '@/lib/cases/service'
|
||||
import { ConflictError, saveSection } from '@/lib/cases/sectionService'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
|
||||
const schema = z.object({
|
||||
caseNumber: z.string().min(1),
|
||||
fieldKey: z.string().min(1),
|
||||
value: z.unknown(),
|
||||
baseVersion: z.coerce.number().int().min(0),
|
||||
minClearance: z.coerce.number().int().min(0).max(5).optional(),
|
||||
})
|
||||
|
||||
export type SaveSectionResult =
|
||||
| { ok: true; version: number }
|
||||
/** Kein stilles Überschreiben: die fremde Fassung kommt mit zurück. */
|
||||
| { ok: false; conflict: true; current: { value: unknown; version: number } }
|
||||
| { ok: false; conflict: false; error: string }
|
||||
|
||||
export async function saveSectionAction(input: unknown): Promise<SaveSectionResult> {
|
||||
const user = await requireUser()
|
||||
const parsed = schema.safeParse(input)
|
||||
if (!parsed.success) return { ok: false, conflict: false, error: de.case.errors.default! }
|
||||
|
||||
try {
|
||||
const { version } = await saveSection(user.permissions, parsed.data)
|
||||
revalidatePath(`/faelle/${parsed.data.caseNumber}`)
|
||||
return { ok: true, version }
|
||||
} catch (error) {
|
||||
if (error instanceof ConflictError) {
|
||||
return {
|
||||
ok: false,
|
||||
conflict: true,
|
||||
current: { value: error.current.value, version: error.current.version },
|
||||
}
|
||||
}
|
||||
if (error instanceof AccessError) {
|
||||
return { ok: false, conflict: false, error: de.case.errors[error.code] ?? de.case.errors.default! }
|
||||
}
|
||||
console.error('[abschnitt] unerwartet', error)
|
||||
return { ok: false, conflict: false, error: de.case.errors.default! }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user