// Domain model for stamdata maintenance (ADR-0004). Pure TS, no Angular. A table is a // reflected column schema + editable rows; the pure functions below cover validation // (FORMAT only — the CI build + StamdataValidationTests stay the authority), the // valid-time preview filter, the change diff, and serialization back to the file shape. export type ColumnType = 'text' | 'date' | 'number' | 'enum'; export interface StamColumn { name: string; type: ColumnType; isKey: boolean; options: readonly string[]; } export interface StamTable { id: string; label: string; columns: readonly StamColumn[]; temporal: boolean; } /** * An editable row: every cell is the text the user edits ('' = empty). Typed values * (number, null geldigTot) are reconstructed at export time — see {@link toJson}. */ export type StamRow = Record; export interface ChangeCounts { added: number; removed: number; edited: number; } const GELDIG_VAN = 'geldigVan'; const GELDIG_TOT = 'geldigTot'; export function keyColumn(table: StamTable): StamColumn { return table.columns.find((c) => c.isKey) ?? table.columns[0]; } export function keyOf(table: StamTable, row: StamRow): string { return row[keyColumn(table).name] ?? ''; } export function emptyRow(table: StamTable): StamRow { const row: StamRow = {}; for (const c of table.columns) row[c.name] = ''; return row; } /** * Valid-time membership, half-open [van, tot) — the same rule the backend applies, done * client-side so the editor's "geldig op" preview is instant and never drops unsaved edits. * ISO yyyy-MM-dd strings compare correctly lexicographically. Non-temporal tables: all rows. */ export function activeOn(table: StamTable, row: StamRow, on: string): boolean { if (!table.temporal) return true; const van = row[GELDIG_VAN] ?? ''; const tot = row[GELDIG_TOT] ?? ''; return van !== '' && van <= on && (tot === '' || on < tot); } /** Per-row FORMAT error (index-aligned; '' = valid). Cross-row overlap is the CI gate's job. */ export function rowErrors(table: StamTable, rows: readonly StamRow[]): string[] { const key = keyColumn(table).name; return rows.map((row) => { if ((row[key] ?? '').trim() === '') return $localize`:@@beheer.validation.key:Vul de sleutelkolom in.`; if (table.temporal) { const van = row[GELDIG_VAN] ?? ''; const tot = row[GELDIG_TOT] ?? ''; if (van === '') return $localize`:@@beheer.validation.van:Vul een 'geldig van'-datum in.`; if (tot !== '' && tot <= van) return $localize`:@@beheer.validation.range:'Geldig tot' moet ná 'geldig van' liggen.`; } return ''; }); } export function isValid(table: StamTable, rows: readonly StamRow[]): boolean { return rowErrors(table, rows).every((e) => e === ''); } /** Diff draft against the loaded snapshot, matched by key value. */ export function changeCounts( table: StamTable, original: readonly StamRow[], draft: readonly StamRow[], ): ChangeCounts { const origByKey = new Map(original.map((r) => [keyOf(table, r), r])); const draftKeys = new Set(draft.map((r) => keyOf(table, r))); let added = 0; let edited = 0; for (const row of draft) { const prev = origByKey.get(keyOf(table, row)); if (!prev) added++; else if (!sameRow(table, prev, row)) edited++; } const removed = original.filter((r) => !draftKeys.has(keyOf(table, r))).length; return { added, removed, edited }; } function sameRow(table: StamTable, a: StamRow, b: StamRow): boolean { return table.columns.every((c) => (a[c.name] ?? '') === (b[c.name] ?? '')); } /** * Serialize the draft back to the data-file's JSON shape, reconstructing typed values per * column: an empty date/number cell becomes null (an open-ended geldigTot), a number cell * becomes a number, everything else a string. This is the file the admin drops into the * repo — the existing CI build re-validates it (a bad edit fails the build, never prod). */ export function toJson(table: StamTable, rows: readonly StamRow[]): string { const objects = rows.map((row) => { const out: Record = {}; for (const c of table.columns) { const cell = (row[c.name] ?? '').trim(); out[c.name] = cell === '' ? c.type === 'text' || c.type === 'enum' ? '' : null : c.type === 'number' ? Number(cell) : cell; } return out; }); return JSON.stringify(objects, null, 2) + '\n'; }