Files
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

202 lines
6.6 KiB
TypeScript

/**
* 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<string, unknown>,
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<string> {
const keys = new Set<string>()
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<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),
)
}