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
@@ -0,0 +1,55 @@
import { assertNever } from '@shared/kernel/fp';
import { StamRow, StamTable, emptyRow } from '@beheer/domain/stamdata';
/**
* The stamdata table editor as one Elm-style tagged union (the house form idiom). While
* `loaded`, the draft `rows` are the edit state and `original` is the loaded snapshot the
* diff compares against — no separate `dirty` flag (derive it, don't store it). Loading and
* failure are states here too, so the page can render them via `<app-async>`.
*
* There is no submit/save Msg: an edit stays local until the admin downloads the file (the
* apply path is a reviewed PR, not a runtime write — ADR-0004).
*/
export type StamdataEditorState =
| { tag: 'loading' }
| { tag: 'failed'; reason: string }
| { tag: 'loaded'; table: StamTable; rows: StamRow[]; original: readonly StamRow[] };
export type StamdataEditorMsg =
| { tag: 'Loading' }
| { tag: 'Loaded'; table: StamTable; rows: StamRow[] }
| { tag: 'LoadFailed'; reason: string }
| { tag: 'CellEdited'; row: number; column: string; value: string }
| { tag: 'RowAdded' }
| { tag: 'RowRemoved'; row: number }
| { tag: 'Seed'; state: StamdataEditorState }; // mount a specific state (stories/tests)
export const initial: StamdataEditorState = { tag: 'loading' };
const copy = (rows: readonly StamRow[]): StamRow[] => rows.map((r) => ({ ...r }));
export function reduce(s: StamdataEditorState, m: StamdataEditorMsg): StamdataEditorState {
switch (m.tag) {
case 'Loading':
return { tag: 'loading' };
case 'Loaded':
// original is an independent snapshot so later edits never mutate it (drives the diff).
return { tag: 'loaded', table: m.table, rows: copy(m.rows), original: copy(m.rows) };
case 'LoadFailed':
return { tag: 'failed', reason: m.reason };
case 'CellEdited':
if (s.tag !== 'loaded') return s;
return {
...s,
rows: s.rows.map((r, i) => (i === m.row ? { ...r, [m.column]: m.value } : r)),
};
case 'RowAdded':
return s.tag === 'loaded' ? { ...s, rows: [...s.rows, emptyRow(s.table)] } : s;
case 'RowRemoved':
return s.tag === 'loaded' ? { ...s, rows: s.rows.filter((_, i) => i !== m.row) } : s;
case 'Seed':
return m.state;
default:
return assertNever(m);
}
}