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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
202 lines
6.6 KiB
TypeScript
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),
|
|
)
|
|
}
|