feat(stamdata): admin stamdata maintenance editor (beheer)

Realizes ADR-0004's "future low-code editor that commits a PR": an
admin-only stamdata maintenance editor built on the stamdata-as-code
foundation.

Backend: `professions` moves from a hardcoded C# dictionary to an embedded
`professions.json` data-file (typed as `ProfessionMapping`) with valid-time
(geldigVan/geldigTot, half-open). A generic, reflection-driven
StamdataCatalog/StamdataTable/StamdataFile describes every table so one
endpoint pair + one grid editor serve all of them; add a table in one line.
Two read-only, admin-gated endpoints (GET /stamdata, GET /stamdata/{table}
?peildatum=) — no runtime write path. Generic build gate
`Every_catalog_table_is_valid` (keys non-blank, no overlapping validity,
well-formed windows).

Frontend: new `beheer` context (route beheer/stamdata, capabilityGuard
'stamdata:edit'). A schema-driven grid editor edits rows locally; download()
emits {table}.json for the admin to commit as a reviewed PR (no mutation
command — the CI build + StamdataValidationTests stay the authority).

Full gate GREEN both sides; gen:api leaves no drift; new stamdata story
passes axe. See WP-29 + ADR-0004.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-21 13:43:51 +02:00
co-authored by Claude Opus 4.8
parent c459fa0a60
commit 0e77faf351
32 changed files with 7822 additions and 2284 deletions
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import { StamTable, activeOn, changeCounts, isValid, rowErrors, toJson } from './stamdata';
const professions: StamTable = {
id: 'professions',
label: 'Opleiding → beroep',
temporal: true,
columns: [
{ name: 'program', type: 'text', isKey: true, options: [] },
{ name: 'beroep', type: 'text', isKey: false, options: [] },
{ name: 'geldigVan', type: 'date', isKey: false, options: [] },
{ name: 'geldigTot', type: 'date', isKey: false, options: [] },
],
};
const row = (program: string, beroep: string, van: string, tot = ''): Record<string, string> => ({
program,
beroep,
geldigVan: van,
geldigTot: tot,
});
describe('activeOn (valid-time, half-open [van, tot))', () => {
it('includes a row whose window covers the date', () => {
expect(activeOn(professions, row('a', 'A', '2000-01-01'), '2020-01-01')).toBe(true);
});
it('excludes a row before its geldigVan', () => {
expect(activeOn(professions, row('a', 'A', '2000-01-01'), '1999-01-01')).toBe(false);
});
it('excludes on the geldigTot boundary (half-open)', () => {
expect(activeOn(professions, row('a', 'A', '2000-01-01', '2020-01-01'), '2020-01-01')).toBe(false);
expect(activeOn(professions, row('a', 'A', '2000-01-01', '2020-01-01'), '2019-12-31')).toBe(true);
});
});
describe('rowErrors / isValid (format only)', () => {
it('flags a blank key', () => {
expect(rowErrors(professions, [row('', 'A', '2000-01-01')])[0]).not.toBe('');
});
it('flags a missing geldigVan on a temporal table', () => {
expect(rowErrors(professions, [row('a', 'A', '')])[0]).not.toBe('');
});
it('flags geldigTot on or before geldigVan', () => {
expect(rowErrors(professions, [row('a', 'A', '2020-01-01', '2020-01-01')])[0]).not.toBe('');
});
it('passes a well-formed row', () => {
expect(isValid(professions, [row('a', 'A', '2000-01-01')])).toBe(true);
});
});
describe('changeCounts (diff against the loaded snapshot, by key)', () => {
const original = [row('a', 'A', '2000-01-01'), row('b', 'B', '2000-01-01')];
it('counts an added key', () => {
const draft = [...original, row('c', 'C', '2000-01-01')];
expect(changeCounts(professions, original, draft)).toEqual({ added: 1, removed: 0, edited: 0 });
});
it('counts a removed key', () => {
expect(changeCounts(professions, original, [original[0]])).toEqual({ added: 0, removed: 1, edited: 0 });
});
it('counts an edited cell', () => {
const draft = [row('a', 'CHANGED', '2000-01-01'), original[1]];
expect(changeCounts(professions, original, draft)).toEqual({ added: 0, removed: 0, edited: 1 });
});
});
describe('toJson (draft → file shape)', () => {
it('reconstructs an open-ended geldigTot as null and pretty-prints', () => {
const json = toJson(professions, [row('a', 'Arts', '2000-01-01')]);
expect(JSON.parse(json)).toEqual([
{ program: 'a', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: null },
]);
expect(json.endsWith('\n')).toBe(true);
});
it('coerces a number column', () => {
const table: StamTable = {
id: 't',
label: 't',
temporal: false,
columns: [
{ name: 'code', type: 'text', isKey: true, options: [] },
{ name: 'jaar', type: 'number', isKey: false, options: [] },
],
};
expect(JSON.parse(toJson(table, [{ code: 'x', jaar: '2020' }]))).toEqual([{ code: 'x', jaar: 2020 }]);
});
});