import { z } from 'zod' /** * Bausteine einer Vorlage. * * Eine Vorlage ist eine **geordnete Liste von Bausteinen**. Manche nehmen eine * Eingabe entgegen, andere liefern festen Text. Damit tragen dieselbe Struktur * ein Vorfallsbericht (fast nur Eingaben) und ein Sonderarbeitsvertrag (fast * 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. */ export const INPUT_TYPES = [ 'shorttext', 'longtext', 'select', 'multiselect', 'date', 'person', 'number', 'checklist', 'table', ] as const /** Bausteine, die aus der Vorlage kommen und nicht ausgefüllt werden. */ export const STATIC_TYPES = ['heading', 'paragraph', 'signature', 'pagebreak'] as const export type InputType = (typeof INPUT_TYPES)[number] export type StaticType = (typeof STATIC_TYPES)[number] 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(), /** Blockiert den Abschluss des Falls, solange leer. */ requiredToClose: z.boolean().optional(), /** Für select und multiselect. */ 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({ ...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() 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 export type InputField = z.infer export function isInputField(field: TemplateField): field is InputField { return (INPUT_TYPES as readonly string[]).includes(field.type) } /** Platzhalter `{{schlüssel}}` in einem festen Textbaustein. */ export const PLACEHOLDER = /\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g export function fillPlaceholders( text: string, values: Record, fallback = (key: string) => `[${key}]`, ): string { return text.replace(PLACEHOLDER, (_match, name: string) => { const value = values[name] if (value === undefined || value === null || value === '') return fallback(name) return Array.isArray(value) ? value.join(', ') : String(value) }) } /** Die Bausteine, die vor dem Abschluss eines Falls gefüllt sein müssen. */ export function requiredToCloseKeys(fields: TemplateField[]): string[] { return fields .filter((field): field is InputField => isInputField(field) && field.requiredToClose === true) .map((field) => field.key) }