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>
56 lines
2.2 KiB
TypeScript
56 lines
2.2 KiB
TypeScript
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);
|
|
}
|
|
}
|