Compare commits

...
3 Commits
Author SHA1 Message Date
ehoandClaude Opus 4.8 4cf1147fc1 feat(beheer): WP-32 — undo/redo for the stamdata table editor
CI / frontend (push) Successful in 1m50s
CI / backend (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / semgrep (push) Has been cancelled
CI / api-client-drift (push) Has been cancelled
CI / storybook-a11y (push) Has been cancelled
Wire the WP-31 createHistory helper into StamdataStore: per-table undo/redo
over the edited rows, recording only real edits and restoring via the existing
Seed msg. Ctrl/Cmd+Z / +Shift+Z, deferring to native text-undo inside grid
cell inputs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 16:10:27 +02:00
ehoandClaude Opus 4.8 ac3e9a9399 refactor(fp): WP-31 — shared store helpers (dedupe brief/org-template/stamdata)
Audit "apply high-value": extract four shared helpers into shared/application/ and
rewire the editor stores (behaviour unchanged, existing specs are the gate):
- action-state.ts: ActionState/SaveState (were duplicated in both brief stores).
- history.ts: createHistory<T> (extracted from BriefStore's WP-27 undo/redo; WP-32 reuses).
- debounced-save.ts: createDebouncedSave (the 600ms timer/PendingSave dance, was 2×+).
- machine-remote-data.ts: machineRemoteData (the loading/failed/loaded→RemoteData switch, 3×).
Each helper has a co-located spec. Deferred DDD findings (contracts/ inconsistency, a
parse* traverse combinator, the 6× Seed boilerplate) are reported in the WP file, not built.

npm run ci green; 323 tests (+13 helper specs); brief/org-template/stamdata specs unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:40:02 +02:00
ehoandClaude Opus 4.8 13b3e5e663 docs(backlog): truth-up statuses + add Phase 7 (WP-31..36)
WP-25 (1bb9383) and WP-27 (5e36d68) were done but the table still said todo — fixed.
WP-28/WP-30 remain todo. Added Phase 7 (refinements & hardening): WP-31 shared store
helpers, WP-32 stamdata undo, WP-33 dev switchers, WP-34 adres phone + BRP read-only,
WP-35 one Concept per type, WP-36 admin cases page + delete. Only 31→32 is ordered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:26:55 +02:00
18 changed files with 1733 additions and 759 deletions
+40 -32
View File
@@ -43,38 +43,44 @@ WP-19's own file), so it's a separate manual/CI step, not chained into the other
Gates land before the work they cover; each lint rule lands in the same WP as the fixes
for its existing violations, so every WP ends green.
| WP | Title | Phase | Status |
| ---------------------------------------- | -------------------------------------------------------------------------- | --------------------------- | ------ |
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done |
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done |
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done |
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done |
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done |
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | todo |
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | todo |
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo |
| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done |
| [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | todo |
| WP | Title | Phase | Status |
| ------------------------------------------ | --------------------------------------------------------------------------------- | --------------------------- | ------ |
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done |
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done |
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done |
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done |
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done |
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done |
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done |
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo |
| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done |
| [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | todo |
| [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | todo |
| [WP-32](WP-32-stamdata-undo.md) | Undo/redo in the stamdata editor | 7 · refinements | todo |
| [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | todo |
| [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | todo |
| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | todo |
| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | todo |
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed
@@ -87,6 +93,8 @@ current in-memory persistence is a documented, defensible POC choice, not a bug.
Phase 6 (Brief v2, the "Brief opstellen v2" PRD) is strictly ordered
23 → 24 → 25 → 26 → 27 → 28: 24 needs 23's `orgTemplate` on the wire, 25 needs 24's
`letter.css` contract, 26 needs 23's endpoints + 24's canvas, 27/28 polish on top.
Phase 7 (refinements, WP-31..36): only 31 → 32 is ordered (32 reuses the `createHistory`
helper 31 extracts); 33–36 are independent — pick any order.
## WP template
@@ -0,0 +1,52 @@
# WP-31 — Shared store helpers (audit: apply high-value)
Status: done
Phase: 7 — refinements
## Why
A code audit found real duplication across the editor stores. This WP extracts the four
highest-value shared helpers and rewires the stores to them (behaviour unchanged), and
**reports** the lower-value / riskier DDD items as deferred backlog. Extracting `createHistory`
here also unblocks WP-32 (stamdata undo) so it needn't copy-paste the brief pattern.
## Decisions (pre-made, don't relitigate)
- Extract into `shared/application/` (importable by every context; must not import back).
- Apply the four concrete extractions + reuse; **do not** chase the deferred DDD items in this
phase (bound the diff). Behaviour must be identical — the existing store specs are the gate.
## Files
- New (each with a co-located spec): `shared/application/action-state.ts` (`ActionState`/
`SaveState`), `history.ts` (`createHistory<T>`), `debounced-save.ts` (`createDebouncedSave`),
`machine-remote-data.ts` (`machineRemoteData`).
- Rewired: `brief/application/brief.store.ts` (all four), `brief/application/org-template.store.ts`
(types + debounced-save + remote-data), `beheer/application/stamdata.store.ts` (remote-data).
## Acceptance criteria
- [x] `ActionState`/`SaveState` defined once; both brief stores import them.
- [x] `createHistory` backs brief undo/redo (identical semantics; specs pass).
- [x] `createDebouncedSave` backs both brief stores' autosave, integrating `PendingSave`.
- [x] `machineRemoteData` backs the RemoteData projection in all three stores.
- [x] `npm run ci` green; all pre-existing store specs still pass (no behaviour change).
## Deferred (reported, not built) — audit findings for a later WP
- **`contracts/` folder inconsistency:** only `beheer/` + `registratie/` have a `contracts/`
folder; `brief/`/`herregistratie/`/`auth/` declare wire DTOs inline in adapters. Decide whether
inline DTOs are a sanctioned exception or should be normalized.
- **`parse*` traverse combinator:** ~35 `parse*` boundary fns repeat an array-parse-and-collect
shape; a shared `traverse`/`parseAll` `Result` combinator would collapse the common idiom.
- **`Seed { state }` msg boilerplate:** the `Seed`/`return m.state` pair repeats in 6 machines —
cheap and per-machine typed; extract only if it earns its keep.
## Out of scope
- The deferred items above (this WP only applies the four extractions).
## Risks
- Behaviour drift in the central stores — mitigated: the extractions are 1:1 with the originals
and gated by the existing brief/org-template/stamdata specs (all green).
+1133 -561
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,65 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect } from 'vitest';
import { Result, ok } from '@shared/kernel/fp';
import { StamRow, StamTable } from '@beheer/domain/stamdata';
import { StamdataAdapter } from '@beheer/infrastructure/stamdata.adapter';
import { StamdataStore } from './stamdata.store';
const table: StamTable = {
id: 'professions',
label: 'Opleiding → beroep',
temporal: false,
columns: [
{ name: 'program', type: 'text', isKey: true, options: [] },
{ name: 'beroep', type: 'text', isKey: false, options: [] },
],
};
const rows: StamRow[] = [{ program: 'geneeskunde', beroep: 'Arts' }];
function setup(): StamdataStore {
const adapter: Partial<StamdataAdapter> = {
list: (): Promise<Result<string, StamTable[]>> => Promise.resolve(ok([table])),
load: (): Promise<Result<string, { table: StamTable; rows: StamRow[] }>> =>
Promise.resolve(ok({ table, rows: rows.map((r) => ({ ...r })) })),
};
TestBed.configureTestingModule({ providers: [{ provide: StamdataAdapter, useValue: adapter }] });
return TestBed.inject(StamdataStore);
}
describe('StamdataStore undo/redo (WP-32)', () => {
it('records a cell edit, undoes and redoes it', async () => {
const store = setup();
await store.load();
expect(store.canUndo()).toBe(false);
store.editCell(0, 'beroep', 'Chirurg');
expect(store.rows()[0]['beroep']).toBe('Chirurg');
expect(store.canUndo()).toBe(true);
store.undo();
expect(store.rows()[0]['beroep']).toBe('Arts');
expect(store.canRedo()).toBe(true);
store.redo();
expect(store.rows()[0]['beroep']).toBe('Chirurg');
expect(store.canRedo()).toBe(false);
});
it('records addRow and undoes it', async () => {
const store = setup();
await store.load();
store.addRow();
expect(store.rows().length).toBe(2);
store.undo();
expect(store.rows().length).toBe(1);
});
it('clears history when switching table', async () => {
const store = setup();
await store.load();
store.addRow();
expect(store.canUndo()).toBe(true);
await store.selectTable('professions');
expect(store.canUndo()).toBe(false);
});
});
+33 -15
View File
@@ -1,6 +1,7 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { createStore } from '@shared/application/store';
import { machineRemoteData } from '@shared/application/machine-remote-data';
import { createHistory } from '@shared/application/history';
import {
ChangeCounts,
StamRow,
@@ -39,17 +40,7 @@ export class StamdataStore {
so toggling it never round-trips or drops unsaved edits (see domain `activeOn`). */
readonly previewDate = signal<string>('');
readonly remoteData = computed<RemoteData<Error | undefined, LoadedState>>(() => {
const s = this.model();
switch (s.tag) {
case 'loading':
return { tag: 'Loading' };
case 'failed':
return { tag: 'Failure', error: new Error(s.reason) };
case 'loaded':
return { tag: 'Success', value: s };
}
});
readonly remoteData = computed(() => machineRemoteData(this.model()));
private loaded = computed<LoadedState | null>(() => {
const s = this.model();
@@ -95,6 +86,7 @@ export class StamdataStore {
async selectTable(tableId: string) {
this.selectedTableId.set(tableId);
this.previewDate.set('');
this.history.clear(); // undo history is per-table, not across tables
this.store.dispatch({ tag: 'Loading' });
const r = await this.adapter.load(tableId);
if (r.ok) this.store.dispatch({ tag: 'Loaded', table: r.value.table, rows: r.value.rows });
@@ -105,14 +97,40 @@ export class StamdataStore {
this.previewDate.set(date);
}
/** Undo/redo over the edited rows (WP-32): the document snapshot is `rows`; restore via
the existing `Seed` msg. Only real edits are recorded (a no-op reduce leaves no step). */
private history = createHistory<readonly StamRow[]>(50);
readonly canUndo = this.history.canUndo;
readonly canRedo = this.history.canRedo;
private recordThenDispatch(msg: StamdataEditorMsg) {
const before = this.rows();
this.store.dispatch(msg);
if (this.loaded() && this.rows() !== before) this.history.record(before);
}
editCell(row: number, column: string, value: string) {
this.store.dispatch({ tag: 'CellEdited', row, column, value });
this.recordThenDispatch({ tag: 'CellEdited', row, column, value });
}
addRow() {
this.store.dispatch({ tag: 'RowAdded' });
this.recordThenDispatch({ tag: 'RowAdded' });
}
removeRow(row: number) {
this.store.dispatch({ tag: 'RowRemoved', row });
this.recordThenDispatch({ tag: 'RowRemoved', row });
}
undo() {
this.restore((rows) => this.history.undo(rows));
}
redo() {
this.restore((rows) => this.history.redo(rows));
}
private restore(step: (current: readonly StamRow[]) => readonly StamRow[] | undefined) {
const s = this.loaded();
if (!s) return;
const target = step(s.rows);
if (target === undefined) return;
// copy readonly history snapshot into the machine's mutable rows shape
this.store.dispatch({ tag: 'Seed', state: { ...s, rows: [...target] } });
}
/** Emit the edited data-file for the admin to drop into the repo (see domain `toJson`). */
@@ -163,6 +163,12 @@ interface DisplayRow {
@if (!previewing()) {
<div class="footer">
<app-button variant="subtle" [disabled]="!canUndo()" (click)="undo.emit()">{{
undoLabel
}}</app-button>
<app-button variant="subtle" [disabled]="!canRedo()" (click)="redo.emit()">{{
redoLabel
}}</app-button>
<app-button variant="secondary" (click)="rowAdded.emit()">{{ addRowLabel }}</app-button>
<span class="counts">{{ countsLabel() }}</span>
<app-button variant="primary" [disabled]="!canDownload()" (click)="download.emit()">{{
@@ -180,6 +186,8 @@ export class StamdataTableEditorComponent {
counts = input.required<ChangeCounts>();
previewDate = input('');
canDownload = input(false);
canUndo = input(false);
canRedo = input(false);
tables = input<readonly StamTable[]>([]);
selectedTableId = input<string | null>(null);
@@ -189,6 +197,8 @@ export class StamdataTableEditorComponent {
rowRemoved = output<number>();
previewDateChanged = output<string>();
download = output<void>();
undo = output<void>();
redo = output<void>();
protected previewing = computed(() => this.previewDate() !== '');
@@ -224,6 +234,8 @@ export class StamdataTableEditorComponent {
protected previewNote = $localize`:@@beheer.previewNote:Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit.`;
protected actionsLabel = $localize`:@@beheer.actions:Acties`;
protected removeLabel = $localize`:@@beheer.remove:Verwijderen`;
protected undoLabel = $localize`:@@beheer.undo:Ongedaan maken`;
protected redoLabel = $localize`:@@beheer.redo:Opnieuw uitvoeren`;
protected addRowLabel = $localize`:@@beheer.addRow:Rij toevoegen`;
protected downloadLabel = $localize`:@@beheer.download:Download JSON`;
protected applyHint = $localize`:@@beheer.applyHint:Wijzigingen worden als JSON-bestand gedownload en via een pull request toegepast — de build (CI) controleert ze.`;
+16
View File
@@ -14,6 +14,7 @@ import { StamdataTableEditorComponent } from '@beheer/ui/stamdata-table-editor/s
*/
@Component({
selector: 'app-stamdata-page',
host: { '(document:keydown)': 'onKeydown($event)' },
imports: [
PageShellComponent,
AlertComponent,
@@ -42,6 +43,8 @@ import { StamdataTableEditorComponent } from '@beheer/ui/stamdata-table-editor/s
[counts]="store.counts()"
[previewDate]="store.previewDate()"
[canDownload]="store.canDownload()"
[canUndo]="store.canUndo()"
[canRedo]="store.canRedo()"
[tables]="store.tables()"
[selectedTableId]="store.selectedTableId()"
(selectTable)="store.selectTable($event)"
@@ -50,6 +53,8 @@ import { StamdataTableEditorComponent } from '@beheer/ui/stamdata-table-editor/s
(rowRemoved)="store.removeRow($event)"
(previewDateChanged)="store.setPreviewDate($event)"
(download)="store.download()"
(undo)="store.undo()"
(redo)="store.redo()"
/>
}
</ng-template>
@@ -86,4 +91,15 @@ export class StamdataPage {
protected reload() {
void this.store.load();
}
/** Ctrl/Cmd+Z undo, Ctrl/Cmd+Shift+Z redo (WP-32). Ignored while focus is in a grid
cell input so the browser's native text-undo still works there (mirrors brief.page). */
protected onKeydown(e: KeyboardEvent) {
if (!this.canEdit() || !(e.ctrlKey || e.metaKey) || (e.key !== 'z' && e.key !== 'Z')) return;
const t = e.target as HTMLElement | null;
if (t && (t.isContentEditable || ['INPUT', 'SELECT', 'TEXTAREA'].includes(t.tagName))) return;
e.preventDefault();
if (e.shiftKey) this.store.redo();
else this.store.undo();
}
}
+37 -80
View File
@@ -1,7 +1,10 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { RemoteData } from '@shared/application/remote-data';
import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state';
import { createHistory } from '@shared/application/history';
import { createDebouncedSave } from '@shared/application/debounced-save';
import { machineRemoteData } from '@shared/application/machine-remote-data';
import {
Brief,
CaseContext,
@@ -19,17 +22,6 @@ import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.a
import { uploadContentUrl } from '@shared/upload/upload.adapter';
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
instead of a busy boolean + a nullable error sitting side by side. */
type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
/** Debounced-autosave indicator, shown in a small status line near the toolbar —
a separate concern from ActionState (a stale autosave error doesn't block
submit/approve/reject), but tag-aligned with it for one consistent idiom. */
type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
type LoadedBriefState = Extract<BriefState, { tag: 'loaded' }>;
/**
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
* read-model, and the commands (effects) that call the adapter and dispatch the
@@ -57,17 +49,14 @@ export class BriefStore implements PendingSave {
/** Surfaced autosave state for the indicator + aria-live region. */
readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** Undo/redo is SHELL state, not machine state (WP-27): a stack of past/future
`Brief` snapshots. Each is a deep-frozen immutable value, so sharing is safe.
Only CONTENT edits are recorded (they flow through `edit()`); status transitions
never enter history, or undo would replay workflow state. Capped so a long session
can't grow unbounded. Restore re-dispatches the existing `Seed` Msg — zero machine
/** Undo/redo is SHELL state, not machine state (WP-27): a `createHistory` stack of
`Brief` snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded
(they flow through `edit()`); status transitions never enter history, or undo would
replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine
changes. */
private static readonly HISTORY_CAP = 50;
private past = signal<readonly Brief[]>([]);
private future = signal<readonly Brief[]>([]);
readonly canUndo = computed(() => this.past().length > 0);
readonly canRedo = computed(() => this.future().length > 0);
private history = createHistory<Brief>(50);
readonly canUndo = this.history.canUndo;
readonly canRedo = this.history.canRedo;
/** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The
approver diffs it against the resubmitted letter. POC limit: in-memory only, so a
@@ -104,17 +93,7 @@ export class BriefStore implements PendingSave {
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
purely a projection of its loading/failed tags onto the shared async seam. */
readonly remoteData = computed<RemoteData<Error | undefined, LoadedBriefState>>(() => {
const s = this.model();
switch (s.tag) {
case 'loading':
return { tag: 'Loading' };
case 'failed':
return { tag: 'Failure', error: new Error(s.reason) };
case 'loaded':
return { tag: 'Success', value: s };
}
});
readonly remoteData = computed(() => machineRemoteData(this.model()));
private brief = computed<Brief | null>(() => {
const s = this.model();
@@ -145,7 +124,7 @@ export class BriefStore implements PendingSave {
if (r.ok) {
this.orgTemplate.set(r.value.orgTemplate);
this.caseContext.set(r.value.caseContext);
this.clearHistory();
this.history.clear();
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else {
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
@@ -159,34 +138,26 @@ export class BriefStore implements PendingSave {
const before = this.brief();
this.store.dispatch(msg);
const after = this.brief();
if (before && after && after !== before) {
this.past.update((p) => [...p, before].slice(-BriefStore.HISTORY_CAP));
this.future.set([]);
}
this.scheduleSave();
// Record only a real change: a no-op edit (e.g. a locked section) returns the same
// value and leaves no dead history step.
if (before && after && after !== before) this.history.record(before);
this.debouncedSave.schedule();
}
/** Undo: restore the previous snapshot via the existing `Seed` Msg, push the current
onto the redo stack, then autosave. Redo is the mirror image. */
/** Undo/redo: restore a snapshot via the existing `Seed` Msg, then autosave. */
undo() {
this.step(this.past, this.future);
this.restore((current) => this.history.undo(current));
}
redo() {
this.step(this.future, this.past);
this.restore((current) => this.history.redo(current));
}
private step(from: typeof this.past, to: typeof this.future) {
private restore(step: (current: Brief) => Brief | undefined) {
const s = this.model();
const target = from().at(-1);
if (s.tag !== 'loaded' || !target) return;
from.update((x) => x.slice(0, -1));
to.update((x) => [...x, s.brief].slice(-BriefStore.HISTORY_CAP));
if (s.tag !== 'loaded') return;
const target = step(s.brief);
if (target === undefined) return;
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
this.scheduleSave();
}
private clearHistory() {
this.past.set([]);
this.future.set([]);
this.debouncedSave.schedule();
}
constructor() {
@@ -195,27 +166,15 @@ export class BriefStore implements PendingSave {
registerPendingSave(this);
}
private saveTimer?: ReturnType<typeof setTimeout>;
private scheduleSave() {
if (!this.canEdit()) return;
clearTimeout(this.saveTimer);
// ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.
// Null the handle when it fires so `hasPendingSave()` reflects "a write is still owed".
this.saveTimer = setTimeout(() => {
this.saveTimer = undefined;
void this.flushSave();
}, 600);
}
/** True while a debounced edit hasn't been written yet (PendingSave). */
hasPendingSave = () => this.saveTimer !== undefined;
/** Flush a pending debounced save now and await it; no-op when nothing is pending. */
async flushPending() {
if (this.saveTimer === undefined) return;
clearTimeout(this.saveTimer);
this.saveTimer = undefined;
await this.flushSave();
}
// 600ms debounced autosave (the server is the store of record). Timer mechanics live in
// the shared helper; `flushSave` below is the store-specific write + save-state (WP-31).
private debouncedSave = createDebouncedSave({
canSave: () => this.canEdit(),
flush: () => this.flushSave(),
});
/** PendingSave: delegate to the debounce helper so the guard/unload can flush. */
hasPendingSave = () => this.debouncedSave.hasPendingSave();
flushPending = () => this.debouncedSave.flushPending();
private async flushSave() {
const b = this.brief();
if (!b) return;
@@ -237,15 +196,14 @@ export class BriefStore implements PendingSave {
/** Demo "start over": recreate the brief server-side and load the fresh view. */
async resetDemo() {
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
this.saveTimer = undefined;
this.debouncedSave.cancel();
const r = await this.adapter.reset();
this.saveState.set({ tag: 'Idle' });
if (r.ok) {
this.actionState.set({ tag: 'Idle' });
this.orgTemplate.set(r.value.orgTemplate);
this.caseContext.set(r.value.caseContext);
this.clearHistory();
this.history.clear();
this.rejectionSnapshot.set(null);
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else {
@@ -289,8 +247,7 @@ export class BriefStore implements PendingSave {
// the returned status through the pure reducer's guarded transition.
private async transition(action: () => Promise<Result<string, BriefView>>) {
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
this.saveTimer = undefined;
this.debouncedSave.cancel();
await this.flushSave();
const r = await action();
if (!r.ok) {
+20 -46
View File
@@ -1,6 +1,8 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state';
import { createDebouncedSave } from '@shared/application/debounced-save';
import { machineRemoteData } from '@shared/application/machine-remote-data';
import { UploadAdapter } from '@shared/upload/upload.adapter';
import { UploadShellService } from '@shared/upload/upload-shell.service';
import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';
@@ -19,9 +21,6 @@ import {
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
/** Transient action state for publish/rollback/proefbrief — the BriefStore idiom. */
type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>;
const LOGO_CATEGORY = 'org-logo';
@@ -57,17 +56,7 @@ export class OrgTemplateStore implements PendingSave {
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */
readonly pendingPublish = signal(false);
readonly remoteData = computed<RemoteData<Error | undefined, LoadedState>>(() => {
const s = this.model();
switch (s.tag) {
case 'loading':
return { tag: 'Loading' };
case 'failed':
return { tag: 'Failure', error: new Error(s.reason) };
case 'loaded':
return { tag: 'Success', value: s };
}
});
readonly remoteData = computed(() => machineRemoteData(this.model()));
private loaded = computed<LoadedState | null>(() => {
const s = this.model();
@@ -138,8 +127,7 @@ export class OrgTemplateStore implements PendingSave {
async selectSubOrg(subOrgId: string) {
this.selectedSubOrgId.set(subOrgId);
this.saveState.set({ tag: 'Idle' });
clearTimeout(this.saveTimer);
this.saveTimer = undefined;
this.debouncedSave.cancel();
this.store.dispatch({ tag: 'Loading' });
const r = await this.adapter.load(subOrgId);
if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value });
@@ -149,30 +137,18 @@ export class OrgTemplateStore implements PendingSave {
/** An in-place canvas or margin edit: apply optimistically, then debounce-save. */
edit(msg: OrgTemplateMsg) {
this.store.dispatch(msg);
this.scheduleSave();
this.debouncedSave.schedule();
}
private saveTimer?: ReturnType<typeof setTimeout>;
private scheduleSave() {
if (this.loaded() === null) return;
clearTimeout(this.saveTimer);
// ponytail: 600ms debounce, same as BriefStore; the server is the store of record.
// Null the handle when it fires so `hasPendingSave()` reflects "a write is still owed".
this.saveTimer = setTimeout(() => {
this.saveTimer = undefined;
void this.flushSave();
}, 600);
}
/** True while a debounced edit hasn't been written yet (PendingSave). */
hasPendingSave = () => this.saveTimer !== undefined;
/** Flush a pending debounced save now and await it; no-op when nothing is pending. */
async flushPending() {
if (this.saveTimer === undefined) return;
clearTimeout(this.saveTimer);
this.saveTimer = undefined;
await this.flushSave();
}
// 600ms debounced autosave (same idiom as BriefStore, WP-31). Timer mechanics live in the
// shared helper; `flushSave` below is the store-specific write + save-state.
private debouncedSave = createDebouncedSave({
canSave: () => this.loaded() !== null,
flush: () => this.flushSave(),
});
/** PendingSave: delegate to the debounce helper so the guard/unload can flush. */
hasPendingSave = () => this.debouncedSave.hasPendingSave();
flushPending = () => this.debouncedSave.flushPending();
private async flushSave() {
const s = this.loaded();
if (!s || !s.dirty) return;
@@ -201,8 +177,7 @@ export class OrgTemplateStore implements PendingSave {
if (!s) return;
this.pendingPublish.set(false);
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
this.saveTimer = undefined;
this.debouncedSave.cancel();
await this.flushSave(); // publish the saved draft — flush any pending edit first
const r = await this.adapter.publish(s.subOrgId);
if (!r.ok) {
@@ -217,8 +192,7 @@ export class OrgTemplateStore implements PendingSave {
const s = this.loaded();
if (!s) return;
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
this.saveTimer = undefined;
this.debouncedSave.cancel();
const r = await this.adapter.rollback(s.subOrgId, version);
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
@@ -232,8 +206,7 @@ export class OrgTemplateStore implements PendingSave {
const s = this.loaded();
if (!s) return;
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
this.saveTimer = undefined;
this.debouncedSave.cancel();
await this.flushSave(); // the proefbrief renders the server's draft
const r = await this.adapter.proefbrief(s.subOrgId);
if (!r.ok) {
@@ -294,6 +267,7 @@ export class OrgTemplateStore implements PendingSave {
draft (in the reducer) and needs persisting. */
private onUploadMsg(msg: UploadMsg) {
this.dispatchUpload(msg);
if (msg.type === 'UploadComplete' || msg.type === 'UploadRemoved') this.scheduleSave();
if (msg.type === 'UploadComplete' || msg.type === 'UploadRemoved')
this.debouncedSave.schedule();
}
}
@@ -0,0 +1,9 @@
/** Transient state of a one-shot action (submit/approve/publish/reset/…): one tagged
union instead of a busy boolean + a nullable error sitting side by side. Shared by the
editor stores (WP-31). */
export type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
/** Debounced-autosave indicator, shown in a small status line near a toolbar — a separate
concern from ActionState (a stale autosave error doesn't block submit/approve), but
tag-aligned with it for one consistent idiom. */
export type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createDebouncedSave } from './debounced-save';
describe('createDebouncedSave', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('flushes after the delay when canSave is true', async () => {
const flush = vi.fn().mockResolvedValue(undefined);
const d = createDebouncedSave({ delayMs: 600, canSave: () => true, flush });
d.schedule();
expect(d.hasPendingSave()).toBe(true);
expect(flush).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(600);
expect(flush).toHaveBeenCalledTimes(1);
expect(d.hasPendingSave()).toBe(false);
});
it('does not schedule when canSave is false', () => {
const flush = vi.fn().mockResolvedValue(undefined);
const d = createDebouncedSave({ canSave: () => false, flush });
d.schedule();
expect(d.hasPendingSave()).toBe(false);
});
it('coalesces rapid schedules into a single flush', async () => {
const flush = vi.fn().mockResolvedValue(undefined);
const d = createDebouncedSave({ delayMs: 100, canSave: () => true, flush });
d.schedule();
d.schedule();
d.schedule();
await vi.advanceTimersByTimeAsync(100);
expect(flush).toHaveBeenCalledTimes(1);
});
it('flushPending runs the save immediately and clears; no-op when idle', async () => {
const flush = vi.fn().mockResolvedValue(undefined);
const d = createDebouncedSave({ delayMs: 600, canSave: () => true, flush });
await d.flushPending();
expect(flush).not.toHaveBeenCalled(); // idle
d.schedule();
await d.flushPending();
expect(flush).toHaveBeenCalledTimes(1);
expect(d.hasPendingSave()).toBe(false);
});
it('cancel drops a scheduled save without running it', async () => {
const flush = vi.fn().mockResolvedValue(undefined);
const d = createDebouncedSave({ delayMs: 600, canSave: () => true, flush });
d.schedule();
d.cancel();
expect(d.hasPendingSave()).toBe(false);
await vi.advanceTimersByTimeAsync(600);
expect(flush).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,49 @@
export interface DebouncedSave {
/** (Re)arm the debounce timer; no-op when `canSave()` is false. */
schedule(): void;
/** True while a scheduled save hasn't run yet — implements `PendingSave.hasPendingSave`. */
hasPendingSave(): boolean;
/** Run a scheduled save now and await it; no-op when nothing is scheduled. */
flushPending(): Promise<void>;
/** Drop a scheduled save without running it (e.g. before an authoritative transition,
which flushes explicitly, or a reset that discards the draft). */
cancel(): void;
}
/**
* The debounced-autosave timer shared by the editor stores (WP-31). It owns ONLY the timer
* bookkeeping; the actual write + save-state transitions live in the caller's `flush`
* (store-specific — it touches that store's SaveState/ActionState + adapter). The handle is
* nulled the moment it fires, so `hasPendingSave()` means "a write is still owed". Integrates
* with the `PendingSave` seam (pending-saves.ts): a store delegates hasPendingSave/flushPending
* here so the CanDeactivate guard / beforeunload handler can flush a pending edit.
*/
export function createDebouncedSave(opts: {
delayMs?: number;
canSave: () => boolean;
flush: () => Promise<void>;
}): DebouncedSave {
const delay = opts.delayMs ?? 600;
let timer: ReturnType<typeof setTimeout> | undefined;
return {
schedule() {
if (!opts.canSave()) return;
clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
void opts.flush();
}, delay);
},
hasPendingSave: () => timer !== undefined,
async flushPending() {
if (timer === undefined) return;
clearTimeout(timer);
timer = undefined;
await opts.flush();
},
cancel() {
clearTimeout(timer);
timer = undefined;
},
};
}
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { createHistory } from './history';
describe('createHistory', () => {
it('starts empty; undo/redo are no-ops', () => {
const h = createHistory<number>();
expect(h.canUndo()).toBe(false);
expect(h.canRedo()).toBe(false);
expect(h.undo(1)).toBeUndefined();
expect(h.redo(1)).toBeUndefined();
});
it('records pre-edit snapshots, then undoes and redoes through them', () => {
const h = createHistory<string>();
// document went a -> b (record a) -> c (record b); current is 'c'
h.record('a');
h.record('b');
expect(h.canUndo()).toBe(true);
expect(h.undo('c')).toBe('b'); // current 'c' pushed to redo
expect(h.canRedo()).toBe(true);
expect(h.undo('b')).toBe('a');
expect(h.canUndo()).toBe(false);
expect(h.redo('a')).toBe('b');
expect(h.redo('b')).toBe('c');
expect(h.canRedo()).toBe(false);
});
it('record() clears the redo stack (no dead redo after a fresh edit)', () => {
const h = createHistory<string>();
h.record('a');
h.undo('b'); // redo now holds 'b'
expect(h.canRedo()).toBe(true);
h.record('x');
expect(h.canRedo()).toBe(false);
});
it('caps the stack depth', () => {
const h = createHistory<number>(3);
for (let i = 0; i < 5; i++) h.record(i);
let undos = 0;
let cur = 99;
while (h.canUndo()) {
cur = h.undo(cur)!;
undos++;
}
expect(undos).toBe(3);
});
it('clear() empties both stacks', () => {
const h = createHistory<number>();
h.record(1);
h.undo(2);
h.clear();
expect(h.canUndo()).toBe(false);
expect(h.canRedo()).toBe(false);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { Signal, computed, signal } from '@angular/core';
export interface History<T> {
readonly canUndo: Signal<boolean>;
readonly canRedo: Signal<boolean>;
/** Push a pre-edit snapshot onto the undo stack and drop the redo stack. */
record(snapshot: T): void;
/** Undo: pop the last recorded snapshot and return it (moving `current` onto the redo
stack); returns undefined and changes nothing when there's nothing to undo. */
undo(current: T): T | undefined;
/** Redo: mirror of undo. */
redo(current: T): T | undefined;
clear(): void;
}
/**
* Generic undo/redo history over an immutable "document" value `T`. Elm-store editors
* restore a returned snapshot by re-dispatching a `Seed`-style Msg — this helper only
* shuffles references, it never mutates them, so the caller must hold copy-on-write state
* (every edit produces a fresh value). Both stacks are capped so a long session can't grow
* unbounded. Extracted from BriefStore's WP-27 undo/redo (WP-31); reused by the stamdata
* editor (WP-32).
*/
export function createHistory<T>(cap = 50): History<T> {
const past = signal<readonly T[]>([]);
const future = signal<readonly T[]>([]);
return {
canUndo: computed(() => past().length > 0),
canRedo: computed(() => future().length > 0),
record(snapshot) {
past.update((p) => [...p, snapshot].slice(-cap));
future.set([]);
},
undo(current) {
const p = past();
if (p.length === 0) return undefined;
past.set(p.slice(0, -1));
future.update((f) => [...f, current].slice(-cap));
return p[p.length - 1];
},
redo(current) {
const f = future();
if (f.length === 0) return undefined;
future.set(f.slice(0, -1));
past.update((p) => [...p, current].slice(-cap));
return f[f.length - 1];
},
clear() {
past.set([]);
future.set([]);
},
};
}
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { machineRemoteData } from './machine-remote-data';
describe('machineRemoteData', () => {
it('maps loading → Loading', () => {
expect(machineRemoteData({ tag: 'loading' })).toEqual({ tag: 'Loading' });
});
it('maps failed → Failure carrying an Error with the reason', () => {
const rd = machineRemoteData({ tag: 'failed', reason: 'boom' });
expect(rd.tag).toBe('Failure');
if (rd.tag === 'Failure') expect(rd.error.message).toBe('boom');
});
it('maps loaded → Success carrying the whole loaded state', () => {
const loaded = { tag: 'loaded', foo: 42 } as const;
expect(machineRemoteData(loaded)).toEqual({ tag: 'Success', value: loaded });
});
});
@@ -0,0 +1,24 @@
import { RemoteData } from '@shared/application/remote-data';
/** The standard load-lifecycle tags an editor machine exposes. */
export type LoadLifecycle =
{ tag: 'loading' } | { tag: 'failed'; reason: string } | { tag: 'loaded' };
/**
* Project an Elm-machine state onto `RemoteData` for the `<app-async>` seam. The machine
* keeps owning its own domain lifecycle (draft/submitted/…); this is purely the
* loading/failed/loaded → async mapping, which was byte-identical across BriefStore,
* OrgTemplateStore and StamdataStore (WP-31). Wrap the call in a `computed`.
*/
export function machineRemoteData<S extends LoadLifecycle>(
s: S,
): RemoteData<Error, Extract<S, { tag: 'loaded' }>> {
switch (s.tag) {
case 'loading':
return { tag: 'Loading' };
case 'failed':
return { tag: 'Failure', error: new Error(s.reason) };
default: // 'loaded'
return { tag: 'Success', value: s as Extract<S, { tag: 'loaded' }> };
}
}
+17
View File
@@ -3598,6 +3598,23 @@
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.undo" datatype="html">
<source>Ongedaan maken</source>
<target datatype="html">Undo</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">237</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.redo" datatype="html">
<source>Opnieuw uitvoeren</source>
<target datatype="html">Redo</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">238</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>
+39 -25
View File
@@ -70,7 +70,7 @@
<source>Er is geen stamdata om te beheren.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/application/stamdata.store.ts</context>
<context context-type="linenumber">132</context>
<context context-type="linenumber">150</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.validation.key" datatype="html">
@@ -105,126 +105,140 @@
<source>toegevoegd</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">213</context>
<context context-type="linenumber">223</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.edited" datatype="html">
<source>gewijzigd</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">214</context>
<context context-type="linenumber">224</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.removed" datatype="html">
<source>verwijderd</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">215</context>
<context context-type="linenumber">225</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.table" datatype="html">
<source>Tabel</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">221</context>
<context context-type="linenumber">231</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.peildatum" datatype="html">
<source>Toon geldig op</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">222</context>
<context context-type="linenumber">232</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.showAll" datatype="html">
<source>Toon alles</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">223</context>
<context context-type="linenumber">233</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.previewNote" datatype="html">
<source>Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">224</context>
<context context-type="linenumber">234</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.actions" datatype="html">
<source>Acties</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">225</context>
<context context-type="linenumber">235</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.remove" datatype="html">
<source>Verwijderen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">226</context>
<context context-type="linenumber">236</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.undo" datatype="html">
<source>Ongedaan maken</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">237</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.redo" datatype="html">
<source>Opnieuw uitvoeren</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">238</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.addRow" datatype="html">
<source>Rij toevoegen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">227</context>
<context context-type="linenumber">239</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.download" datatype="html">
<source>Download JSON</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">228</context>
<context context-type="linenumber">240</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.applyHint" datatype="html">
<source>Wijzigingen worden als JSON-bestand gedownload en via een pull request toegepast — de build (CI) controleert ze.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">229</context>
<context context-type="linenumber">241</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.heading" datatype="html">
<source>Stamdata onderhouden</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata.page.ts</context>
<context context-type="linenumber">67</context>
<context context-type="linenumber">72</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.intro" datatype="html">
<source>Beheer de business-tabellen die de registratie stuurt. Wijzigingen worden als JSON gedownload en via een pull request toegepast; de build blijft de bewaker.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata.page.ts</context>
<context context-type="linenumber">68</context>
<context context-type="linenumber">73</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.denied" datatype="html">
<source>U hebt geen rechten om stamdata te onderhouden.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata.page.ts</context>
<context context-type="linenumber">69</context>
<context context-type="linenumber">74</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.failed" datatype="html">
<source>De stamdata kon niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata.page.ts</context>
<context context-type="linenumber">70</context>
<context context-type="linenumber">75</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata.page.ts</context>
<context context-type="linenumber">71</context>
<context context-type="linenumber">76</context>
</context-group>
</trans-unit>
<trans-unit id="orgTemplate.noSubOrgs" datatype="html">
<source>Er zijn geen organisatiesjablonen om te beheren.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/application/org-template.store.ts</context>
<context context-type="linenumber">28</context>
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="brief.lint.malformed" datatype="html">
@@ -2620,42 +2634,42 @@
<source>Huisstijl</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">26</context>
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="header.nav.stamdata" datatype="html">
<source>Stamdata</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">27</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit>
<trans-unit id="header.sender" datatype="html">
<source>BIG-register</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">62,63</context>
<context context-type="linenumber">70,71</context>
</context-group>
</trans-unit>
<trans-unit id="header.ministry" datatype="html">
<source>Ministerie van Volksgezondheid, Welzijn en Sport</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">64,66</context>
<context context-type="linenumber">72,74</context>
</context-group>
</trans-unit>
<trans-unit id="header.uitloggen" datatype="html">
<source> Uitloggen </source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">86,87</context>
<context context-type="linenumber">94,95</context>
</context-group>
</trans-unit>
<trans-unit id="header.navAria" datatype="html">
<source>Hoofdnavigatie</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">94,95</context>
<context context-type="linenumber">102,103</context>
</context-group>
</trans-unit>
<trans-unit id="wizard.naarStap" datatype="html">