/** * 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 * Eingabe entgegen, andere liefern festen Text. Damit tragen dieselbe Struktur * ein Vorfallsbericht und ein Sonderarbeitsvertrag. */ /** 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', /** Lichtbild. Der Wert ist die Kennung eines geprüften Anhangs, nie ein Bild. */ 'photo', /** Getippter Name, dargestellt als Zug über einer Linie. */ 'signature', ] as const /** Bausteine, die aus der Vorlage kommen und nicht ausgefüllt werden. */ export const STATIC_TYPES = ['heading', 'paragraph', 'pagebreak'] as const export type InputType = (typeof INPUT_TYPES)[number] export type StaticType = (typeof STATIC_TYPES)[number] export type InputField = { key: string label: string type: InputType help?: string minClearance?: number required?: boolean requiredToClose?: boolean options?: string[] columns?: string[] /** * Breite im Raster des Aktenblatts, 1 bis 5 Spalten. Nur `layout = DOSSIER` * wertet das aus; die übrigen Darstellungen setzen ohnehin untereinander. */ span?: number /** Nur bei `photo`: über wie viele Zeilen die Zelle reicht. */ rows?: number /** * Statt einer eigenen Zelle im Raster: dieses Feld füllt einen festen Platz * des Aktenblatts. Bekannt ist `classbar` - der Klassifizierungsbalken. */ slot?: string /** Hebt den Wert hervor, etwa die Freigabestufen im Aktenblatt. */ emphasis?: boolean /** * Nur bei `signature`: Schlüssel des Datumsfeldes, das unter der Zeile * steht. Das genannte Feld bekommt dadurch keine eigene Zelle mehr. */ dateKey?: string } export type StaticBlock = { key: string label: string type: StaticType help?: string minClearance?: number text?: string span?: number } export type TemplateField = InputField | StaticBlock 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) => { // **Nur eigene Eigenschaften.** `values[name]` lief über die // Prototypkette: ein Baustein mit dem Platzhalter `{{constructor}}` oder // `{{toString}}` bekam den Quelltext der geerbten Funktion in den Text // geschrieben - die Feldschlüssel sind Kleinbuchstaben, und `constructor` // ist einer. Kein Code, der ausgeführt wird, aber Innenleben, das in einem // Dokument nichts zu suchen hat. const value = Object.hasOwn(values, name) ? values[name] : undefined 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) } /** * Die Datumsfelder, die eine Unterschrift bereits mitzeichnet. Sie bekommen * im Aktenblatt keine eigene Zelle, bleiben aber ganz normal bearbeitbar. */ export const DOSSIER_COLUMNS = 5 /** * Zerlegt die Bausteine in die Zeilen des Aktenblatts. * * Solange die Lichtbildzelle mitläuft, ist eine Zeile eine Spalte schmaler - * genau so, wie das Formular es tut. Danach nimmt sie die volle Breite. * Felder, die in einen festen Platz gehören oder von einer Unterschrift * mitgezeichnet werden, tauchen im Raster nicht auf. */ export function dossierRows(fields: TemplateField[]): { photo: InputField | null photoRows: number rows: TemplateField[][] } { const mitgezeichnet = signatureDateKeys(fields) const photo = fields.find((f): f is InputField => isInputField(f) && f.type === 'photo') ?? null const photoRows = photo ? Math.max(1, photo.rows ?? 4) : 0 const rest = fields.filter( (field) => field !== photo && !mitgezeichnet.has(field.key) && !(isInputField(field) && field.slot), ) const rows: TemplateField[][] = [] let aktuell: TemplateField[] = [] let breite = 0 for (const field of rest) { const grenze = rows.length < photoRows ? DOSSIER_COLUMNS - 1 : DOSSIER_COLUMNS const span = Math.min(field.span ?? grenze, grenze) if (breite + span > grenze && aktuell.length > 0) { rows.push(aktuell) aktuell = [] breite = 0 } aktuell.push(field) breite += span if (breite >= grenze) { rows.push(aktuell) aktuell = [] breite = 0 } } if (aktuell.length > 0) rows.push(aktuell) return { photo, photoRows, rows } } /** Die Breite einer Zelle in Rasterspalten, begrenzt auf die Zeilenbreite. */ export function dossierSpan(field: TemplateField, inPhotoRows: boolean): number { const grenze = inPhotoRows ? DOSSIER_COLUMNS - 1 : DOSSIER_COLUMNS return Math.min(field.span ?? grenze, grenze) } export function signatureDateKeys(fields: TemplateField[]): Set { const keys = new Set() for (const field of fields) { if (isInputField(field) && field.type === 'signature' && field.dateKey) { keys.add(field.dateKey) } } return keys } /** * 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([...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), ) }