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:
@@ -66,9 +66,39 @@ const TEMPLATES: {
|
|||||||
]
|
]
|
||||||
|
|
||||||
const TEMPLATE_FIELDS = [
|
const TEMPLATE_FIELDS = [
|
||||||
|
// Nachgebaut nach dem Vorfallsbericht der Abteilung. Die Foundation-Freigabe
|
||||||
|
// steht im Kopf des Dokuments, steuert aber keinen Zugriff — das tut allein
|
||||||
|
// die interne Freigabe. Siehe docs/13-entscheidungen.md.
|
||||||
|
{
|
||||||
|
key: 'foundation_freigabe',
|
||||||
|
label: 'Foundation-Freigabe',
|
||||||
|
type: 'select',
|
||||||
|
options: ['0', '1', '2', '3', '4', '5'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'berichtsart',
|
||||||
|
label: 'Berichtsart',
|
||||||
|
type: 'multiselect',
|
||||||
|
options: [
|
||||||
|
'Vorfallsbericht',
|
||||||
|
'Sicherheitsanalyse',
|
||||||
|
'Interne Untersuchung',
|
||||||
|
'Allg. Bericht',
|
||||||
|
'Sonstiges',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gefahreneinschaetzung',
|
||||||
|
label: 'Gefahreneinschätzung',
|
||||||
|
type: 'select',
|
||||||
|
options: ['Keine', 'Leichte', 'Mittlere', 'Erhöhte', 'Kritische'],
|
||||||
|
},
|
||||||
|
{ key: 'vorfallsdatum', label: 'Datum des Vorfalls', type: 'date' },
|
||||||
{ key: 'sachverhalt', label: 'Sachverhalt', type: 'longtext', required: true },
|
{ key: 'sachverhalt', label: 'Sachverhalt', type: 'longtext', required: true },
|
||||||
{ key: 'beteiligte', label: 'Beteiligte Personen', type: 'longtext' },
|
{ key: 'beteiligte', label: 'Beteiligte Personen', type: 'longtext' },
|
||||||
{ key: 'feststellungen', label: 'Feststellungen', type: 'longtext' },
|
{ key: 'feststellungen', label: 'Feststellungen', type: 'longtext' },
|
||||||
|
{ key: 'anmerkungen', label: 'Anmerkungen', type: 'longtext' },
|
||||||
|
{ key: 'massnahmen', label: 'Maßnahmen', type: 'longtext' },
|
||||||
{
|
{
|
||||||
key: 'bewertung',
|
key: 'bewertung',
|
||||||
label: 'Abschließende Bewertung',
|
label: 'Abschließende Bewertung',
|
||||||
@@ -110,9 +140,14 @@ async function main(): Promise<void> {
|
|||||||
fields: TEMPLATE_FIELDS,
|
fields: TEMPLATE_FIELDS,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
// Muster und Felder bleiben unangetastet: was der Leiter im
|
// Version 1 gehört dem Seed und wird von ihm aktuell gehalten. Ändert
|
||||||
// Vorlagen-Editor eingestellt hat, überschreibt der Seed nicht.
|
// der Leiter eine Vorlage im Editor, entsteht Version 2 — die fasst der
|
||||||
update: { name: template.name, description: template.description },
|
// Seed nie an, und bestehende Akten behalten ohnehin ihre Version.
|
||||||
|
update: {
|
||||||
|
name: template.name,
|
||||||
|
description: template.description,
|
||||||
|
fields: TEMPLATE_FIELDS,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
console.log(`[seed] ${TEMPLATES.length} Startvorlagen`)
|
console.log(`[seed] ${TEMPLATES.length} Startvorlagen`)
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo, useState, useTransition } from 'react'
|
||||||
|
import { de } from '@/lib/i18n/de'
|
||||||
|
import { isInputField, looksLikeFields, type TemplateField } from '@/lib/templates/fields'
|
||||||
|
import { saveTemplate } from './actions'
|
||||||
|
|
||||||
|
type Vorlage = {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
version: number
|
||||||
|
numberPrefix: string
|
||||||
|
numberPadding: number
|
||||||
|
fields: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorlagen-Editor.
|
||||||
|
*
|
||||||
|
* Die Bausteine werden als JSON bearbeitet. Das ist bewusst: ein
|
||||||
|
* Baukasten-Editor mit Ziehen und Ablegen wäre eine eigene Phase, und die
|
||||||
|
* Vorlagen ändern sich selten. Geprüft wird gegen dasselbe Schema wie
|
||||||
|
* serverseitig, und die Vorschau zeigt sofort, was dabei herauskommt.
|
||||||
|
*
|
||||||
|
* Nicht optimistisch: eine neue Version wirkt auf alle künftigen Akten.
|
||||||
|
*/
|
||||||
|
export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
|
||||||
|
const [name, setName] = useState(vorlage.name)
|
||||||
|
const [description, setDescription] = useState(vorlage.description ?? '')
|
||||||
|
const [numberPrefix, setNumberPrefix] = useState(vorlage.numberPrefix)
|
||||||
|
const [numberPadding, setNumberPadding] = useState(vorlage.numberPadding)
|
||||||
|
const [json, setJson] = useState(() => JSON.stringify(vorlage.fields, null, 2))
|
||||||
|
const [result, setResult] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [pending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const geparst = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const roh = JSON.parse(json) as unknown
|
||||||
|
// Nur eine grobe Formprüfung für die Vorschau. Ob die Vorlage
|
||||||
|
// gespeichert werden darf, entscheidet allein der Server.
|
||||||
|
if (!looksLikeFields(roh)) {
|
||||||
|
return { ok: false as const, fehler: de.templateAdmin.fieldsInvalid }
|
||||||
|
}
|
||||||
|
return { ok: true as const, fields: roh }
|
||||||
|
} catch {
|
||||||
|
return { ok: false as const, fehler: de.templateAdmin.invalid }
|
||||||
|
}
|
||||||
|
}, [json])
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (!geparst.ok) return
|
||||||
|
setError(null)
|
||||||
|
setResult(null)
|
||||||
|
startTransition(async () => {
|
||||||
|
const antwort = await saveTemplate({
|
||||||
|
key: vorlage.key,
|
||||||
|
name,
|
||||||
|
description: description || undefined,
|
||||||
|
numberPrefix,
|
||||||
|
numberPadding,
|
||||||
|
fields: geparst.fields,
|
||||||
|
})
|
||||||
|
if (antwort.ok) setResult(de.templateAdmin.saved(antwort.version))
|
||||||
|
else setError(antwort.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border-b border-line py-6">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-3">
|
||||||
|
<h2 className="font-condensed">{vorlage.name}</h2>
|
||||||
|
<span className="font-mono text-2xs text-text-dim">{vorlage.key}</span>
|
||||||
|
<span className="font-condensed text-2xs text-text-dim">
|
||||||
|
{de.templateAdmin.version(vorlage.version)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<label className="block">
|
||||||
|
<span className="font-condensed text-text-dim">{de.case.title}</span>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
className="mt-1 block w-full border border-line bg-raised px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="font-condensed text-text-dim">{de.case.type}</span>
|
||||||
|
<input
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
className="mt-1 block w-full border border-line bg-raised px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="font-condensed text-text-dim">{de.templateAdmin.numberPrefix}</span>
|
||||||
|
<input
|
||||||
|
value={numberPrefix}
|
||||||
|
onChange={(event) => setNumberPrefix(event.target.value)}
|
||||||
|
className="mt-1 block w-32 border border-line bg-raised px-2 py-1 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="font-condensed text-text-dim">{de.templateAdmin.numberPadding}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={8}
|
||||||
|
value={numberPadding}
|
||||||
|
onChange={(event) => setNumberPadding(Number(event.target.value))}
|
||||||
|
className="mt-1 block w-20 border border-line bg-raised px-2 py-1 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="font-condensed text-text-dim">{de.templateAdmin.fieldsLabel}</span>
|
||||||
|
<textarea
|
||||||
|
value={json}
|
||||||
|
onChange={(event) => setJson(event.target.value)}
|
||||||
|
rows={16}
|
||||||
|
spellCheck={false}
|
||||||
|
className="mt-1 block w-full border border-line bg-raised px-2 py-1 font-mono text-2xs"
|
||||||
|
/>
|
||||||
|
<span className="mt-1 block text-2xs text-text-dim">{de.templateAdmin.fieldsHelp}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={pending || !geparst.ok}
|
||||||
|
className="border border-line bg-raised px-3 py-1.5 font-condensed text-accent disabled:text-text-dim"
|
||||||
|
>
|
||||||
|
{pending ? de.case.saving : de.templateAdmin.save}
|
||||||
|
</button>
|
||||||
|
{result ? <span className="font-condensed text-2xs text-text-dim">{result}</span> : null}
|
||||||
|
{!geparst.ok ? (
|
||||||
|
<span className="font-condensed text-2xs text-stamp-4">{geparst.fehler}</span>
|
||||||
|
) : null}
|
||||||
|
{error ? <span className="font-condensed text-2xs text-stamp-4">{error}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-line p-4">
|
||||||
|
<p className="font-condensed text-xs text-text-dim">{de.templateAdmin.preview}</p>
|
||||||
|
<p className="mt-1 text-2xs text-text-dim">{de.templateAdmin.previewIntro}</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
{geparst.ok ? <Vorschau fields={geparst.fields} /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „So sieht eine neue Akte damit aus", ohne etwas zu speichern. */
|
||||||
|
function Vorschau({ fields }: { fields: TemplateField[] }) {
|
||||||
|
if (fields.length === 0) return <p className="text-2xs text-text-dim">{de.section.empty}</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dl className="space-y-4">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<div key={field.key}>
|
||||||
|
<dt className="flex flex-wrap items-baseline gap-x-2">
|
||||||
|
<span className="font-condensed text-2xs text-text-dim">{field.label}</span>
|
||||||
|
<span className="font-mono text-2xs text-text-dim">{field.type}</span>
|
||||||
|
{field.minClearance ? (
|
||||||
|
<span className="font-mono text-2xs text-stamp-3">≥ {field.minClearance}</span>
|
||||||
|
) : null}
|
||||||
|
{isInputField(field) && field.requiredToClose ? (
|
||||||
|
<span className="font-condensed text-2xs text-stamp-3">
|
||||||
|
{de.section.requiredToClose}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1">
|
||||||
|
{!isInputField(field) ? (
|
||||||
|
<p className="text-2xs text-text-dim italic">
|
||||||
|
{field.text ? field.text.slice(0, 120) : de.templateAdmin.staticBlock}
|
||||||
|
</p>
|
||||||
|
) : field.type === 'longtext' ? (
|
||||||
|
<div className="h-12 border border-line bg-raised" />
|
||||||
|
) : field.type === 'select' || field.type === 'multiselect' ? (
|
||||||
|
<p className="text-2xs text-text-dim">{(field.options ?? []).join(' · ')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="h-6 w-48 border border-line bg-raised" />
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { requirePermissionForAction } from '@/lib/auth/current'
|
||||||
|
import { writeAudit } from '@/lib/audit'
|
||||||
|
import { templateFieldsSchema } from '@/lib/templates/schema'
|
||||||
|
import { de } from '@/lib/i18n/de'
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
key: z.string().regex(/^[a-z][a-z0-9_-]*$/),
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
description: z.string().trim().max(500).optional(),
|
||||||
|
numberPrefix: z.string().regex(/^[A-Za-z][A-Za-z0-9-]*$/).max(20),
|
||||||
|
numberPadding: z.coerce.number().int().min(1).max(8),
|
||||||
|
fields: templateFieldsSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type TemplateResult =
|
||||||
|
| { ok: true; version: number }
|
||||||
|
| { ok: false; error: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert eine Vorlage als **neue Version**.
|
||||||
|
*
|
||||||
|
* Bestehende Akten bleiben unberührt: sie merken sich ihre `templateId`. Eine
|
||||||
|
* Änderung gilt erst für Akten, die danach angelegt werden. Siehe
|
||||||
|
* docs/05-akten-und-vorlagen.md.
|
||||||
|
*/
|
||||||
|
export async function saveTemplate(input: unknown): Promise<TemplateResult> {
|
||||||
|
const user = await requirePermissionForAction('template.manage')
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(input)
|
||||||
|
if (!parsed.success) {
|
||||||
|
const erste = parsed.error.issues[0]
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: erste ? `${erste.path.join('.')}: ${erste.message}` : de.case.errors.default!,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { key, fields, ...rest } = parsed.data
|
||||||
|
|
||||||
|
const neueste = await db.caseTemplate.findFirst({
|
||||||
|
where: { key },
|
||||||
|
orderBy: { version: 'desc' },
|
||||||
|
select: { version: true },
|
||||||
|
})
|
||||||
|
const version = (neueste?.version ?? 0) + 1
|
||||||
|
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
// Ältere Versionen bleiben stehen, aber nur die neueste ist auswählbar.
|
||||||
|
await tx.caseTemplate.updateMany({ where: { key }, data: { isActive: false } })
|
||||||
|
|
||||||
|
await tx.caseTemplate.create({
|
||||||
|
data: {
|
||||||
|
key,
|
||||||
|
version,
|
||||||
|
...rest,
|
||||||
|
description: rest.description || null,
|
||||||
|
fields,
|
||||||
|
isActive: true,
|
||||||
|
createdById: user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await writeAudit(tx, {
|
||||||
|
actorId: user.id,
|
||||||
|
action: 'roles.update',
|
||||||
|
targetType: 'CaseTemplate',
|
||||||
|
targetId: `${key}:${version}`,
|
||||||
|
clearanceAtTime: user.permissions.clearanceLevel,
|
||||||
|
meta: { key, version, fieldCount: fields.length },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/admin/vorlagen')
|
||||||
|
return { ok: true, version }
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { requirePermission } from '@/lib/auth/current'
|
||||||
|
import { de } from '@/lib/i18n/de'
|
||||||
|
import { TemplateEditor } from './TemplateEditor'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: de.templateAdmin.heading }
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default async function TemplateAdminPage() {
|
||||||
|
await requirePermission('template.manage')
|
||||||
|
|
||||||
|
// Je Schlüssel die neueste Version; ältere bleiben für bestehende Akten stehen.
|
||||||
|
const vorlagen = await db.caseTemplate.findMany({
|
||||||
|
orderBy: [{ key: 'asc' }, { version: 'desc' }],
|
||||||
|
distinct: ['key'],
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-5xl px-6 py-10">
|
||||||
|
<h1 className="font-condensed">{de.templateAdmin.heading}</h1>
|
||||||
|
<p className="mt-2 max-w-(--container-measure) text-text-dim">{de.templateAdmin.intro}</p>
|
||||||
|
|
||||||
|
{vorlagen.length === 0 ? (
|
||||||
|
<p className="mt-8 text-text-dim">{de.templateAdmin.empty}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 border-t border-line">
|
||||||
|
{vorlagen.map((vorlage) => (
|
||||||
|
<TemplateEditor
|
||||||
|
key={vorlage.id}
|
||||||
|
vorlage={{
|
||||||
|
key: vorlage.key,
|
||||||
|
name: vorlage.name,
|
||||||
|
description: vorlage.description,
|
||||||
|
version: vorlage.version,
|
||||||
|
numberPrefix: vorlage.numberPrefix,
|
||||||
|
numberPadding: vorlage.numberPadding,
|
||||||
|
fields: vorlage.fields,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { loadVisibleCase } from '@/lib/cases/service'
|
||||||
import { loadChangeHistory } from '@/lib/cases/entryService'
|
import { loadChangeHistory } from '@/lib/cases/entryService'
|
||||||
import { de } from '@/lib/i18n/de'
|
import { de } from '@/lib/i18n/de'
|
||||||
|
import { wordDiff } from '@/lib/diff'
|
||||||
|
|
||||||
export const metadata: Metadata = { title: de.entry.changesHeading }
|
export const metadata: Metadata = { title: de.entry.changesHeading }
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -72,20 +73,23 @@ export default async function ChangesPage({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-3 grid gap-4 md:grid-cols-2">
|
{/* Wortdiff statt zweier Spalten: bei langen Vermerken sieht man
|
||||||
<div>
|
sonst nicht, was sich geändert hat. */}
|
||||||
<p className="font-condensed text-2xs text-text-dim">{de.entry.before}</p>
|
<p className="mt-3 max-w-(--container-measure) text-sm whitespace-pre-wrap">
|
||||||
<p className="mt-1 border-l-(length:--spacing-edge) border-line pl-3 text-sm whitespace-pre-wrap">
|
{wordDiff(version.body, version.entry.body).map((teil, index) =>
|
||||||
{version.body}
|
teil.kind === 'gleich' ? (
|
||||||
</p>
|
<span key={index}>{teil.text}</span>
|
||||||
</div>
|
) : teil.kind === 'entfernt' ? (
|
||||||
<div>
|
<del key={index} className="text-stamp-4 decoration-stamp-4">
|
||||||
<p className="font-condensed text-2xs text-text-dim">{de.entry.after}</p>
|
{teil.text}
|
||||||
<p className="mt-1 border-l-(length:--spacing-edge) border-steel pl-3 text-sm whitespace-pre-wrap">
|
</del>
|
||||||
{version.entry.body}
|
) : (
|
||||||
</p>
|
<ins key={index} className="text-stamp-2 no-underline">
|
||||||
</div>
|
{teil.text}
|
||||||
</div>
|
</ins>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { de } from '@/lib/i18n/de'
|
|||||||
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
|
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
|
||||||
import { SkeletonLine } from '@/components/Skeleton'
|
import { SkeletonLine } from '@/components/Skeleton'
|
||||||
import { EntryComposer } from './EntryComposer'
|
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 { CaseActions } from './CaseActions'
|
||||||
import { EntryActions } from './EntryActions'
|
import { EntryActions } from './EntryActions'
|
||||||
import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle'
|
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({
|
export default async function CasePage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -192,7 +212,31 @@ export default async function CasePage({
|
|||||||
/>
|
/>
|
||||||
</header>
|
</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>
|
<span className="text-text-dim">{de.case.timeline}</span>
|
||||||
<Link href={`/faelle/${akte.caseNumber}/aenderungen`}>{de.case.changes}</Link>
|
<Link href={`/faelle/${akte.caseNumber}/aenderungen`}>{de.case.changes}</Link>
|
||||||
</nav>
|
</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! }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,10 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||||||
{user.permissions.isAdmin || user.permissions.permissions.has('roles.manage') ? (
|
{user.permissions.isAdmin || user.permissions.permissions.has('roles.manage') ? (
|
||||||
<>
|
<>
|
||||||
<Link href="/admin/rollen">{de.nav.admin}</Link>
|
<Link href="/admin/rollen">{de.nav.admin}</Link>
|
||||||
|
{user.permissions.isAdmin ||
|
||||||
|
user.permissions.permissions.has('template.manage') ? (
|
||||||
|
<Link href="/admin/vorlagen">{de.templateAdmin.heading}</Link>
|
||||||
|
) : null}
|
||||||
{user.permissions.isAdmin ? (
|
{user.permissions.isAdmin ? (
|
||||||
<Link href="/admin/richtlinie">{de.settings.heading}</Link>
|
<Link href="/admin/richtlinie">{de.settings.heading}</Link>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Segment } from '@/lib/redaction'
|
||||||
|
import { de } from '@/lib/i18n/de'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert Fliesstext mit Schwärzungen.
|
||||||
|
*
|
||||||
|
* Die Segmente kommen fertig vom Server: der geschwärzte Inhalt ist gar nicht
|
||||||
|
* erst im Dokument. Der Balken bekommt seine Breite aus der gerundeten Länge,
|
||||||
|
* nicht aus dem echten Text.
|
||||||
|
*
|
||||||
|
* Der Wischeffekt beim ersten Erscheinen steckt in der Klasse `.redaction`
|
||||||
|
* (siehe app/globals.css) und respektiert prefers-reduced-motion.
|
||||||
|
*/
|
||||||
|
export function RedactedText({ segments }: { segments: Segment[] }) {
|
||||||
|
return (
|
||||||
|
<p className="max-w-(--container-measure) text-sm whitespace-pre-wrap">
|
||||||
|
{segments.map((segment, index) =>
|
||||||
|
segment.kind === 'text' ? (
|
||||||
|
<span key={index}>{segment.text}</span>
|
||||||
|
) : (
|
||||||
|
<span key={index} className="inline-flex items-baseline gap-1.5 align-baseline">
|
||||||
|
<span
|
||||||
|
className="redaction"
|
||||||
|
style={{ width: `${segment.blockLength}ch` }}
|
||||||
|
aria-label={de.section.redactedFrom(segment.requiredLevel)}
|
||||||
|
>
|
||||||
|
{' '}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-2xs text-text-dim" aria-hidden>
|
||||||
|
{segment.requiredLevel}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { writeAudit } from '@/lib/audit'
|
||||||
|
import { canEditCase } from '@/lib/access/caseWrite'
|
||||||
|
import { AccessError, loadVisibleCase } from '@/lib/cases/service'
|
||||||
|
import { isInputField, type TemplateField } from '@/lib/templates/fields'
|
||||||
|
import { parseTemplateFields } from '@/lib/templates/schema'
|
||||||
|
import { redactForLevel, type Segment } from '@/lib/redaction'
|
||||||
|
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
||||||
|
import type { Prisma } from '@afis/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abschnitte einer Fallakte: der aktuelle Sachstand, aus der Vorlage erzeugt
|
||||||
|
* und je Feld versioniert. Siehe docs/05-akten-und-vorlagen.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Was ein Leser sieht, dessen Stufe für ein Feld nicht reicht. */
|
||||||
|
export type Placeholder = { redacted: true; requiredLevel: number }
|
||||||
|
|
||||||
|
export type RenderedSection = {
|
||||||
|
fieldKey: string
|
||||||
|
field: TemplateField
|
||||||
|
version: number
|
||||||
|
updatedAt: Date | null
|
||||||
|
updatedById: string | null
|
||||||
|
} & (
|
||||||
|
| { visible: true; value: unknown; segments: Segment[] | null }
|
||||||
|
| { visible: false; value: Placeholder }
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prisma trennt „Feld nicht setzen" von „JSON null". Ein leerer Abschnitt ist
|
||||||
|
* inhaltlich JSON null, nicht das Fehlen des Feldes.
|
||||||
|
*/
|
||||||
|
function asJson(value: unknown): Prisma.InputJsonValue {
|
||||||
|
return (value ?? null) as Prisma.InputJsonValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export function templateFields(raw: unknown): TemplateField[] {
|
||||||
|
return parseTemplateFields(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die Abschnitte einer Akte und wendet dabei die Stufe des Lesers an.
|
||||||
|
*
|
||||||
|
* Felder über seiner Stufe werden **serverseitig** durch einen Platzhalter
|
||||||
|
* ersetzt. Der echte Wert verlässt den Server nicht — nie im Client
|
||||||
|
* ausblenden. Dasselbe gilt für Schwärzungen im Fliesstext, die als Segmente
|
||||||
|
* zurückkommen.
|
||||||
|
*/
|
||||||
|
export async function loadSections(
|
||||||
|
p: EffectivePermissions,
|
||||||
|
caseId: string,
|
||||||
|
templateId: string,
|
||||||
|
): Promise<RenderedSection[]> {
|
||||||
|
const [template, rows] = await Promise.all([
|
||||||
|
db.caseTemplate.findUnique({ where: { id: templateId }, select: { fields: true } }),
|
||||||
|
db.caseSection.findMany({ where: { caseId } }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const fields = templateFields(template?.fields)
|
||||||
|
const byKey = new Map(rows.map((row) => [row.fieldKey, row]))
|
||||||
|
|
||||||
|
return fields.map((field): RenderedSection => {
|
||||||
|
const row = byKey.get(field.key)
|
||||||
|
const required = Math.max(field.minClearance ?? 0, row?.minClearance ?? 0)
|
||||||
|
const common = {
|
||||||
|
fieldKey: field.key,
|
||||||
|
field,
|
||||||
|
version: row?.currentVersion ?? 0,
|
||||||
|
updatedAt: row?.updatedAt ?? null,
|
||||||
|
updatedById: row?.updatedById ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!p.isAdmin && required > p.clearanceLevel) {
|
||||||
|
return { ...common, visible: false, value: { redacted: true, requiredLevel: required } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = row?.value ?? null
|
||||||
|
const segments =
|
||||||
|
isInputField(field) && field.type === 'longtext' && typeof value === 'string'
|
||||||
|
? redactForLevel(value, p.clearanceLevel)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return { ...common, visible: true, value, segments }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConflictError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly current: { value: unknown; version: number; updatedById: string | null },
|
||||||
|
) {
|
||||||
|
super('conflict')
|
||||||
|
this.name = 'ConflictError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert einen Abschnitt.
|
||||||
|
*
|
||||||
|
* Der Client schickt die Version mit, die er geladen hat. Weicht sie ab, gibt
|
||||||
|
* es kein stilles Überschreiben, sondern einen Konflikt mit beiden Fassungen.
|
||||||
|
* Siehe docs/05-akten-und-vorlagen.md.
|
||||||
|
*/
|
||||||
|
export async function saveSection(
|
||||||
|
p: EffectivePermissions,
|
||||||
|
input: {
|
||||||
|
caseNumber: string
|
||||||
|
fieldKey: string
|
||||||
|
value: unknown
|
||||||
|
/** Version, die der Client geladen hat. 0 heisst: Feld war noch leer. */
|
||||||
|
baseVersion: number
|
||||||
|
minClearance?: number
|
||||||
|
},
|
||||||
|
): Promise<{ version: number }> {
|
||||||
|
const target = await loadVisibleCase(p, input.caseNumber)
|
||||||
|
if (!target) throw new AccessError('not-found')
|
||||||
|
|
||||||
|
const check = canEditCase(p, target)
|
||||||
|
if (!check.ok) throw new AccessError(check.reason)
|
||||||
|
|
||||||
|
const template = await db.caseTemplate.findUnique({
|
||||||
|
where: { id: target.templateId },
|
||||||
|
select: { fields: true },
|
||||||
|
})
|
||||||
|
const field = templateFields(template?.fields).find((f) => f.key === input.fieldKey)
|
||||||
|
if (!field || !isInputField(field)) throw new AccessError('field-unknown')
|
||||||
|
|
||||||
|
const existing = await db.caseSection.findUnique({
|
||||||
|
where: { caseId_fieldKey: { caseId: target.id, fieldKey: input.fieldKey } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const required = Math.max(field.minClearance ?? 0, existing?.minClearance ?? 0)
|
||||||
|
if (!p.isAdmin && required > p.clearanceLevel) throw new AccessError('clearance-too-low')
|
||||||
|
|
||||||
|
const nextClearance = input.minClearance ?? existing?.minClearance ?? field.minClearance ?? 0
|
||||||
|
if (nextClearance > p.clearanceLevel && !p.isAdmin) throw new AccessError('clearance-too-low')
|
||||||
|
|
||||||
|
if ((existing?.currentVersion ?? 0) !== input.baseVersion) {
|
||||||
|
throw new ConflictError({
|
||||||
|
value: existing?.value ?? null,
|
||||||
|
version: existing?.currentVersion ?? 0,
|
||||||
|
updatedById: existing?.updatedById ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextVersion = (existing?.currentVersion ?? 0) + 1
|
||||||
|
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
if (existing) {
|
||||||
|
// Der vorherige Stand wandert in die Versionstabelle, bevor überschrieben
|
||||||
|
// wird. Die Historie ist damit lückenlos.
|
||||||
|
await tx.caseSectionVersion.create({
|
||||||
|
data: {
|
||||||
|
sectionId: existing.id,
|
||||||
|
version: existing.currentVersion,
|
||||||
|
value: asJson(existing.value),
|
||||||
|
authorId: existing.updatedById,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await tx.caseSection.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
value: asJson(input.value),
|
||||||
|
currentVersion: nextVersion,
|
||||||
|
updatedById: p.userId,
|
||||||
|
minClearance: nextClearance,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await tx.caseSection.create({
|
||||||
|
data: {
|
||||||
|
caseId: target.id,
|
||||||
|
fieldKey: input.fieldKey,
|
||||||
|
value: asJson(input.value),
|
||||||
|
currentVersion: 1,
|
||||||
|
updatedById: p.userId,
|
||||||
|
minClearance: nextClearance,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeAudit(tx, {
|
||||||
|
actorId: p.userId,
|
||||||
|
action: 'case.update',
|
||||||
|
targetType: 'CaseSection',
|
||||||
|
targetId: `${target.id}:${input.fieldKey}`,
|
||||||
|
clearanceAtTime: p.clearanceLevel,
|
||||||
|
meta: {
|
||||||
|
caseNumber: target.caseNumber,
|
||||||
|
fieldKey: input.fieldKey,
|
||||||
|
version: nextVersion,
|
||||||
|
minClearance: nextClearance,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { version: nextVersion }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Versionen eines Abschnitts, für die Diff-Ansicht. */
|
||||||
|
export async function loadSectionHistory(
|
||||||
|
p: EffectivePermissions,
|
||||||
|
caseNumber: string,
|
||||||
|
fieldKey: string,
|
||||||
|
) {
|
||||||
|
const target = await loadVisibleCase(p, caseNumber)
|
||||||
|
if (!target) throw new AccessError('not-found')
|
||||||
|
|
||||||
|
const section = await db.caseSection.findUnique({
|
||||||
|
where: { caseId_fieldKey: { caseId: target.id, fieldKey } },
|
||||||
|
select: { id: true, minClearance: true, value: true, currentVersion: true, updatedById: true },
|
||||||
|
})
|
||||||
|
if (!section) return null
|
||||||
|
|
||||||
|
if (!p.isAdmin && section.minClearance > p.clearanceLevel) throw new AccessError('clearance-too-low')
|
||||||
|
|
||||||
|
const versions = await db.caseSectionVersion.findMany({
|
||||||
|
where: { sectionId: section.id },
|
||||||
|
orderBy: { version: 'desc' },
|
||||||
|
take: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { section, versions }
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Wortdiff für die Fassungsvergleiche an Abschnitten und Verlaufseinträgen.
|
||||||
|
*
|
||||||
|
* Bewusst ohne Paket: eine längste gemeinsame Teilfolge über Wörter ist ein
|
||||||
|
* Dutzend Zeilen, und die Alternative wären 30 kB im Bündel für etwas, das
|
||||||
|
* ausschliesslich serverseitig läuft. Siehe docs/09-performance-und-ux.md.
|
||||||
|
*/
|
||||||
|
export type DiffPart = { kind: 'gleich' | 'entfernt' | 'neu'; text: string }
|
||||||
|
|
||||||
|
function tokenize(text: string): string[] {
|
||||||
|
// Wörter samt folgendem Leerraum, damit sich der Text verlustfrei
|
||||||
|
// zusammensetzen lässt.
|
||||||
|
return text.match(/\S+\s*|\s+/g) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wordDiff(before: string, after: string): DiffPart[] {
|
||||||
|
const a = tokenize(before)
|
||||||
|
const b = tokenize(after)
|
||||||
|
|
||||||
|
// Längste gemeinsame Teilfolge, klassisch über eine Tabelle.
|
||||||
|
const lengths: number[][] = Array.from({ length: a.length + 1 }, () =>
|
||||||
|
new Array<number>(b.length + 1).fill(0),
|
||||||
|
)
|
||||||
|
for (let i = a.length - 1; i >= 0; i--) {
|
||||||
|
for (let j = b.length - 1; j >= 0; j--) {
|
||||||
|
lengths[i]![j] = a[i] === b[j] ? lengths[i + 1]![j + 1]! + 1 : Math.max(lengths[i + 1]![j]!, lengths[i]![j + 1]!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: DiffPart[] = []
|
||||||
|
const push = (kind: DiffPart['kind'], text: string) => {
|
||||||
|
const last = parts.at(-1)
|
||||||
|
if (last && last.kind === kind) last.text += text
|
||||||
|
else parts.push({ kind, text })
|
||||||
|
}
|
||||||
|
|
||||||
|
let i = 0
|
||||||
|
let j = 0
|
||||||
|
while (i < a.length && j < b.length) {
|
||||||
|
if (a[i] === b[j]) {
|
||||||
|
push('gleich', a[i]!)
|
||||||
|
i++
|
||||||
|
j++
|
||||||
|
} else if (lengths[i + 1]![j]! >= lengths[i]![j + 1]!) {
|
||||||
|
push('entfernt', a[i]!)
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
push('neu', b[j]!)
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (i < a.length) push('entfernt', a[i++]!)
|
||||||
|
while (j < b.length) push('neu', b[j++]!)
|
||||||
|
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grobe Kennzahl für das Protokoll, ohne den Text zu doppeln. */
|
||||||
|
export function diffSummary(before: string, after: string): { entfernt: number; neu: number } {
|
||||||
|
const parts = wordDiff(before, after)
|
||||||
|
return {
|
||||||
|
entfernt: parts.filter((p) => p.kind === 'entfernt').reduce((n, p) => n + p.text.length, 0),
|
||||||
|
neu: parts.filter((p) => p.kind === 'neu').reduce((n, p) => n + p.text.length, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -178,6 +178,28 @@ export const de = {
|
|||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
section: {
|
||||||
|
heading: 'Abschnitte',
|
||||||
|
empty: 'Die Vorlage dieser Fallart hat keine Abschnitte.',
|
||||||
|
save: 'Abschnitt speichern',
|
||||||
|
saving: 'Wird gespeichert',
|
||||||
|
saved: 'Gespeichert',
|
||||||
|
draftKept: 'Entwurf lokal gesichert',
|
||||||
|
version: (v: number) => `v${v}`,
|
||||||
|
history: 'Fassungen',
|
||||||
|
conflict:
|
||||||
|
'Nicht gespeichert, die Akte wurde zwischenzeitlich geändert. Fassungen vergleichen.',
|
||||||
|
mine: 'Meine Fassung',
|
||||||
|
theirs: 'Fremde Fassung',
|
||||||
|
takeMine: 'Meine übernehmen',
|
||||||
|
takeTheirs: 'Fremde übernehmen',
|
||||||
|
redacted: 'Geschwärzt',
|
||||||
|
redactedFrom: (n: number) => `Geschwärzt, ab Stufe ${n} sichtbar`,
|
||||||
|
lockedField: (n: number) => `Feld gesperrt, sichtbar ab Stufe ${n}`,
|
||||||
|
requiredToClose: 'Für den Abschluss nötig',
|
||||||
|
signatureLine: 'Unterschrift',
|
||||||
|
},
|
||||||
|
|
||||||
entry: {
|
entry: {
|
||||||
one: 'Vermerk',
|
one: 'Vermerk',
|
||||||
many: 'Vermerke',
|
many: 'Vermerke',
|
||||||
@@ -243,6 +265,27 @@ export const de = {
|
|||||||
conflict: 'Nicht gespeichert, die Akte wurde zwischenzeitlich geändert. Fassungen vergleichen.',
|
conflict: 'Nicht gespeichert, die Akte wurde zwischenzeitlich geändert. Fassungen vergleichen.',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
templateAdmin: {
|
||||||
|
heading: 'Vorlagen',
|
||||||
|
intro:
|
||||||
|
'Eine Vorlage bestimmt, aus welchen Bausteinen eine Fallart besteht. Speichern legt eine neue Version an; bestehende Akten behalten die Version, mit der sie angelegt wurden.',
|
||||||
|
empty: 'Noch keine Vorlage.',
|
||||||
|
version: (v: number) => `Version ${v}`,
|
||||||
|
fieldsLabel: 'Bausteine als JSON',
|
||||||
|
fieldsHelp:
|
||||||
|
'Liste von Bausteinen. Eingabefelder: shorttext, longtext, select, multiselect, date, person, number, checklist, table. Feste Bausteine: heading, paragraph, signature, pagebreak.',
|
||||||
|
numberPrefix: 'Präfix des Aktenzeichens',
|
||||||
|
numberPadding: 'Stellen der laufenden Nummer',
|
||||||
|
save: 'Als neue Version speichern',
|
||||||
|
saved: (v: number) => `Gespeichert als Version ${v}`,
|
||||||
|
preview: 'Vorschau',
|
||||||
|
previewIntro: 'So sieht eine neue Akte mit dieser Vorlage aus.',
|
||||||
|
invalid: 'Die Bausteine sind kein gültiges JSON.',
|
||||||
|
fieldsInvalid: 'Die Bausteine sind nicht gültig.',
|
||||||
|
inputField: 'Eingabe',
|
||||||
|
staticBlock: 'Fester Baustein',
|
||||||
|
},
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
heading: 'Änderungsrichtlinie',
|
heading: 'Änderungsrichtlinie',
|
||||||
intro:
|
intro:
|
||||||
|
|||||||
+45
-75
@@ -1,15 +1,15 @@
|
|||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bausteine einer Vorlage.
|
* Bausteine einer Vorlage — Typen und reine Funktionen.
|
||||||
|
*
|
||||||
|
* **Ohne zod.** Dieses Modul wird auch im Client geladen (Vorlagen-Editor,
|
||||||
|
* Vorschau), und eine Validierungsbibliothek im Bündel kostet mehr als das
|
||||||
|
* Budget aus docs/09-performance-und-ux.md hergibt. Die verbindliche Prüfung
|
||||||
|
* steht in `schema.ts` und läuft ausschliesslich serverseitig — dort, wo sie
|
||||||
|
* ohnehin hingehört.
|
||||||
*
|
*
|
||||||
* Eine Vorlage ist eine **geordnete Liste von Bausteinen**. Manche nehmen eine
|
* Eine Vorlage ist eine **geordnete Liste von Bausteinen**. Manche nehmen eine
|
||||||
* Eingabe entgegen, andere liefern festen Text. Damit tragen dieselbe Struktur
|
* Eingabe entgegen, andere liefern festen Text. Damit tragen dieselbe Struktur
|
||||||
* ein Vorfallsbericht (fast nur Eingaben) und ein Sonderarbeitsvertrag (fast
|
* ein Vorfallsbericht und ein Sonderarbeitsvertrag.
|
||||||
* nur fester Text mit ein paar Platzhaltern).
|
|
||||||
*
|
|
||||||
* Eine neue Dokumentart ist damit ein Datensatz, kein Deployment.
|
|
||||||
* Siehe docs/05-akten-und-vorlagen.md und docs/13-entscheidungen.md.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Bausteine, die der Nutzer ausfüllt. Ihr Wert landet in einem Abschnitt. */
|
/** Bausteine, die der Nutzer ausfüllt. Ihr Wert landet in einem Abschnitt. */
|
||||||
@@ -31,76 +31,28 @@ export const STATIC_TYPES = ['heading', 'paragraph', 'signature', 'pagebreak'] a
|
|||||||
export type InputType = (typeof INPUT_TYPES)[number]
|
export type InputType = (typeof INPUT_TYPES)[number]
|
||||||
export type StaticType = (typeof STATIC_TYPES)[number]
|
export type StaticType = (typeof STATIC_TYPES)[number]
|
||||||
|
|
||||||
const key = z
|
export type InputField = {
|
||||||
.string()
|
key: string
|
||||||
.min(1)
|
label: string
|
||||||
.max(64)
|
type: InputType
|
||||||
// Der Schlüssel ist stabil und wird nie geändert: ein Abschnitt findet über
|
help?: string
|
||||||
// ihn seinen Baustein wieder, auch über Vorlagenversionen hinweg.
|
minClearance?: number
|
||||||
.regex(/^[a-z][a-z0-9_]*$/, 'Kleinbuchstaben, Ziffern und Unterstrich')
|
required?: boolean
|
||||||
|
requiredToClose?: boolean
|
||||||
const gemeinsam = {
|
options?: string[]
|
||||||
key,
|
columns?: string[]
|
||||||
label: z.string().min(1).max(200),
|
|
||||||
help: z.string().max(500).optional(),
|
|
||||||
/** Feld erst ab dieser Stufe sichtbar, siehe docs/05-akten-und-vorlagen.md. */
|
|
||||||
minClearance: z.number().int().min(0).max(5).optional(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputField = z.object({
|
export type StaticBlock = {
|
||||||
...gemeinsam,
|
key: string
|
||||||
type: z.enum(INPUT_TYPES),
|
label: string
|
||||||
required: z.boolean().optional(),
|
type: StaticType
|
||||||
/** Blockiert den Abschluss des Falls, solange leer. */
|
help?: string
|
||||||
requiredToClose: z.boolean().optional(),
|
minClearance?: number
|
||||||
/** Für select und multiselect. */
|
text?: string
|
||||||
options: z.array(z.string().min(1)).optional(),
|
}
|
||||||
/** Für table: die Spaltenüberschriften. */
|
|
||||||
columns: z.array(z.string().min(1)).optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const staticBlock = z.object({
|
export type TemplateField = InputField | StaticBlock
|
||||||
...gemeinsam,
|
|
||||||
type: z.enum(STATIC_TYPES),
|
|
||||||
/**
|
|
||||||
* Fester Text des Bausteins. Platzhalter in geschweiften Klammern werden
|
|
||||||
* beim Rendern durch Werte ersetzt: `{{name}}` zieht den Wert des
|
|
||||||
* Bausteins mit dem Schlüssel `name`.
|
|
||||||
*/
|
|
||||||
text: z.string().max(20_000).optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const templateFieldSchema = z.union([inputField, staticBlock])
|
|
||||||
export const templateFieldsSchema = z
|
|
||||||
.array(templateFieldSchema)
|
|
||||||
.max(200)
|
|
||||||
.superRefine((fields, ctx) => {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
for (const [index, field] of fields.entries()) {
|
|
||||||
if (seen.has(field.key)) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: 'custom',
|
|
||||||
path: [index, 'key'],
|
|
||||||
message: `Schlüssel doppelt vergeben: ${field.key}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
seen.add(field.key)
|
|
||||||
|
|
||||||
if (
|
|
||||||
(field.type === 'select' || field.type === 'multiselect') &&
|
|
||||||
(!('options' in field) || !field.options?.length)
|
|
||||||
) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: 'custom',
|
|
||||||
path: [index, 'options'],
|
|
||||||
message: 'Auswahlfelder brauchen mindestens eine Option',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export type TemplateField = z.infer<typeof templateFieldSchema>
|
|
||||||
export type InputField = z.infer<typeof inputField>
|
|
||||||
|
|
||||||
export function isInputField(field: TemplateField): field is InputField {
|
export function isInputField(field: TemplateField): field is InputField {
|
||||||
return (INPUT_TYPES as readonly string[]).includes(field.type)
|
return (INPUT_TYPES as readonly string[]).includes(field.type)
|
||||||
@@ -127,3 +79,21 @@ export function requiredToCloseKeys(fields: TemplateField[]): string[] {
|
|||||||
.filter((field): field is InputField => isInputField(field) && field.requiredToClose === true)
|
.filter((field): field is InputField => isInputField(field) && field.requiredToClose === true)
|
||||||
.map((field) => field.key)
|
.map((field) => field.key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grobe Formprüfung für die Vorschau im Editor. Sagt, ob sich die Eingabe
|
||||||
|
* überhaupt darstellen lässt — nicht, ob sie gespeichert werden darf. Das
|
||||||
|
* entscheidet allein der Server.
|
||||||
|
*/
|
||||||
|
export function looksLikeFields(value: unknown): value is TemplateField[] {
|
||||||
|
if (!Array.isArray(value)) return false
|
||||||
|
const known = new Set<string>([...INPUT_TYPES, ...STATIC_TYPES])
|
||||||
|
return value.every(
|
||||||
|
(item) =>
|
||||||
|
typeof item === 'object' &&
|
||||||
|
item !== null &&
|
||||||
|
typeof (item as TemplateField).key === 'string' &&
|
||||||
|
typeof (item as TemplateField).label === 'string' &&
|
||||||
|
known.has((item as TemplateField).type),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { INPUT_TYPES, STATIC_TYPES, type TemplateField } from '@/lib/templates/fields'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbindliche Prüfung einer Vorlage. Läuft ausschliesslich serverseitig:
|
||||||
|
* eine Server Action ist ein Endpunkt, und was hier durchgeht, geht auch durch,
|
||||||
|
* wenn jemand den Editor umgeht.
|
||||||
|
*/
|
||||||
|
const key = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(64)
|
||||||
|
// Der Schlüssel ist stabil und wird nie geändert: ein Abschnitt findet über
|
||||||
|
// ihn seinen Baustein wieder, auch über Vorlagenversionen hinweg.
|
||||||
|
.regex(/^[a-z][a-z0-9_]*$/, 'Kleinbuchstaben, Ziffern und Unterstrich')
|
||||||
|
|
||||||
|
const gemeinsam = {
|
||||||
|
key,
|
||||||
|
label: z.string().min(1).max(200),
|
||||||
|
help: z.string().max(500).optional(),
|
||||||
|
/** Feld erst ab dieser Stufe sichtbar, siehe docs/05-akten-und-vorlagen.md. */
|
||||||
|
minClearance: z.number().int().min(0).max(5).optional(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputField = z.object({
|
||||||
|
...gemeinsam,
|
||||||
|
type: z.enum(INPUT_TYPES),
|
||||||
|
required: z.boolean().optional(),
|
||||||
|
requiredToClose: z.boolean().optional(),
|
||||||
|
options: z.array(z.string().min(1)).optional(),
|
||||||
|
columns: z.array(z.string().min(1)).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const staticBlock = z.object({
|
||||||
|
...gemeinsam,
|
||||||
|
type: z.enum(STATIC_TYPES),
|
||||||
|
text: z.string().max(20_000).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const templateFieldSchema = z.union([inputField, staticBlock])
|
||||||
|
|
||||||
|
export const templateFieldsSchema = z
|
||||||
|
.array(templateFieldSchema)
|
||||||
|
.max(200)
|
||||||
|
.superRefine((fields, ctx) => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const [index, field] of fields.entries()) {
|
||||||
|
if (seen.has(field.key)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: [index, 'key'],
|
||||||
|
message: `Schlüssel doppelt vergeben: ${field.key}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
seen.add(field.key)
|
||||||
|
|
||||||
|
if (
|
||||||
|
(field.type === 'select' || field.type === 'multiselect') &&
|
||||||
|
(!('options' in field) || !field.options?.length)
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: [index, 'options'],
|
||||||
|
message: 'Auswahlfelder brauchen mindestens eine Option',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export function parseTemplateFields(raw: unknown): TemplateField[] {
|
||||||
|
const parsed = templateFieldsSchema.safeParse(raw)
|
||||||
|
return parsed.success ? (parsed.data as TemplateField[]) : []
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { createPrismaClient, type PrismaClient } from '@afis/db'
|
||||||
|
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
|
||||||
|
|
||||||
|
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
|
||||||
|
const suite = url ? describe : describe.skip
|
||||||
|
|
||||||
|
let db: PrismaClient
|
||||||
|
const marke = `abschnitt-${Date.now()}`
|
||||||
|
const NUTZER = `${marke}-nutzer`
|
||||||
|
const ZWEITER = `${marke}-zweiter`
|
||||||
|
|
||||||
|
function perms(level: number, userId = NUTZER): EffectivePermissions {
|
||||||
|
const permissions: Permission[] = ['case.create', 'case.edit', 'entry.create']
|
||||||
|
return { userId, clearanceLevel: level, permissions: new Set(permissions), isAdmin: false, hasMembership: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const FELDER = [
|
||||||
|
{ key: 'sachverhalt', label: 'Sachverhalt', type: 'longtext' },
|
||||||
|
{ key: 'bewertung', label: 'Bewertung', type: 'longtext', requiredToClose: true },
|
||||||
|
{ key: 'zeugenname', label: 'Zeuge', type: 'shorttext', minClearance: 4 },
|
||||||
|
{ key: 'hinweis', label: 'Hinweis', type: 'paragraph', text: 'Fester Text mit {{sachverhalt}}' },
|
||||||
|
]
|
||||||
|
|
||||||
|
let caseNumber = ''
|
||||||
|
let caseId = ''
|
||||||
|
let templateId = ''
|
||||||
|
|
||||||
|
suite('Abschnitte', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = createPrismaClient(url)
|
||||||
|
const vorlage = await db.caseTemplate.create({
|
||||||
|
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: FELDER },
|
||||||
|
})
|
||||||
|
templateId = vorlage.id
|
||||||
|
for (const id of [NUTZER, ZWEITER]) {
|
||||||
|
await db.user.create({ data: { id, username: id, displayName: id } })
|
||||||
|
}
|
||||||
|
const { createCase } = await import('@/lib/cases/service')
|
||||||
|
const akte = await createCase(perms(5), {
|
||||||
|
title: 'Abschnittsprüfung',
|
||||||
|
classification: 1,
|
||||||
|
templateKey: `${marke}-vorlage`,
|
||||||
|
})
|
||||||
|
caseNumber = akte.caseNumber
|
||||||
|
caseId = akte.id
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.$disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('legt beim ersten Speichern Version 1 an', async () => {
|
||||||
|
const { saveSection } = await import('@/lib/cases/sectionService')
|
||||||
|
const ergebnis = await saveSection(perms(5), {
|
||||||
|
caseNumber,
|
||||||
|
fieldKey: 'sachverhalt',
|
||||||
|
value: 'Erster Stand',
|
||||||
|
baseVersion: 0,
|
||||||
|
})
|
||||||
|
expect(ergebnis.version).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erkennt einen Konflikt, statt still zu überschreiben', async () => {
|
||||||
|
const { saveSection, ConflictError } = await import('@/lib/cases/sectionService')
|
||||||
|
|
||||||
|
// Zwei laden dieselbe Fassung.
|
||||||
|
await saveSection(perms(5), {
|
||||||
|
caseNumber,
|
||||||
|
fieldKey: 'bewertung',
|
||||||
|
value: 'Fassung A',
|
||||||
|
baseVersion: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Einer speichert, der andere hat noch die alte Version im Formular.
|
||||||
|
await saveSection(perms(5), {
|
||||||
|
caseNumber,
|
||||||
|
fieldKey: 'bewertung',
|
||||||
|
value: 'Fassung B',
|
||||||
|
baseVersion: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
saveSection(perms(5, ZWEITER), {
|
||||||
|
caseNumber,
|
||||||
|
fieldKey: 'bewertung',
|
||||||
|
value: 'Fassung C',
|
||||||
|
baseVersion: 1,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ConflictError)
|
||||||
|
|
||||||
|
// Der Konflikt bringt die fremde Fassung mit, damit die Oberfläche beide
|
||||||
|
// nebeneinander zeigen kann.
|
||||||
|
try {
|
||||||
|
await saveSection(perms(5, ZWEITER), {
|
||||||
|
caseNumber,
|
||||||
|
fieldKey: 'bewertung',
|
||||||
|
value: 'Fassung C',
|
||||||
|
baseVersion: 1,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
expect((error as InstanceType<typeof ConflictError>).current).toMatchObject({
|
||||||
|
value: 'Fassung B',
|
||||||
|
version: 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schreibt jede Fassung in die Versionstabelle', async () => {
|
||||||
|
const section = await db.caseSection.findUniqueOrThrow({
|
||||||
|
where: { caseId_fieldKey: { caseId, fieldKey: 'bewertung' } },
|
||||||
|
})
|
||||||
|
const versionen = await db.caseSectionVersion.findMany({
|
||||||
|
where: { sectionId: section.id },
|
||||||
|
orderBy: { version: 'asc' },
|
||||||
|
})
|
||||||
|
expect(versionen.map((v) => v.value)).toEqual(['Fassung A'])
|
||||||
|
expect(section.currentVersion).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ersetzt ein Feld über der Stufe des Lesers serverseitig', async () => {
|
||||||
|
const { saveSection, loadSections } = await import('@/lib/cases/sectionService')
|
||||||
|
await saveSection(perms(5), {
|
||||||
|
caseNumber,
|
||||||
|
fieldKey: 'zeugenname',
|
||||||
|
value: 'Wachmann Perreira',
|
||||||
|
baseVersion: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const fuerNiedrig = await loadSections(perms(2), caseId, templateId)
|
||||||
|
const feld = fuerNiedrig.find((s) => s.fieldKey === 'zeugenname')
|
||||||
|
expect(feld?.visible).toBe(false)
|
||||||
|
// Der echte Wert verlässt den Server nicht.
|
||||||
|
expect(JSON.stringify(fuerNiedrig)).not.toContain('Perreira')
|
||||||
|
expect(feld?.value).toMatchObject({ redacted: true, requiredLevel: 4 })
|
||||||
|
|
||||||
|
const fuerHoch = await loadSections(perms(4), caseId, templateId)
|
||||||
|
expect(fuerHoch.find((s) => s.fieldKey === 'zeugenname')?.value).toBe('Wachmann Perreira')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt niemanden ein Feld über die eigene Stufe heben', async () => {
|
||||||
|
const { saveSection } = await import('@/lib/cases/sectionService')
|
||||||
|
const { AccessError } = await import('@/lib/cases/service')
|
||||||
|
await expect(
|
||||||
|
saveSection(perms(2), {
|
||||||
|
caseNumber,
|
||||||
|
fieldKey: 'sachverhalt',
|
||||||
|
value: 'Heimlich hochstufen',
|
||||||
|
baseVersion: 1,
|
||||||
|
minClearance: 5,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(AccessError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gibt Fliesstext als Segmente zurück, mit angewandter Schwärzung', async () => {
|
||||||
|
const { saveSection, loadSections } = await import('@/lib/cases/sectionService')
|
||||||
|
await saveSection(perms(5), {
|
||||||
|
caseNumber,
|
||||||
|
fieldKey: 'sachverhalt',
|
||||||
|
value: 'Offen [[c4]]geheim[[/c4]] Ende',
|
||||||
|
baseVersion: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const niedrig = await loadSections(perms(1), caseId, templateId)
|
||||||
|
const feld = niedrig.find((s) => s.fieldKey === 'sachverhalt')
|
||||||
|
if (!feld || feld.visible !== true) throw new Error('Abschnitt sollte sichtbar sein')
|
||||||
|
expect(JSON.stringify(feld.segments)).not.toContain('geheim')
|
||||||
|
expect(JSON.stringify(feld.segments)).toContain('Offen')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { diffSummary, wordDiff } from '@/lib/diff'
|
||||||
|
|
||||||
|
describe('Wortdiff', () => {
|
||||||
|
it('setzt den ursprünglichen Text verlustfrei wieder zusammen', () => {
|
||||||
|
const vorher = 'Der Zeuge sagte am Montag aus.'
|
||||||
|
const nachher = 'Der Zeuge sagte am Dienstag erneut aus.'
|
||||||
|
const teile = wordDiff(vorher, nachher)
|
||||||
|
|
||||||
|
const rekonstruiertVorher = teile
|
||||||
|
.filter((t) => t.kind !== 'neu')
|
||||||
|
.map((t) => t.text)
|
||||||
|
.join('')
|
||||||
|
const rekonstruiertNachher = teile
|
||||||
|
.filter((t) => t.kind !== 'entfernt')
|
||||||
|
.map((t) => t.text)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
expect(rekonstruiertVorher).toBe(vorher)
|
||||||
|
expect(rekonstruiertNachher).toBe(nachher)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('markiert nur die geänderten Wörter', () => {
|
||||||
|
const teile = wordDiff('a b c', 'a x c')
|
||||||
|
expect(teile.find((t) => t.kind === 'entfernt')?.text.trim()).toBe('b')
|
||||||
|
expect(teile.find((t) => t.kind === 'neu')?.text.trim()).toBe('x')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('kommt mit leeren Fassungen zurecht', () => {
|
||||||
|
expect(wordDiff('', 'neu').every((t) => t.kind === 'neu')).toBe(true)
|
||||||
|
expect(wordDiff('weg', '').every((t) => t.kind === 'entfernt')).toBe(true)
|
||||||
|
expect(wordDiff('', '')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('meldet bei unverändertem Text keine Änderung', () => {
|
||||||
|
expect(diffSummary('gleich', 'gleich')).toEqual({ entfernt: 0, neu: 0 })
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user