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:
@@ -0,0 +1,94 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { runSubmit } from '@shared/application/submit';
|
||||
import { ApiClient, StamdataColumnDto } from '@shared/infrastructure/api-client';
|
||||
import { ColumnType, StamColumn, StamRow, StamTable } from '@beheer/domain/stamdata';
|
||||
|
||||
/** A loaded table: its schema plus the rows for editing (or the peildatum-filtered view). */
|
||||
export interface LoadedTable {
|
||||
table: StamTable;
|
||||
rows: StamRow[];
|
||||
}
|
||||
|
||||
const FAILED = $localize`:@@beheer.load.failed:De stamdata kon niet worden geladen.`;
|
||||
const COLUMN_TYPES: readonly ColumnType[] = ['text', 'date', 'number', 'enum'];
|
||||
|
||||
/**
|
||||
* The only place stamdata HTTP lives (ADR-0001 boundary). Both endpoints are reads; the
|
||||
* generic `parse*` narrows the untrusted wire shape (schema + opaque rows) into the domain
|
||||
* model. There is no write method — the edit is downloaded and lands as a PR.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StamdataAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
/** The tables in the catalog (schema only, no rows) — for the table switcher. */
|
||||
async list(): Promise<Result<string, StamTable[]>> {
|
||||
const r = await runSubmit(() => this.client.stamdataTables(), FAILED);
|
||||
if (!r.ok) return r;
|
||||
const out: StamTable[] = [];
|
||||
for (const t of r.value ?? []) {
|
||||
const parsed = parseTable(t);
|
||||
if (!parsed.ok) return parsed;
|
||||
out.push(parsed.value);
|
||||
}
|
||||
return ok(out);
|
||||
}
|
||||
|
||||
/** One table's schema + rows. `peildatum` (yyyy-MM-dd) asks the server for only the rows
|
||||
valid on that date; the editor uses it for a server-side cross-check, previewing
|
||||
locally for instant feedback (see `activeOn`). */
|
||||
async load(tableId: string, peildatum?: string): Promise<Result<string, LoadedTable>> {
|
||||
const r = await runSubmit(() => this.client.stamdataTable(tableId, peildatum), FAILED);
|
||||
return r.ok ? parseStamdataTable(r.value) : r;
|
||||
}
|
||||
}
|
||||
|
||||
// --- parse: wire → domain, validating at the boundary ---
|
||||
|
||||
/** Trust-boundary parse for one table response: schema + rows → domain. Exported so its
|
||||
spec can exercise it without HTTP (the house `parse*` seam, ADR-0001). */
|
||||
export function parseStamdataTable(dto: {
|
||||
id?: string;
|
||||
label?: string;
|
||||
columns?: StamdataColumnDto[];
|
||||
temporal?: boolean;
|
||||
rows?: readonly unknown[];
|
||||
}): Result<string, LoadedTable> {
|
||||
const table = parseTable(dto);
|
||||
if (!table.ok) return table;
|
||||
return ok({ table: table.value, rows: parseRows(dto.rows ?? [], table.value.columns) });
|
||||
}
|
||||
|
||||
function parseColumn(dto: StamdataColumnDto): Result<string, StamColumn> {
|
||||
if (typeof dto.name !== 'string' || dto.name === '') return err('stamdata column: bad name');
|
||||
const raw = dto.type ?? '';
|
||||
const type = (COLUMN_TYPES as string[]).includes(raw) ? (raw as ColumnType) : 'text';
|
||||
return ok({ name: dto.name, type, isKey: dto.isKey === true, options: dto.options ?? [] });
|
||||
}
|
||||
|
||||
function parseTable(dto: { id?: string; label?: string; columns?: StamdataColumnDto[]; temporal?: boolean }): Result<string, StamTable> {
|
||||
if (typeof dto.id !== 'string' || !Array.isArray(dto.columns))
|
||||
return err('stamdata table: bad shape');
|
||||
const columns: StamColumn[] = [];
|
||||
for (const c of dto.columns) {
|
||||
const parsed = parseColumn(c);
|
||||
if (!parsed.ok) return parsed;
|
||||
columns.push(parsed.value);
|
||||
}
|
||||
if (columns.length === 0) return err('stamdata table: no columns');
|
||||
return ok({ id: dto.id, label: dto.label ?? dto.id, columns, temporal: dto.temporal === true });
|
||||
}
|
||||
|
||||
/** Every cell becomes editable text: null → '' (open-ended), number/bool → its string form. */
|
||||
function parseRows(rows: readonly unknown[], columns: readonly StamColumn[]): StamRow[] {
|
||||
return rows.map((raw) => {
|
||||
const row: StamRow = {};
|
||||
const obj = (raw ?? {}) as Record<string, unknown>;
|
||||
for (const c of columns) {
|
||||
const v = obj[c.name];
|
||||
row[c.name] = v === null || v === undefined ? '' : String(v);
|
||||
}
|
||||
return row;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user