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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user