From 4cf1147fc13484dbc1ffd62b1e17d1ff0f1818b4 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 22 Jul 2026 16:10:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(beheer):=20WP-32=20=E2=80=94=20undo/redo?= =?UTF-8?q?=20for=20the=20stamdata=20table=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- documentation.json | 1694 +++++++++++------ .../beheer/application/stamdata.store.spec.ts | 65 + src/app/beheer/application/stamdata.store.ts | 34 +- .../stamdata-table-editor.component.ts | 12 + src/app/beheer/ui/stamdata.page.ts | 16 + src/locale/messages.en.xlf | 17 + src/locale/messages.xlf | 64 +- 7 files changed, 1313 insertions(+), 589 deletions(-) create mode 100644 src/app/beheer/application/stamdata.store.spec.ts diff --git a/documentation.json b/documentation.json index a49832a..529dae0 100644 --- a/documentation.json +++ b/documentation.json @@ -1991,6 +1991,69 @@ "duplicateId": 1, "duplicateName": "DashboardViewDto-1" }, + { + "name": "DebouncedSave", + "id": "interface-DebouncedSave-fc6393aa87c7638fac9a4f8a1e552ea7ba0c5390c4835292c427eb0c98f23560a5686c27b4009cda9a4c9a967793cb13182e20ed529f7fbf31dff34bc4bae862", + "file": "src/app/shared/application/debounced-save.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "export interface DebouncedSave {\n /** (Re)arm the debounce timer; no-op when `canSave()` is false. */\n schedule(): void;\n /** True while a scheduled save hasn't run yet — implements `PendingSave.hasPendingSave`. */\n hasPendingSave(): boolean;\n /** Run a scheduled save now and await it; no-op when nothing is scheduled. */\n flushPending(): Promise;\n /** Drop a scheduled save without running it (e.g. before an authoritative transition,\n which flushes explicitly, or a reset that discards the draft). */\n cancel(): void;\n}\n\n/**\n * The debounced-autosave timer shared by the editor stores (WP-31). It owns ONLY the timer\n * bookkeeping; the actual write + save-state transitions live in the caller's `flush`\n * (store-specific — it touches that store's SaveState/ActionState + adapter). The handle is\n * nulled the moment it fires, so `hasPendingSave()` means \"a write is still owed\". Integrates\n * with the `PendingSave` seam (pending-saves.ts): a store delegates hasPendingSave/flushPending\n * here so the CanDeactivate guard / beforeunload handler can flush a pending edit.\n */\nexport function createDebouncedSave(opts: {\n delayMs?: number;\n canSave: () => boolean;\n flush: () => Promise;\n}): DebouncedSave {\n const delay = opts.delayMs ?? 600;\n let timer: ReturnType | undefined;\n return {\n schedule() {\n if (!opts.canSave()) return;\n clearTimeout(timer);\n timer = setTimeout(() => {\n timer = undefined;\n void opts.flush();\n }, delay);\n },\n hasPendingSave: () => timer !== undefined,\n async flushPending() {\n if (timer === undefined) return;\n clearTimeout(timer);\n timer = undefined;\n await opts.flush();\n },\n cancel() {\n clearTimeout(timer);\n timer = undefined;\n },\n };\n}\n", + "properties": [], + "indexSignatures": [], + "kind": 174, + "methods": [ + { + "name": "cancel", + "args": [], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 10, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\nDrop a scheduled save without running it (e.g. before an authoritative transition,\nwhich flushes explicitly, or a reset that discards the draft).", + "description": "

Drop a scheduled save without running it (e.g. before an authoritative transition,\nwhich flushes explicitly, or a reset that discards the draft).

\n" + }, + { + "name": "flushPending", + "args": [], + "optional": false, + "returnType": "Promise", + "typeParameters": [], + "line": 7, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\nRun a scheduled save now and await it; no-op when nothing is scheduled.", + "description": "

Run a scheduled save now and await it; no-op when nothing is scheduled.

\n" + }, + { + "name": "hasPendingSave", + "args": [], + "optional": false, + "returnType": "boolean", + "typeParameters": [], + "line": 5, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\nTrue while a scheduled save hasn't run yet — implements `PendingSave.hasPendingSave`.", + "description": "

True while a scheduled save hasn't run yet — implements PendingSave.hasPendingSave.

\n" + }, + { + "name": "schedule", + "args": [], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 3, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\n(Re)arm the debounce timer; no-op when `canSave()` is false.", + "description": "

(Re)arm the debounce timer; no-op when canSave() is false.

\n" + } + ], + "extends": [] + }, { "name": "Diagnostic", "id": "interface-Diagnostic-24aa1b1685594bea112d1b3c31df103c636a268f289c497bc10188a5406317268811107fbc86521cae511b7156f1d0967e6a409ccffcc903a367eafa46bb7e21", @@ -2127,12 +2190,12 @@ }, { "name": "DisplayRow", - "id": "interface-DisplayRow-6a5aeaa8ec97f6f8c54ca5436c496ec1b6c8d7b400fe73d1e25136c6d9fc54b04d78f7cd27c163fa17c2c3b256207fae7d90ac95cff23a5748a89b563fcbeaba", + "id": "interface-DisplayRow-c2c497936813284d0fd2f39d0d4236ff08834dc50a5659329af0547d899fd8e83c8ad23eb828c270d7bf62eae8069561233b44599a5da9d2ebc9591687f8cc0f", "file": "src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "import { Component, computed, input, output } from '@angular/core';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { ChangeCounts, StamColumn, StamRow, StamTable, activeOn } from '@beheer/domain/stamdata';\n\ninterface DisplayRow {\n row: StamRow;\n index: number;\n}\n\n/**\n * Organism: the GENERIC stamdata grid. It renders entirely from the reflected column\n * schema — one input per column type (native `date`/`number`, `enum` select, text) — so a\n * new stamdata table needs zero UI code here. The \"geldig op\" control filters to the rows\n * valid on a date (read-only preview); edits and download work on the full set. Emits\n * intent; the store owns state (CLAUDE.md §1).\n */\n@Component({\n selector: 'app-stamdata-table-editor',\n imports: [ButtonComponent],\n styles: [\n `\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n align-items: end;\n margin-block-end: 1rem;\n }\n .field label {\n display: block;\n font-size: 0.85em;\n color: var(--rhc-color-foreground-subtle);\n }\n table {\n inline-size: 100%;\n }\n .err {\n color: var(--rhc-color-rood-500);\n font-size: 0.85em;\n }\n .footer {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n align-items: center;\n margin-block-start: 1rem;\n }\n .counts {\n color: var(--rhc-color-foreground-subtle);\n }\n .hint {\n margin-block-start: 0.5rem;\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n `,\n ],\n template: `\n
\n @if (tables().length > 1) {\n
\n \n \n @for (t of tables(); track t.id) {\n \n }\n \n
\n }\n\n @if (table().temporal) {\n
\n \n \n
\n @if (previewing()) {\n {{\n showAll\n }}\n }\n }\n
\n\n @if (previewing()) {\n

{{ previewNote }}

\n }\n\n \n \n \n @for (col of table().columns; track col.name) {\n \n }\n \n \n \n \n @for (item of display(); track item.index) {\n \n @for (col of table().columns; track col.name) {\n \n }\n \n \n }\n \n
{{ col.name }}{{ previewing() ? '' : actionsLabel }}
\n @if (col.type === 'enum') {\n \n \n @for (opt of col.options; track opt) {\n \n }\n \n } @else {\n \n }\n \n @if (!previewing()) {\n {{ removeLabel }}\n }\n @if (errors()[item.index]) {\n {{ errors()[item.index] }}\n }\n
\n\n @if (!previewing()) {\n
\n {{ addRowLabel }}\n {{ countsLabel() }}\n {{\n downloadLabel\n }}\n
\n

{{ applyHint }}

\n }\n `,\n})\nexport class StamdataTableEditorComponent {\n table = input.required();\n rows = input.required();\n errors = input.required();\n counts = input.required();\n previewDate = input('');\n canDownload = input(false);\n tables = input([]);\n selectedTableId = input(null);\n\n selectTable = output();\n cellEdited = output<{ row: number; column: string; value: string }>();\n rowAdded = output();\n rowRemoved = output();\n previewDateChanged = output();\n download = output();\n\n protected previewing = computed(() => this.previewDate() !== '');\n\n protected display = computed(() =>\n this.rows()\n .map((row, index) => ({ row, index }))\n .filter(({ row }) => !this.previewing() || activeOn(this.table(), row, this.previewDate())),\n );\n\n protected inputType(col: StamColumn): string {\n return col.type === 'date' ? 'date' : col.type === 'number' ? 'number' : 'text';\n }\n\n protected cellLabel(col: StamColumn, index: number): string {\n return `${col.name} — rij ${index + 1}`;\n }\n\n protected asValue(e: Event): string {\n return (e.target as HTMLInputElement | HTMLSelectElement).value;\n }\n\n private addedWord = $localize`:@@beheer.added:toegevoegd`;\n private editedWord = $localize`:@@beheer.edited:gewijzigd`;\n private removedWord = $localize`:@@beheer.removed:verwijderd`;\n protected countsLabel = computed(() => {\n const c = this.counts();\n return `${c.added} ${this.addedWord} · ${c.edited} ${this.editedWord} · ${c.removed} ${this.removedWord}`;\n });\n\n protected tableLabel = $localize`:@@beheer.table:Tabel`;\n protected peildatumLabel = $localize`:@@beheer.peildatum:Toon geldig op`;\n protected showAll = $localize`:@@beheer.showAll:Toon alles`;\n protected previewNote = $localize`:@@beheer.previewNote:Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit.`;\n protected actionsLabel = $localize`:@@beheer.actions:Acties`;\n protected removeLabel = $localize`:@@beheer.remove:Verwijderen`;\n protected addRowLabel = $localize`:@@beheer.addRow:Rij toevoegen`;\n protected downloadLabel = $localize`:@@beheer.download:Download JSON`;\n protected applyHint = $localize`:@@beheer.applyHint:Wijzigingen worden als JSON-bestand gedownload en via een pull request toegepast — de build (CI) controleert ze.`;\n}\n", + "sourceCode": "import { Component, computed, input, output } from '@angular/core';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { ChangeCounts, StamColumn, StamRow, StamTable, activeOn } from '@beheer/domain/stamdata';\n\ninterface DisplayRow {\n row: StamRow;\n index: number;\n}\n\n/**\n * Organism: the GENERIC stamdata grid. It renders entirely from the reflected column\n * schema — one input per column type (native `date`/`number`, `enum` select, text) — so a\n * new stamdata table needs zero UI code here. The \"geldig op\" control filters to the rows\n * valid on a date (read-only preview); edits and download work on the full set. Emits\n * intent; the store owns state (CLAUDE.md §1).\n */\n@Component({\n selector: 'app-stamdata-table-editor',\n imports: [ButtonComponent],\n styles: [\n `\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n align-items: end;\n margin-block-end: 1rem;\n }\n .field label {\n display: block;\n font-size: 0.85em;\n color: var(--rhc-color-foreground-subtle);\n }\n table {\n inline-size: 100%;\n }\n .err {\n color: var(--rhc-color-rood-500);\n font-size: 0.85em;\n }\n .footer {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n align-items: center;\n margin-block-start: 1rem;\n }\n .counts {\n color: var(--rhc-color-foreground-subtle);\n }\n .hint {\n margin-block-start: 0.5rem;\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n `,\n ],\n template: `\n
\n @if (tables().length > 1) {\n
\n \n \n @for (t of tables(); track t.id) {\n \n }\n \n
\n }\n\n @if (table().temporal) {\n
\n \n \n
\n @if (previewing()) {\n {{\n showAll\n }}\n }\n }\n
\n\n @if (previewing()) {\n

{{ previewNote }}

\n }\n\n \n \n \n @for (col of table().columns; track col.name) {\n \n }\n \n \n \n \n @for (item of display(); track item.index) {\n \n @for (col of table().columns; track col.name) {\n \n }\n \n \n }\n \n
{{ col.name }}{{ previewing() ? '' : actionsLabel }}
\n @if (col.type === 'enum') {\n \n \n @for (opt of col.options; track opt) {\n \n }\n \n } @else {\n \n }\n \n @if (!previewing()) {\n {{ removeLabel }}\n }\n @if (errors()[item.index]) {\n {{ errors()[item.index] }}\n }\n
\n\n @if (!previewing()) {\n
\n {{\n undoLabel\n }}\n {{\n redoLabel\n }}\n {{ addRowLabel }}\n {{ countsLabel() }}\n {{\n downloadLabel\n }}\n
\n

{{ applyHint }}

\n }\n `,\n})\nexport class StamdataTableEditorComponent {\n table = input.required();\n rows = input.required();\n errors = input.required();\n counts = input.required();\n previewDate = input('');\n canDownload = input(false);\n canUndo = input(false);\n canRedo = input(false);\n tables = input([]);\n selectedTableId = input(null);\n\n selectTable = output();\n cellEdited = output<{ row: number; column: string; value: string }>();\n rowAdded = output();\n rowRemoved = output();\n previewDateChanged = output();\n download = output();\n undo = output();\n redo = output();\n\n protected previewing = computed(() => this.previewDate() !== '');\n\n protected display = computed(() =>\n this.rows()\n .map((row, index) => ({ row, index }))\n .filter(({ row }) => !this.previewing() || activeOn(this.table(), row, this.previewDate())),\n );\n\n protected inputType(col: StamColumn): string {\n return col.type === 'date' ? 'date' : col.type === 'number' ? 'number' : 'text';\n }\n\n protected cellLabel(col: StamColumn, index: number): string {\n return `${col.name} — rij ${index + 1}`;\n }\n\n protected asValue(e: Event): string {\n return (e.target as HTMLInputElement | HTMLSelectElement).value;\n }\n\n private addedWord = $localize`:@@beheer.added:toegevoegd`;\n private editedWord = $localize`:@@beheer.edited:gewijzigd`;\n private removedWord = $localize`:@@beheer.removed:verwijderd`;\n protected countsLabel = computed(() => {\n const c = this.counts();\n return `${c.added} ${this.addedWord} · ${c.edited} ${this.editedWord} · ${c.removed} ${this.removedWord}`;\n });\n\n protected tableLabel = $localize`:@@beheer.table:Tabel`;\n protected peildatumLabel = $localize`:@@beheer.peildatum:Toon geldig op`;\n protected showAll = $localize`:@@beheer.showAll:Toon alles`;\n protected previewNote = $localize`:@@beheer.previewNote:Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit.`;\n protected actionsLabel = $localize`:@@beheer.actions:Acties`;\n protected removeLabel = $localize`:@@beheer.remove:Verwijderen`;\n protected undoLabel = $localize`:@@beheer.undo:Ongedaan maken`;\n protected redoLabel = $localize`:@@beheer.redo:Opnieuw uitvoeren`;\n protected addRowLabel = $localize`:@@beheer.addRow:Rij toevoegen`;\n protected downloadLabel = $localize`:@@beheer.download:Download JSON`;\n protected applyHint = $localize`:@@beheer.applyHint:Wijzigingen worden als JSON-bestand gedownload en via een pull request toegepast — de build (CI) controleert ze.`;\n}\n", "properties": [ { "name": "index", @@ -3310,6 +3373,160 @@ "methods": [], "extends": [] }, + { + "name": "History", + "id": "interface-History-2c5e92c848921bd57a211b731b6f1cee9a5e627e38e562fceefb5e756ca6b74e3fe656a9d83486f00fb071676f9852aa6cf911ea8572a0608bfc9d94e9023c42", + "file": "src/app/shared/application/history.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "import { Signal, computed, signal } from '@angular/core';\n\nexport interface History {\n readonly canUndo: Signal;\n readonly canRedo: Signal;\n /** Push a pre-edit snapshot onto the undo stack and drop the redo stack. */\n record(snapshot: T): void;\n /** Undo: pop the last recorded snapshot and return it (moving `current` onto the redo\n stack); returns undefined and changes nothing when there's nothing to undo. */\n undo(current: T): T | undefined;\n /** Redo: mirror of undo. */\n redo(current: T): T | undefined;\n clear(): void;\n}\n\n/**\n * Generic undo/redo history over an immutable \"document\" value `T`. Elm-store editors\n * restore a returned snapshot by re-dispatching a `Seed`-style Msg — this helper only\n * shuffles references, it never mutates them, so the caller must hold copy-on-write state\n * (every edit produces a fresh value). Both stacks are capped so a long session can't grow\n * unbounded. Extracted from BriefStore's WP-27 undo/redo (WP-31); reused by the stamdata\n * editor (WP-32).\n */\nexport function createHistory(cap = 50): History {\n const past = signal([]);\n const future = signal([]);\n return {\n canUndo: computed(() => past().length > 0),\n canRedo: computed(() => future().length > 0),\n record(snapshot) {\n past.update((p) => [...p, snapshot].slice(-cap));\n future.set([]);\n },\n undo(current) {\n const p = past();\n if (p.length === 0) return undefined;\n past.set(p.slice(0, -1));\n future.update((f) => [...f, current].slice(-cap));\n return p[p.length - 1];\n },\n redo(current) {\n const f = future();\n if (f.length === 0) return undefined;\n future.set(f.slice(0, -1));\n past.update((p) => [...p, current].slice(-cap));\n return f[f.length - 1];\n },\n clear() {\n past.set([]);\n future.set([]);\n },\n };\n}\n", + "properties": [ + { + "name": "canRedo", + "deprecated": false, + "deprecationMessage": "", + "type": "Signal", + "indexKey": "", + "optional": false, + "description": "", + "line": 5, + "modifierKind": [ + 148 + ] + }, + { + "name": "canUndo", + "deprecated": false, + "deprecationMessage": "", + "type": "Signal", + "indexKey": "", + "optional": false, + "description": "", + "line": 4, + "modifierKind": [ + 148 + ] + } + ], + "indexSignatures": [], + "kind": 174, + "methods": [ + { + "name": "clear", + "args": [], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 13, + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "record", + "args": [ + { + "name": "snapshot", + "type": "T", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 7, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\nPush a pre-edit snapshot onto the undo stack and drop the redo stack.", + "description": "

Push a pre-edit snapshot onto the undo stack and drop the redo stack.

\n", + "jsdoctags": [ + { + "name": "snapshot", + "type": "T", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "redo", + "args": [ + { + "name": "current", + "type": "T", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ], + "optional": false, + "returnType": "T | undefined", + "typeParameters": [], + "line": 12, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\nRedo: mirror of undo.", + "description": "

Redo: mirror of undo.

\n", + "jsdoctags": [ + { + "name": "current", + "type": "T", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "undo", + "args": [ + { + "name": "current", + "type": "T", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ], + "optional": false, + "returnType": "T | undefined", + "typeParameters": [], + "line": 10, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\nUndo: pop the last recorded snapshot and return it (moving `current` onto the redo\nstack); returns undefined and changes nothing when there's nothing to undo.", + "description": "

Undo: pop the last recorded snapshot and return it (moving current onto the redo\nstack); returns undefined and changes nothing when there's nothing to undo.

\n", + "jsdoctags": [ + { + "name": "current", + "type": "T", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + ], + "extends": [] + }, { "name": "IntakePolicy", "id": "interface-IntakePolicy-5e5d31e9a961501d76826a01d95de1a753c795ded0003e2d7d4aece6a5b65c55dd32f7bb57e96a0eb98c9a6b67c4d9393d3b291db65b9176b275106b92ef8f6e", @@ -8690,7 +8907,7 @@ }, { "name": "BriefStore", - "id": "injectable-BriefStore-16e7ee051c2b3ad5c6544d54e37fe9fb5fd45103f760066b24a5e178a725ff6d8434c3ba13a5c7458bff940751e6ff7c5e2a92307f4a828ebe54d59875c8fe4c", + "id": "injectable-BriefStore-f2acf80758f5f4c4bb76d7554024db1a44004804daac663e2ef5dcc85617d136abe4d02b938b9af8e2fb42372a99c5567aacc85c5231c64845a8e2e400475182", "file": "src/app/brief/application/brief.store.ts", "properties": [ { @@ -8702,7 +8919,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 50, + "line": 42, "modifierKind": [ 123 ] @@ -8716,7 +8933,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 43, + "line": 35, "modifierKind": [ 123 ] @@ -8730,7 +8947,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 257 + "line": 215 }, { "name": "blockDiffs", @@ -8741,7 +8958,7 @@ "indexKey": "", "optional": false, "description": "

Changed/added/removed blocks since rejection — a pure fold over two snapshots.

\n", - "line": 77, + "line": 66, "rawdescription": "\nChanged/added/removed blocks since rejection — a pure fold over two snapshots.", "modifierKind": [ 148 @@ -8756,7 +8973,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 119, + "line": 98, "modifierKind": [ 123 ] @@ -8770,7 +8987,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 51, + "line": 43, "modifierKind": [ 148 ] @@ -8784,7 +9001,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 125, + "line": 104, "modifierKind": [ 148 ] @@ -8798,21 +9015,21 @@ "indexKey": "", "optional": false, "description": "", - "line": 124, + "line": 103, "modifierKind": [ 148 ] }, { "name": "canRedo", - "defaultValue": "computed(() => this.future().length > 0)", + "defaultValue": "this.history.canRedo", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "", - "line": 70, + "line": 59, "modifierKind": [ 148 ] @@ -8826,7 +9043,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 126, + "line": 105, "modifierKind": [ 148 ] @@ -8840,7 +9057,7 @@ "indexKey": "", "optional": false, "description": "

Field-level PII reveal (PRD-0002 §5c), deny-by-default like the action gates.

\n", - "line": 129, + "line": 108, "rawdescription": "\nField-level PII reveal (PRD-0002 §5c), deny-by-default like the action gates.", "modifierKind": [ 148 @@ -8855,7 +9072,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 127, + "line": 106, "modifierKind": [ 148 ] @@ -8869,7 +9086,7 @@ "indexKey": "", "optional": false, "description": "

Submit is allowed only when required sections are filled AND no blocking errors.

\n", - "line": 138, + "line": 117, "rawdescription": "\nSubmit is allowed only when required sections are filled AND no blocking errors.", "modifierKind": [ 148 @@ -8877,14 +9094,14 @@ }, { "name": "canUndo", - "defaultValue": "computed(() => this.past().length > 0)", + "defaultValue": "this.history.canUndo", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "", - "line": 69, + "line": 58, "modifierKind": [ 148 ] @@ -8898,12 +9115,26 @@ "indexKey": "", "optional": false, "description": "

The case (zorgverlener + aanvraag) this letter concerns — server-joined context for\nthe behandel scherm header, not letter state. Set from every server view.

\n", - "line": 96, + "line": 85, "rawdescription": "\nThe case (zorgverlener + aanvraag) this letter concerns — server-joined context for\nthe behandel scherm header, not letter state. Set from every server view.", "modifierKind": [ 148 ] }, + { + "name": "debouncedSave", + "defaultValue": "createDebouncedSave({\n canSave: () => this.canEdit(),\n flush: () => this.flushSave(),\n })", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 171, + "modifierKind": [ + 123 + ] + }, { "name": "decisions", "defaultValue": "computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s.decisions : null;\n })", @@ -8913,7 +9144,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 131, + "line": 110, "modifierKind": [ 123 ] @@ -8927,24 +9158,21 @@ "indexKey": "", "optional": false, "description": "", - "line": 135, + "line": 114, "modifierKind": [ 148 ] }, { - "name": "future", - "defaultValue": "signal([])", + "name": "flushPending", + "defaultValue": "() => {...}", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "", - "line": 68, - "modifierKind": [ - 123 - ] + "line": 177 }, { "name": "hasPendingSave", @@ -8954,9 +9182,9 @@ "type": "unknown", "indexKey": "", "optional": false, - "description": "

True while a debounced edit hasn't been written yet (PendingSave).

\n", - "line": 211, - "rawdescription": "\nTrue while a debounced edit hasn't been written yet (PendingSave)." + "description": "

PendingSave: delegate to the debounce helper so the guard/unload can flush.

\n", + "line": 176, + "rawdescription": "\nPendingSave: delegate to the debounce helper so the guard/unload can flush." }, { "name": "hasRejectionDiff", @@ -8967,26 +9195,24 @@ "indexKey": "", "optional": false, "description": "", - "line": 87, + "line": 76, "modifierKind": [ 148 ] }, { - "name": "HISTORY_CAP", - "defaultValue": "50", + "name": "history", + "defaultValue": "createHistory(50)", "deprecated": false, "deprecationMessage": "", - "type": "number", + "type": "unknown", "indexKey": "", "optional": false, - "description": "

Undo/redo is SHELL state, not machine state (WP-27): a stack of past/future\nBrief snapshots. Each is a deep-frozen immutable value, so sharing is safe.\nOnly CONTENT edits are recorded (they flow through edit()); status transitions\nnever enter history, or undo would replay workflow state. Capped so a long session\ncan't grow unbounded. Restore re-dispatches the existing Seed Msg — zero machine\nchanges.

\n", - "line": 66, - "rawdescription": "\nUndo/redo is SHELL state, not machine state (WP-27): a stack of past/future\n`Brief` snapshots. Each is a deep-frozen immutable value, so sharing is safe.\nOnly CONTENT edits are recorded (they flow through `edit()`); status transitions\nnever enter history, or undo would replay workflow state. Capped so a long session\ncan't grow unbounded. Restore re-dispatches the existing `Seed` Msg — zero machine\nchanges.", + "description": "

Undo/redo is SHELL state, not machine state (WP-27): a createHistory stack of\nBrief snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded\n(they flow through edit()); status transitions never enter history, or undo would\nreplay workflow state. Restore re-dispatches the existing Seed Msg — zero machine\nchanges.

\n", + "line": 57, + "rawdescription": "\nUndo/redo is SHELL state, not machine state (WP-27): a `createHistory` stack of\n`Brief` snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded\n(they flow through `edit()`); status transitions never enter history, or undo would\nreplay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine\nchanges.", "modifierKind": [ - 123, - 126, - 148 + 123 ] }, { @@ -8998,7 +9224,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 52, + "line": 44, "modifierKind": [ 148 ] @@ -9012,7 +9238,7 @@ "indexKey": "", "optional": false, "description": "

The org logo's content URL for the letterhead, or null when the template has none.

\n", - "line": 99, + "line": 88, "rawdescription": "\nThe org logo's content URL for the letterhead, or null when the template has none.", "modifierKind": [ 148 @@ -9027,7 +9253,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 48, + "line": 40, "modifierKind": [ 148 ] @@ -9041,26 +9267,12 @@ "indexKey": "", "optional": false, "description": "

The org template the letter renders with (WP-24). Server-owned appearance data,\nnot letter state — held beside the machine, never inside it (brief.machine.ts\nstays untouched by design). Set from every server view that carries it.

\n", - "line": 92, + "line": 81, "rawdescription": "\nThe org template the letter renders with (WP-24). Server-owned appearance data,\nnot letter state — held beside the machine, never inside it (`brief.machine.ts`\nstays untouched by design). Set from every server view that carries it.", "modifierKind": [ 148 ] }, - { - "name": "past", - "defaultValue": "signal([])", - "deprecated": false, - "deprecationMessage": "", - "type": "unknown", - "indexKey": "", - "optional": false, - "description": "", - "line": 67, - "modifierKind": [ - 123 - ] - }, { "name": "previewAdapter", "defaultValue": "inject(LetterPreviewAdapter)", @@ -9070,7 +9282,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 44, + "line": 36, "modifierKind": [ 123 ] @@ -9084,7 +9296,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 258 + "line": 216 }, { "name": "rejectionSnapshot", @@ -9095,7 +9307,7 @@ "indexKey": "", "optional": false, "description": "

The letter as it stood when it was REJECTED, captured shell-side (WP-27). The\napprover diffs it against the resubmitted letter. POC limit: in-memory only, so a\nfull page reload loses it — a real system would persist the rejected revision.

\n", - "line": 75, + "line": 64, "rawdescription": "\nThe letter as it stood when it was REJECTED, captured shell-side (WP-27). The\napprover diffs it against the resubmitted letter. POC limit: in-memory only, so a\nfull page reload loses it — a real system would persist the rejected revision.", "modifierKind": [ 123 @@ -9103,14 +9315,14 @@ }, { "name": "remoteData", - "defaultValue": "computed>(() => {\n const s = this.model();\n switch (s.tag) {\n case 'loading':\n return { tag: 'Loading' };\n case 'failed':\n return { tag: 'Failure', error: new Error(s.reason) };\n case 'loaded':\n return { tag: 'Success', value: s };\n }\n })", + "defaultValue": "computed(() => machineRemoteData(this.model()))", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "

The load lifecycle as RemoteData, for <app-async> — the machine keeps\nowning the letter's own domain lifecycle (draft/submitted/approved/…); this is\npurely a projection of its loading/failed tags onto the shared async seam.

\n", - "line": 107, + "line": 96, "rawdescription": "\nThe load lifecycle as `RemoteData`, for `` — the machine keeps\nowning the letter's own domain lifecycle (draft/submitted/approved/…); this is\npurely a projection of its loading/failed tags onto the shared async seam.", "modifierKind": [ 148 @@ -9125,7 +9337,7 @@ "indexKey": "", "optional": false, "description": "

Count of blocks removed since rejection — badged as a summary, since a removed\nblock no longer renders inline.

\n", - "line": 84, + "line": 73, "rawdescription": "\nCount of blocks removed since rejection — badged as a summary, since a removed\nblock no longer renders inline.", "modifierKind": [ 148 @@ -9140,7 +9352,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 45, + "line": 37, "modifierKind": [ 123 ] @@ -9154,25 +9366,12 @@ "indexKey": "", "optional": false, "description": "

Surfaced autosave state for the indicator + aria-live region.

\n", - "line": 58, + "line": 50, "rawdescription": "\nSurfaced autosave state for the indicator + aria-live region.", "modifierKind": [ 148 ] }, - { - "name": "saveTimer", - "deprecated": false, - "deprecationMessage": "", - "type": "ReturnType", - "indexKey": "", - "optional": true, - "description": "", - "line": 198, - "modifierKind": [ - 123 - ] - }, { "name": "send", "defaultValue": "() => {...}", @@ -9182,7 +9381,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 259 + "line": 217 }, { "name": "store", @@ -9193,7 +9392,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 46, + "line": 38, "modifierKind": [ 123 ] @@ -9207,7 +9406,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 256 + "line": 214 }, { "name": "unresolved", @@ -9218,7 +9417,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 136, + "line": 115, "modifierKind": [ 148 ] @@ -9240,7 +9439,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 304, + "line": 261, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -9260,19 +9459,6 @@ } ] }, - { - "name": "clearHistory", - "args": [], - "optional": false, - "returnType": "void", - "typeParameters": [], - "line": 187, - "deprecated": false, - "deprecationMessage": "", - "modifierKind": [ - 123 - ] - }, { "name": "edit", "args": [ @@ -9288,7 +9474,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 158, + "line": 137, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nAn edit: apply it optimistically in the pure reducer, then debounce-save. Records\nan undo step only when the reducer actually changed the brief (a no-op edit — e.g.\na locked section — returns the same value and leaves no dead history step).", @@ -9307,28 +9493,13 @@ } ] }, - { - "name": "flushPending", - "args": [], - "optional": false, - "returnType": "any", - "typeParameters": [], - "line": 213, - "deprecated": false, - "deprecationMessage": "", - "rawdescription": "\nFlush a pending debounced save now and await it; no-op when nothing is pending.", - "description": "

Flush a pending debounced save now and await it; no-op when nothing is pending.

\n", - "modifierKind": [ - 134 - ] - }, { "name": "flushSave", "args": [], "optional": false, "returnType": "any", "typeParameters": [], - "line": 219, + "line": 178, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -9342,7 +9513,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 143, + "line": 122, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -9355,7 +9526,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 264, + "line": 222, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nExplicit action, never a live re-render (PRD §8): opens the server-composed\nletter in a new tab. ponytail: the blob URL is never revoked — it's cheap and\nthe tab outlives this call; not worth a teardown hook for a POC.", @@ -9370,7 +9541,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 174, + "line": 151, "deprecated": false, "deprecationMessage": "" }, @@ -9380,7 +9551,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 238, + "line": 197, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nDemo \"start over\": recreate the brief server-side and load the fresh view.", @@ -9389,13 +9560,68 @@ 134 ] }, + { + "name": "restore", + "args": [ + { + "name": "step", + "type": "function", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "function": [ + { + "name": "current", + "type": "Brief", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ] + } + ], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 154, + "deprecated": false, + "deprecationMessage": "", + "modifierKind": [ + 123 + ], + "jsdoctags": [ + { + "name": "step", + "type": "function", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "function": [ + { + "name": "current", + "type": "Brief", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ], + "tagName": { + "text": "param" + } + } + ] + }, { "name": "retrySave", "args": [], "optional": false, "returnType": "void", "typeParameters": [], - "line": 233, + "line": 192, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nRetry a failed autosave — reuses the existing flush path, no new state (WP-27).", @@ -9407,7 +9633,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 279, + "line": 237, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nReveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability\n+ step-up and audits the attempt; on success we swap the masked value in the\nalready-loaded caseContext (a field update, not a reload). The step-up gesture\nitself is the UI's concern — this command just runs the audited server call.", @@ -9416,73 +9642,6 @@ 134 ] }, - { - "name": "scheduleSave", - "args": [], - "optional": false, - "returnType": "void", - "typeParameters": [], - "line": 199, - "deprecated": false, - "deprecationMessage": "", - "modifierKind": [ - 123 - ] - }, - { - "name": "step", - "args": [ - { - "name": "from", - "type": "unknown", - "optional": false, - "dotDotDotToken": false, - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "to", - "type": "unknown", - "optional": false, - "dotDotDotToken": false, - "deprecated": false, - "deprecationMessage": "" - } - ], - "optional": false, - "returnType": "void", - "typeParameters": [], - "line": 177, - "deprecated": false, - "deprecationMessage": "", - "modifierKind": [ - 123 - ], - "jsdoctags": [ - { - "name": "from", - "type": "unknown", - "optional": false, - "dotDotDotToken": false, - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "to", - "type": "unknown", - "optional": false, - "dotDotDotToken": false, - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, { "name": "transition", "args": [ @@ -9499,7 +9658,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 290, + "line": 248, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -9527,25 +9686,25 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 171, + "line": 148, "deprecated": false, "deprecationMessage": "", - "rawdescription": "\nUndo: restore the previous snapshot via the existing `Seed` Msg, push the current\nonto the redo stack, then autosave. Redo is the mirror image.", - "description": "

Undo: restore the previous snapshot via the existing Seed Msg, push the current\nonto the redo stack, then autosave. Redo is the mirror image.

\n" + "rawdescription": "\nUndo/redo: restore a snapshot via the existing `Seed` Msg, then autosave.", + "description": "

Undo/redo: restore a snapshot via the existing Seed Msg, then autosave.

\n" } ], "deprecated": false, "deprecationMessage": "", "description": "

Root singleton for the letter: the Elm store (Model + dispatch), the derived\nread-model, and the commands (effects) that call the adapter and dispatch the\noutcome. Mirrors BigProfileStore. All of canEdit/canApprove/canReject/\ncanSend, diagnostics, unresolved, canSubmit are DERIVED here — never\nstored. The permission flags come from the server's decision DTO (PRD-0002 phase\nP1) via BriefState.loaded.decisions — this store never computes them itself.

\n", "rawdescription": "\n\nRoot singleton for the letter: the Elm store (Model + dispatch), the derived\nread-model, and the commands (effects) that call the adapter and dispatch the\noutcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/\n`canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never\nstored. The permission flags come from the server's decision DTO (PRD-0002 phase\nP1) via `BriefState.loaded.decisions` — this store never computes them itself.\n", - "sourceCode": "import { Injectable, computed, inject, signal } from '@angular/core';\nimport { Result } from '@shared/kernel/fp';\nimport { RemoteData } from '@shared/application/remote-data';\nimport { createStore } from '@shared/application/store';\nimport {\n Brief,\n CaseContext,\n allDiagnostics,\n canSubmit,\n hasBlockingErrors,\n unresolvedPlaceholders,\n} from '@brief/domain/brief';\nimport { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';\nimport { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';\nimport { OrgTemplate } from '@brief/domain/org-template';\nimport { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';\nimport { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';\nimport { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';\nimport { uploadContentUrl } from '@shared/upload/upload.adapter';\nimport { PendingSave, registerPendingSave } from '@shared/application/pending-saves';\n\n/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union\n instead of a busy boolean + a nullable error sitting side by side. */\ntype ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };\n\n/** Debounced-autosave indicator, shown in a small status line near the toolbar —\n a separate concern from ActionState (a stale autosave error doesn't block\n submit/approve/reject), but tag-aligned with it for one consistent idiom. */\ntype SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };\n\ntype LoadedBriefState = Extract;\n\n/**\n * Root singleton for the letter: the Elm store (Model + dispatch), the derived\n * read-model, and the commands (effects) that call the adapter and dispatch the\n * outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/\n * `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never\n * stored. The permission flags come from the server's decision DTO (PRD-0002 phase\n * P1) via `BriefState.loaded.decisions` — this store never computes them itself.\n */\n@Injectable({ providedIn: 'root' })\nexport class BriefStore implements PendingSave {\n private adapter = inject(BriefAdapter);\n private previewAdapter = inject(LetterPreviewAdapter);\n private revealAdapter = inject(RevealBigNummerAdapter);\n private store = createStore(initial, reduce);\n\n readonly model = this.store.model;\n\n private actionState = signal({ tag: 'Idle' });\n readonly busy = computed(() => this.actionState().tag === 'Busy');\n readonly lastError = computed(() => {\n const s = this.actionState();\n return s.tag === 'Failed' ? s.error : null;\n });\n\n /** Surfaced autosave state for the indicator + aria-live region. */\n readonly saveState = signal({ tag: 'Idle' });\n\n /** Undo/redo is SHELL state, not machine state (WP-27): a stack of past/future\n `Brief` snapshots. Each is a deep-frozen immutable value, so sharing is safe.\n Only CONTENT edits are recorded (they flow through `edit()`); status transitions\n never enter history, or undo would replay workflow state. Capped so a long session\n can't grow unbounded. Restore re-dispatches the existing `Seed` Msg — zero machine\n changes. */\n private static readonly HISTORY_CAP = 50;\n private past = signal([]);\n private future = signal([]);\n readonly canUndo = computed(() => this.past().length > 0);\n readonly canRedo = computed(() => this.future().length > 0);\n\n /** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The\n approver diffs it against the resubmitted letter. POC limit: in-memory only, so a\n full page reload loses it — a real system would persist the rejected revision. */\n private rejectionSnapshot = signal(null);\n /** Changed/added/removed blocks since rejection — a pure fold over two snapshots. */\n readonly blockDiffs = computed>(() => {\n const before = this.rejectionSnapshot();\n const after = this.brief();\n return before && after ? changedBlocks(diffBlocks(before, after)) : new Map();\n });\n /** Count of blocks removed since rejection — badged as a summary, since a removed\n block no longer renders inline. */\n readonly removedSinceReject = computed(\n () => [...this.blockDiffs().values()].filter((k) => k === 'removed').length,\n );\n readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0);\n\n /** The org template the letter renders with (WP-24). Server-owned appearance data,\n not letter state — held beside the machine, never inside it (`brief.machine.ts`\n stays untouched by design). Set from every server view that carries it. */\n readonly orgTemplate = signal(null);\n\n /** The case (zorgverlener + aanvraag) this letter concerns — server-joined context for\n the behandel scherm header, not letter state. Set from every server view. */\n readonly caseContext = signal(null);\n\n /** The org logo's content URL for the letterhead, or null when the template has none. */\n readonly logoUrl = computed(() => {\n const id = this.orgTemplate()?.logoDocumentId;\n return id ? uploadContentUrl(id) : null;\n });\n\n /** The load lifecycle as `RemoteData`, for `` — the machine keeps\n owning the letter's own domain lifecycle (draft/submitted/approved/…); this is\n purely a projection of its loading/failed tags onto the shared async seam. */\n readonly remoteData = computed>(() => {\n const s = this.model();\n switch (s.tag) {\n case 'loading':\n return { tag: 'Loading' };\n case 'failed':\n return { tag: 'Failure', error: new Error(s.reason) };\n case 'loaded':\n return { tag: 'Success', value: s };\n }\n });\n\n private brief = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s.brief : null;\n });\n\n readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);\n readonly canApprove = computed(() => this.decisions()?.canApprove ?? false);\n readonly canReject = computed(() => this.decisions()?.canReject ?? false);\n readonly canSend = computed(() => this.decisions()?.canSend ?? false);\n /** Field-level PII reveal (PRD-0002 §5c), deny-by-default like the action gates. */\n readonly canRevealBigNummer = computed(() => this.decisions()?.canRevealBigNummer ?? false);\n\n private decisions = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s.decisions : null;\n });\n readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));\n readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));\n /** Submit is allowed only when required sections are filled AND no blocking errors. */\n readonly canSubmit = computed(() => {\n const b = this.brief();\n return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());\n });\n\n async load() {\n const r = await this.adapter.load();\n if (r.ok) {\n this.orgTemplate.set(r.value.orgTemplate);\n this.caseContext.set(r.value.caseContext);\n this.clearHistory();\n this.store.dispatch({ tag: 'BriefLoaded', ...r.value });\n } else {\n this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });\n }\n }\n\n /** An edit: apply it optimistically in the pure reducer, then debounce-save. Records\n an undo step only when the reducer actually changed the brief (a no-op edit — e.g.\n a locked section — returns the same value and leaves no dead history step). */\n edit(msg: BriefMsg) {\n const before = this.brief();\n this.store.dispatch(msg);\n const after = this.brief();\n if (before && after && after !== before) {\n this.past.update((p) => [...p, before].slice(-BriefStore.HISTORY_CAP));\n this.future.set([]);\n }\n this.scheduleSave();\n }\n\n /** Undo: restore the previous snapshot via the existing `Seed` Msg, push the current\n onto the redo stack, then autosave. Redo is the mirror image. */\n undo() {\n this.step(this.past, this.future);\n }\n redo() {\n this.step(this.future, this.past);\n }\n private step(from: typeof this.past, to: typeof this.future) {\n const s = this.model();\n const target = from().at(-1);\n if (s.tag !== 'loaded' || !target) return;\n from.update((x) => x.slice(0, -1));\n to.update((x) => [...x, s.brief].slice(-BriefStore.HISTORY_CAP));\n this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });\n this.scheduleSave();\n }\n\n private clearHistory() {\n this.past.set([]);\n this.future.set([]);\n }\n\n constructor() {\n // Register so the CanDeactivate guard / beforeunload handler can flush a pending\n // debounced edit before navigation or unload (see pending-saves.ts).\n registerPendingSave(this);\n }\n\n private saveTimer?: ReturnType;\n private scheduleSave() {\n if (!this.canEdit()) return;\n clearTimeout(this.saveTimer);\n // ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.\n // Null the handle when it fires so `hasPendingSave()` reflects \"a write is still owed\".\n this.saveTimer = setTimeout(() => {\n this.saveTimer = undefined;\n void this.flushSave();\n }, 600);\n }\n\n /** True while a debounced edit hasn't been written yet (PendingSave). */\n hasPendingSave = () => this.saveTimer !== undefined;\n /** Flush a pending debounced save now and await it; no-op when nothing is pending. */\n async flushPending() {\n if (this.saveTimer === undefined) return;\n clearTimeout(this.saveTimer);\n this.saveTimer = undefined;\n await this.flushSave();\n }\n private async flushSave() {\n const b = this.brief();\n if (!b) return;\n this.saveState.set({ tag: 'Saving' });\n const r = await this.adapter.save(b.sections);\n if (r.ok) {\n this.saveState.set({ tag: 'Saved' });\n } else {\n this.actionState.set({ tag: 'Failed', error: r.error });\n this.saveState.set({ tag: 'Error' });\n }\n }\n\n /** Retry a failed autosave — reuses the existing flush path, no new state (WP-27). */\n retrySave() {\n void this.flushSave();\n }\n\n /** Demo \"start over\": recreate the brief server-side and load the fresh view. */\n async resetDemo() {\n this.actionState.set({ tag: 'Busy' });\n clearTimeout(this.saveTimer);\n this.saveTimer = undefined;\n const r = await this.adapter.reset();\n this.saveState.set({ tag: 'Idle' });\n if (r.ok) {\n this.actionState.set({ tag: 'Idle' });\n this.orgTemplate.set(r.value.orgTemplate);\n this.caseContext.set(r.value.caseContext);\n this.clearHistory();\n this.rejectionSnapshot.set(null);\n this.store.dispatch({ tag: 'BriefLoaded', ...r.value });\n } else {\n this.actionState.set({ tag: 'Failed', error: r.error });\n }\n }\n\n submit = () => this.transition(() => this.adapter.submit());\n approve = () => this.transition(() => this.adapter.approve());\n reject = (comments: string) => this.transition(() => this.adapter.reject(comments));\n send = () => this.transition(() => this.adapter.send());\n\n /** Explicit action, never a live re-render (PRD §8): opens the server-composed\n letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and\n the tab outlives this call; not worth a teardown hook for a POC. */\n async previewLetter() {\n this.actionState.set({ tag: 'Busy' });\n const r = await this.previewAdapter.preview();\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n window.open(URL.createObjectURL(r.value), '_blank');\n }\n\n /** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability\n + step-up and audits the attempt; on success we swap the masked value in the\n already-loaded caseContext (a field update, not a reload). The step-up gesture\n itself is the UI's concern — this command just runs the audited server call. */\n async revealBigNummer() {\n const r = await this.revealAdapter.reveal();\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c));\n }\n\n // A transition: flush any pending save, call the server (authoritative), then mirror\n // the returned status through the pure reducer's guarded transition.\n private async transition(action: () => Promise>) {\n this.actionState.set({ tag: 'Busy' });\n clearTimeout(this.saveTimer);\n this.saveTimer = undefined;\n await this.flushSave();\n const r = await action();\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n this.applyServerStatus(r.value);\n }\n\n private applyServerStatus(view: BriefView) {\n // `send` pins the org-template version server-side — mirror whatever came back.\n this.orgTemplate.set(view.orgTemplate);\n this.caseContext.set(view.caseContext);\n const { brief, decisions } = view;\n const s = brief.status;\n switch (s.tag) {\n case 'submitted':\n this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt, decisions });\n break;\n case 'approved':\n this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });\n break;\n case 'rejected':\n // Capture the letter as-rejected for the resubmission diff (WP-27). This is the\n // \"before\" snapshot the approver later compares against.\n this.rejectionSnapshot.set(brief);\n this.store.dispatch({\n tag: 'Rejected',\n by: s.rejectedBy,\n at: s.rejectedAt,\n comments: s.comments,\n decisions,\n });\n break;\n case 'sent':\n this.store.dispatch({ tag: 'Sent', at: s.sentAt, decisions });\n break;\n case 'draft':\n // reopened by a save on a rejected letter — reducer already handled it locally.\n break;\n }\n }\n}\n", + "sourceCode": "import { Injectable, computed, inject, signal } from '@angular/core';\nimport { Result } from '@shared/kernel/fp';\nimport { createStore } from '@shared/application/store';\nimport { ActionState, SaveState } from '@shared/application/action-state';\nimport { createHistory } from '@shared/application/history';\nimport { createDebouncedSave } from '@shared/application/debounced-save';\nimport { machineRemoteData } from '@shared/application/machine-remote-data';\nimport {\n Brief,\n CaseContext,\n allDiagnostics,\n canSubmit,\n hasBlockingErrors,\n unresolvedPlaceholders,\n} from '@brief/domain/brief';\nimport { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';\nimport { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';\nimport { OrgTemplate } from '@brief/domain/org-template';\nimport { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';\nimport { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';\nimport { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';\nimport { uploadContentUrl } from '@shared/upload/upload.adapter';\nimport { PendingSave, registerPendingSave } from '@shared/application/pending-saves';\n\n/**\n * Root singleton for the letter: the Elm store (Model + dispatch), the derived\n * read-model, and the commands (effects) that call the adapter and dispatch the\n * outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/\n * `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never\n * stored. The permission flags come from the server's decision DTO (PRD-0002 phase\n * P1) via `BriefState.loaded.decisions` — this store never computes them itself.\n */\n@Injectable({ providedIn: 'root' })\nexport class BriefStore implements PendingSave {\n private adapter = inject(BriefAdapter);\n private previewAdapter = inject(LetterPreviewAdapter);\n private revealAdapter = inject(RevealBigNummerAdapter);\n private store = createStore(initial, reduce);\n\n readonly model = this.store.model;\n\n private actionState = signal({ tag: 'Idle' });\n readonly busy = computed(() => this.actionState().tag === 'Busy');\n readonly lastError = computed(() => {\n const s = this.actionState();\n return s.tag === 'Failed' ? s.error : null;\n });\n\n /** Surfaced autosave state for the indicator + aria-live region. */\n readonly saveState = signal({ tag: 'Idle' });\n\n /** Undo/redo is SHELL state, not machine state (WP-27): a `createHistory` stack of\n `Brief` snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded\n (they flow through `edit()`); status transitions never enter history, or undo would\n replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine\n changes. */\n private history = createHistory(50);\n readonly canUndo = this.history.canUndo;\n readonly canRedo = this.history.canRedo;\n\n /** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The\n approver diffs it against the resubmitted letter. POC limit: in-memory only, so a\n full page reload loses it — a real system would persist the rejected revision. */\n private rejectionSnapshot = signal(null);\n /** Changed/added/removed blocks since rejection — a pure fold over two snapshots. */\n readonly blockDiffs = computed>(() => {\n const before = this.rejectionSnapshot();\n const after = this.brief();\n return before && after ? changedBlocks(diffBlocks(before, after)) : new Map();\n });\n /** Count of blocks removed since rejection — badged as a summary, since a removed\n block no longer renders inline. */\n readonly removedSinceReject = computed(\n () => [...this.blockDiffs().values()].filter((k) => k === 'removed').length,\n );\n readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0);\n\n /** The org template the letter renders with (WP-24). Server-owned appearance data,\n not letter state — held beside the machine, never inside it (`brief.machine.ts`\n stays untouched by design). Set from every server view that carries it. */\n readonly orgTemplate = signal(null);\n\n /** The case (zorgverlener + aanvraag) this letter concerns — server-joined context for\n the behandel scherm header, not letter state. Set from every server view. */\n readonly caseContext = signal(null);\n\n /** The org logo's content URL for the letterhead, or null when the template has none. */\n readonly logoUrl = computed(() => {\n const id = this.orgTemplate()?.logoDocumentId;\n return id ? uploadContentUrl(id) : null;\n });\n\n /** The load lifecycle as `RemoteData`, for `` — the machine keeps\n owning the letter's own domain lifecycle (draft/submitted/approved/…); this is\n purely a projection of its loading/failed tags onto the shared async seam. */\n readonly remoteData = computed(() => machineRemoteData(this.model()));\n\n private brief = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s.brief : null;\n });\n\n readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);\n readonly canApprove = computed(() => this.decisions()?.canApprove ?? false);\n readonly canReject = computed(() => this.decisions()?.canReject ?? false);\n readonly canSend = computed(() => this.decisions()?.canSend ?? false);\n /** Field-level PII reveal (PRD-0002 §5c), deny-by-default like the action gates. */\n readonly canRevealBigNummer = computed(() => this.decisions()?.canRevealBigNummer ?? false);\n\n private decisions = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s.decisions : null;\n });\n readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));\n readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));\n /** Submit is allowed only when required sections are filled AND no blocking errors. */\n readonly canSubmit = computed(() => {\n const b = this.brief();\n return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());\n });\n\n async load() {\n const r = await this.adapter.load();\n if (r.ok) {\n this.orgTemplate.set(r.value.orgTemplate);\n this.caseContext.set(r.value.caseContext);\n this.history.clear();\n this.store.dispatch({ tag: 'BriefLoaded', ...r.value });\n } else {\n this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });\n }\n }\n\n /** An edit: apply it optimistically in the pure reducer, then debounce-save. Records\n an undo step only when the reducer actually changed the brief (a no-op edit — e.g.\n a locked section — returns the same value and leaves no dead history step). */\n edit(msg: BriefMsg) {\n const before = this.brief();\n this.store.dispatch(msg);\n const after = this.brief();\n // Record only a real change: a no-op edit (e.g. a locked section) returns the same\n // value and leaves no dead history step.\n if (before && after && after !== before) this.history.record(before);\n this.debouncedSave.schedule();\n }\n\n /** Undo/redo: restore a snapshot via the existing `Seed` Msg, then autosave. */\n undo() {\n this.restore((current) => this.history.undo(current));\n }\n redo() {\n this.restore((current) => this.history.redo(current));\n }\n private restore(step: (current: Brief) => Brief | undefined) {\n const s = this.model();\n if (s.tag !== 'loaded') return;\n const target = step(s.brief);\n if (target === undefined) return;\n this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });\n this.debouncedSave.schedule();\n }\n\n constructor() {\n // Register so the CanDeactivate guard / beforeunload handler can flush a pending\n // debounced edit before navigation or unload (see pending-saves.ts).\n registerPendingSave(this);\n }\n\n // 600ms debounced autosave (the server is the store of record). Timer mechanics live in\n // the shared helper; `flushSave` below is the store-specific write + save-state (WP-31).\n private debouncedSave = createDebouncedSave({\n canSave: () => this.canEdit(),\n flush: () => this.flushSave(),\n });\n /** PendingSave: delegate to the debounce helper so the guard/unload can flush. */\n hasPendingSave = () => this.debouncedSave.hasPendingSave();\n flushPending = () => this.debouncedSave.flushPending();\n private async flushSave() {\n const b = this.brief();\n if (!b) return;\n this.saveState.set({ tag: 'Saving' });\n const r = await this.adapter.save(b.sections);\n if (r.ok) {\n this.saveState.set({ tag: 'Saved' });\n } else {\n this.actionState.set({ tag: 'Failed', error: r.error });\n this.saveState.set({ tag: 'Error' });\n }\n }\n\n /** Retry a failed autosave — reuses the existing flush path, no new state (WP-27). */\n retrySave() {\n void this.flushSave();\n }\n\n /** Demo \"start over\": recreate the brief server-side and load the fresh view. */\n async resetDemo() {\n this.actionState.set({ tag: 'Busy' });\n this.debouncedSave.cancel();\n const r = await this.adapter.reset();\n this.saveState.set({ tag: 'Idle' });\n if (r.ok) {\n this.actionState.set({ tag: 'Idle' });\n this.orgTemplate.set(r.value.orgTemplate);\n this.caseContext.set(r.value.caseContext);\n this.history.clear();\n this.rejectionSnapshot.set(null);\n this.store.dispatch({ tag: 'BriefLoaded', ...r.value });\n } else {\n this.actionState.set({ tag: 'Failed', error: r.error });\n }\n }\n\n submit = () => this.transition(() => this.adapter.submit());\n approve = () => this.transition(() => this.adapter.approve());\n reject = (comments: string) => this.transition(() => this.adapter.reject(comments));\n send = () => this.transition(() => this.adapter.send());\n\n /** Explicit action, never a live re-render (PRD §8): opens the server-composed\n letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and\n the tab outlives this call; not worth a teardown hook for a POC. */\n async previewLetter() {\n this.actionState.set({ tag: 'Busy' });\n const r = await this.previewAdapter.preview();\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n window.open(URL.createObjectURL(r.value), '_blank');\n }\n\n /** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability\n + step-up and audits the attempt; on success we swap the masked value in the\n already-loaded caseContext (a field update, not a reload). The step-up gesture\n itself is the UI's concern — this command just runs the audited server call. */\n async revealBigNummer() {\n const r = await this.revealAdapter.reveal();\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c));\n }\n\n // A transition: flush any pending save, call the server (authoritative), then mirror\n // the returned status through the pure reducer's guarded transition.\n private async transition(action: () => Promise>) {\n this.actionState.set({ tag: 'Busy' });\n this.debouncedSave.cancel();\n await this.flushSave();\n const r = await action();\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n this.applyServerStatus(r.value);\n }\n\n private applyServerStatus(view: BriefView) {\n // `send` pins the org-template version server-side — mirror whatever came back.\n this.orgTemplate.set(view.orgTemplate);\n this.caseContext.set(view.caseContext);\n const { brief, decisions } = view;\n const s = brief.status;\n switch (s.tag) {\n case 'submitted':\n this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt, decisions });\n break;\n case 'approved':\n this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });\n break;\n case 'rejected':\n // Capture the letter as-rejected for the resubmission diff (WP-27). This is the\n // \"before\" snapshot the approver later compares against.\n this.rejectionSnapshot.set(brief);\n this.store.dispatch({\n tag: 'Rejected',\n by: s.rejectedBy,\n at: s.rejectedAt,\n comments: s.comments,\n decisions,\n });\n break;\n case 'sent':\n this.store.dispatch({ tag: 'Sent', at: s.sentAt, decisions });\n break;\n case 'draft':\n // reopened by a save on a rejected letter — reducer already handled it locally.\n break;\n }\n }\n}\n", "constructorObj": { "name": "constructor", "description": "", "deprecated": false, "deprecationMessage": "", "args": [], - "line": 190 + "line": 161 }, "extends": [], "type": "injectable" @@ -10325,7 +10484,7 @@ }, { "name": "OrgTemplateStore", - "id": "injectable-OrgTemplateStore-04f8a902ef42dbfff32f2b2d0c45f69e94939457413040784cb8b0a266ffa10bdba819449b7071360861fcfd1da37b52169d67f8fa2010e759c719d35894dd89", + "id": "injectable-OrgTemplateStore-48bee3b6e6c35650cc1672baf94836339ab8d260cb9bb4b4261e5d0ac2a5f023451566f9bb554d6432f9aecbdc11e1603c43e2cc93fe773990ef32ef9610e449", "file": "src/app/brief/application/org-template.store.ts", "properties": [ { @@ -10337,7 +10496,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 49, + "line": 48, "modifierKind": [ 123 ] @@ -10351,7 +10510,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 39, + "line": 38, "modifierKind": [ 123 ] @@ -10365,7 +10524,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 50, + "line": 49, "modifierKind": [ 148 ] @@ -10379,7 +10538,21 @@ "indexKey": "", "optional": false, "description": "", - "line": 102, + "line": 91, + "modifierKind": [ + 123 + ] + }, + { + "name": "debouncedSave", + "defaultValue": "createDebouncedSave({\n canSave: () => this.loaded() !== null,\n flush: () => this.flushSave(),\n })", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 145, "modifierKind": [ 123 ] @@ -10393,7 +10566,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 76, + "line": 65, "modifierKind": [ 148 ] @@ -10407,7 +10580,7 @@ "indexKey": "", "optional": false, "description": "

Client-side mirror of the server rules (OrgTemplateRules) for instant feedback;\nthe server re-validates and stays the authority — publish is gated on this.

\n", - "line": 88, + "line": 77, "rawdescription": "\nClient-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;\nthe server re-validates and stays the authority — publish is gated on this.", "modifierKind": [ 148 @@ -10422,11 +10595,22 @@ "indexKey": "", "optional": false, "description": "", - "line": 101, + "line": 90, "modifierKind": [ 123 ] }, + { + "name": "flushPending", + "defaultValue": "() => {...}", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 151 + }, { "name": "hasPendingSave", "defaultValue": "() => {...}", @@ -10435,9 +10619,9 @@ "type": "unknown", "indexKey": "", "optional": false, - "description": "

True while a debounced edit hasn't been written yet (PendingSave).

\n", - "line": 168, - "rawdescription": "\nTrue while a debounced edit hasn't been written yet (PendingSave)." + "description": "

PendingSave: delegate to the debounce helper so the guard/unload can flush.

\n", + "line": 150, + "rawdescription": "\nPendingSave: delegate to the debounce helper so the guard/unload can flush." }, { "name": "history", @@ -10448,7 +10632,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 78, + "line": 67, "modifierKind": [ 148 ] @@ -10462,7 +10646,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 51, + "line": 50, "modifierKind": [ 148 ] @@ -10476,7 +10660,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 72, + "line": 61, "modifierKind": [ 123 ] @@ -10490,7 +10674,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 81, + "line": 70, "modifierKind": [ 148 ] @@ -10504,7 +10688,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 44, + "line": 43, "modifierKind": [ 148 ] @@ -10518,7 +10702,7 @@ "indexKey": "", "optional": false, "description": "

The publish impact-confirm gate (PRD §7h: show N affected letters before POST).

\n", - "line": 58, + "line": 57, "rawdescription": "\nThe publish impact-confirm gate (PRD §7h: show N affected letters before POST).", "modifierKind": [ 148 @@ -10533,21 +10717,21 @@ "indexKey": "", "optional": false, "description": "", - "line": 79, + "line": 68, "modifierKind": [ 148 ] }, { "name": "remoteData", - "defaultValue": "computed>(() => {\n const s = this.model();\n switch (s.tag) {\n case 'loading':\n return { tag: 'Loading' };\n case 'failed':\n return { tag: 'Failure', error: new Error(s.reason) };\n case 'loaded':\n return { tag: 'Success', value: s };\n }\n })", + "defaultValue": "computed(() => machineRemoteData(this.model()))", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "", - "line": 60, + "line": 59, "modifierKind": [ 148 ] @@ -10561,24 +10745,11 @@ "indexKey": "", "optional": false, "description": "", - "line": 55, + "line": 54, "modifierKind": [ 148 ] }, - { - "name": "saveTimer", - "deprecated": false, - "deprecationMessage": "", - "type": "ReturnType", - "indexKey": "", - "optional": true, - "description": "", - "line": 155, - "modifierKind": [ - 123 - ] - }, { "name": "selectedSubOrgId", "defaultValue": "signal(null)", @@ -10588,7 +10759,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 47, + "line": 46, "modifierKind": [ 148 ] @@ -10602,7 +10773,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 41, + "line": 40, "modifierKind": [ 123 ] @@ -10616,7 +10787,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 42, + "line": 41, "modifierKind": [ 123 ] @@ -10630,7 +10801,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 46, + "line": 45, "modifierKind": [ 148 ] @@ -10644,7 +10815,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 80, + "line": 69, "modifierKind": [ 148 ] @@ -10658,7 +10829,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 40, + "line": 39, "modifierKind": [ 123 ] @@ -10672,7 +10843,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 77, + "line": 66, "modifierKind": [ 148 ] @@ -10685,7 +10856,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 196, + "line": 172, "deprecated": false, "deprecationMessage": "" }, @@ -10695,7 +10866,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 199, + "line": 175, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10717,7 +10888,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 290, + "line": 263, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10752,7 +10923,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 150, + "line": 138, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nAn in-place canvas or margin edit: apply optimistically, then debounce-save.", @@ -10771,28 +10942,13 @@ } ] }, - { - "name": "flushPending", - "args": [], - "optional": false, - "returnType": "any", - "typeParameters": [], - "line": 170, - "deprecated": false, - "deprecationMessage": "", - "rawdescription": "\nFlush a pending debounced save now and await it; no-op when nothing is pending.", - "description": "

Flush a pending debounced save now and await it; no-op when nothing is pending.

\n", - "modifierKind": [ - 134 - ] - }, { "name": "flushSave", "args": [], "optional": false, "returnType": "any", "typeParameters": [], - "line": 176, + "line": 152, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10806,7 +10962,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 122, + "line": 111, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10828,7 +10984,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 274, + "line": 247, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -10860,7 +11016,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 280, + "line": 253, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -10892,7 +11048,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 249, + "line": 222, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -10924,7 +11080,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 295, + "line": 268, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nUpload effects arriving from the transport: a finished/removed logo edits the\ndraft (in the reducer) and needs persisting.", @@ -10952,7 +11108,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 231, + "line": 205, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10965,7 +11121,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 193, + "line": 169, "deprecated": false, "deprecationMessage": "" }, @@ -10984,7 +11140,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 216, + "line": 191, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -11004,19 +11160,6 @@ } ] }, - { - "name": "scheduleSave", - "args": [], - "optional": false, - "returnType": "void", - "typeParameters": [], - "line": 156, - "deprecated": false, - "deprecationMessage": "", - "modifierKind": [ - 123 - ] - }, { "name": "selectSubOrg", "args": [ @@ -11032,7 +11175,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 138, + "line": 127, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -11057,14 +11200,14 @@ "deprecationMessage": "", "description": "

Root singleton for the admin org-template editor (WP-26). The Elm machine owns the\neditable draft; commands here do the debounced save, publish (impact-confirm),\nrollback and proefbrief, then dispatch the outcome — the reducer stays pure. The\nlogo upload reuses the shared upload transport; its completion mutates the draft\n(in the reducer) and triggers a save (here). Mirrors BriefStore.

\n", "rawdescription": "\n\nRoot singleton for the admin org-template editor (WP-26). The Elm machine owns the\neditable draft; commands here do the debounced save, publish (impact-confirm),\nrollback and proefbrief, then dispatch the outcome — the reducer stays pure. The\nlogo upload reuses the shared upload transport; its completion mutates the draft\n(in the reducer) and triggers a save (here). Mirrors `BriefStore`.\n", - "sourceCode": "import { Injectable, computed, effect, inject, signal } from '@angular/core';\nimport { RemoteData } from '@shared/application/remote-data';\nimport { createStore } from '@shared/application/store';\nimport { UploadAdapter } from '@shared/upload/upload.adapter';\nimport { UploadShellService } from '@shared/upload/upload-shell.service';\nimport { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';\nimport {\n MARGIN_MAX_MM,\n MARGIN_MIN_MM,\n OrgTemplate,\n SubOrgSummary,\n} from '@brief/domain/org-template';\nimport {\n OrgTemplateMsg,\n OrgTemplateState,\n initial,\n reduce,\n} from '@brief/domain/org-template.machine';\nimport { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';\nimport { PendingSave, registerPendingSave } from '@shared/application/pending-saves';\n\n/** Transient action state for publish/rollback/proefbrief — the BriefStore idiom. */\ntype ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };\ntype SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };\ntype LoadedState = Extract;\n\nconst LOGO_CATEGORY = 'org-logo';\nconst NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;\n\n/**\n * Root singleton for the admin org-template editor (WP-26). The Elm machine owns the\n * editable draft; commands here do the debounced save, publish (impact-confirm),\n * rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The\n * logo upload reuses the shared upload transport; its completion mutates the draft\n * (in the reducer) and triggers a save (here). Mirrors `BriefStore`.\n */\n@Injectable({ providedIn: 'root' })\nexport class OrgTemplateStore implements PendingSave {\n private adapter = inject(OrgTemplateAdapter);\n private uploadAdapter = inject(UploadAdapter);\n private shell = inject(UploadShellService);\n private store = createStore(initial, reduce);\n\n readonly model = this.store.model;\n\n readonly subOrgs = signal([]);\n readonly selectedSubOrgId = signal(null);\n\n private actionState = signal({ tag: 'Idle' });\n readonly busy = computed(() => this.actionState().tag === 'Busy');\n readonly lastError = computed(() => {\n const s = this.actionState();\n return s.tag === 'Failed' ? s.error : null;\n });\n readonly saveState = signal({ tag: 'Idle' });\n\n /** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */\n readonly pendingPublish = signal(false);\n\n readonly remoteData = computed>(() => {\n const s = this.model();\n switch (s.tag) {\n case 'loading':\n return { tag: 'Loading' };\n case 'failed':\n return { tag: 'Failure', error: new Error(s.reason) };\n case 'loaded':\n return { tag: 'Success', value: s };\n }\n });\n\n private loaded = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s : null;\n });\n readonly draft = computed(() => this.loaded()?.draft ?? null);\n readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);\n readonly history = computed(() => this.loaded()?.history ?? []);\n readonly publishedVersion = computed(() => this.loaded()?.publishedVersion ?? 0);\n readonly unsentBriefs = computed(() => this.loaded()?.unsentBriefs ?? 0);\n readonly logoUrl = computed(() => {\n const id = this.draft()?.logoDocumentId;\n return id ? this.uploadAdapter.contentUrl(id) : null;\n });\n\n /** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;\n the server re-validates and stays the authority — publish is gated on this. */\n readonly draftValid = computed(() => {\n const d = this.draft();\n if (!d) return false;\n const marginsOk = [\n d.margins.topMm,\n d.margins.rightMm,\n d.margins.bottomMm,\n d.margins.leftMm,\n ].every((v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM);\n return d.orgName.trim().length > 0 && d.signatureName.trim().length > 0 && marginsOk;\n });\n\n // Live File blobs keyed by localId — needed to retry a failed upload (a reducer can't hold these).\n private files = new Map();\n private categoriesRes = this.uploadAdapter.categoriesResource('org-template');\n\n constructor() {\n // Feed the logo category into the machine's upload sub-state once loaded. Tracks\n // `model()` so it re-fires after a sub-org switch reseeds an empty upload state;\n // the length guard makes it idempotent (no dispatch loop).\n effect(() => {\n const s = this.model();\n if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;\n const status = this.categoriesRes.status();\n if (status === 'resolved' || status === 'local')\n this.dispatchUpload({\n type: 'CategoriesLoaded',\n categories: this.categoriesRes.value() ?? [],\n });\n });\n // Flush a pending debounced edit before navigation/unload (see pending-saves.ts).\n registerPendingSave(this);\n }\n\n async load() {\n this.store.dispatch({ tag: 'Loading' });\n const list = await this.adapter.list();\n if (!list.ok) {\n this.store.dispatch({ tag: 'LoadFailed', reason: list.error });\n return;\n }\n this.subOrgs.set(list.value);\n const first = list.value[0];\n if (!first) {\n this.store.dispatch({ tag: 'LoadFailed', reason: NO_SUBORGS });\n return;\n }\n await this.selectSubOrg(first.subOrgId);\n }\n\n async selectSubOrg(subOrgId: string) {\n this.selectedSubOrgId.set(subOrgId);\n this.saveState.set({ tag: 'Idle' });\n clearTimeout(this.saveTimer);\n this.saveTimer = undefined;\n this.store.dispatch({ tag: 'Loading' });\n const r = await this.adapter.load(subOrgId);\n if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value });\n else this.store.dispatch({ tag: 'LoadFailed', reason: r.error });\n }\n\n /** An in-place canvas or margin edit: apply optimistically, then debounce-save. */\n edit(msg: OrgTemplateMsg) {\n this.store.dispatch(msg);\n this.scheduleSave();\n }\n\n private saveTimer?: ReturnType;\n private scheduleSave() {\n if (this.loaded() === null) return;\n clearTimeout(this.saveTimer);\n // ponytail: 600ms debounce, same as BriefStore; the server is the store of record.\n // Null the handle when it fires so `hasPendingSave()` reflects \"a write is still owed\".\n this.saveTimer = setTimeout(() => {\n this.saveTimer = undefined;\n void this.flushSave();\n }, 600);\n }\n\n /** True while a debounced edit hasn't been written yet (PendingSave). */\n hasPendingSave = () => this.saveTimer !== undefined;\n /** Flush a pending debounced save now and await it; no-op when nothing is pending. */\n async flushPending() {\n if (this.saveTimer === undefined) return;\n clearTimeout(this.saveTimer);\n this.saveTimer = undefined;\n await this.flushSave();\n }\n private async flushSave() {\n const s = this.loaded();\n if (!s || !s.dirty) return;\n const { subOrgId, draft } = s;\n this.saveState.set({ tag: 'Saving' });\n const r = await this.adapter.save(subOrgId, draft);\n if (r.ok) {\n this.saveState.set({ tag: 'Saved' });\n this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });\n } else {\n this.saveState.set({ tag: 'Error' });\n this.actionState.set({ tag: 'Failed', error: r.error });\n }\n }\n\n // --- publish (impact-confirm) / rollback / proefbrief ---\n\n requestPublish() {\n this.pendingPublish.set(true);\n }\n cancelPublish() {\n this.pendingPublish.set(false);\n }\n async confirmPublish() {\n const s = this.loaded();\n if (!s) return;\n this.pendingPublish.set(false);\n this.actionState.set({ tag: 'Busy' });\n clearTimeout(this.saveTimer);\n this.saveTimer = undefined;\n await this.flushSave(); // publish the saved draft — flush any pending edit first\n const r = await this.adapter.publish(s.subOrgId);\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0\n }\n\n async rollback(version: number) {\n const s = this.loaded();\n if (!s) return;\n this.actionState.set({ tag: 'Busy' });\n clearTimeout(this.saveTimer);\n this.saveTimer = undefined;\n const r = await this.adapter.rollback(s.subOrgId, version);\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft\n }\n\n async proefbrief() {\n const s = this.loaded();\n if (!s) return;\n this.actionState.set({ tag: 'Busy' });\n clearTimeout(this.saveTimer);\n this.saveTimer = undefined;\n await this.flushSave(); // the proefbrief renders the server's draft\n const r = await this.adapter.proefbrief(s.subOrgId);\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n window.open(URL.createObjectURL(r.value), '_blank');\n }\n\n // --- logo upload (reuses the shared upload transport; single `org-logo` file) ---\n\n onLogoSelected(files: File[]) {\n const s = this.loaded();\n const cat = s?.upload.categories.find((c) => c.categoryId === LOGO_CATEGORY);\n const file = files[0];\n if (!s || !cat || !file) return;\n const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 });\n if (reason) {\n this.dispatchUpload({ type: 'FileRejected', categoryId: cat.categoryId, reason });\n return;\n }\n const localId = crypto.randomUUID();\n this.files.set(localId, file);\n this.dispatchUpload({\n type: 'FileSelected',\n categoryId: cat.categoryId,\n localId,\n fileName: file.name,\n fileSizeMb: file.size / 1e6,\n });\n this.shell.upload(\n { localId, categoryId: cat.categoryId, wizardId: 'org-template', file },\n (m) => this.onUploadMsg(m),\n );\n }\n\n onLogoRemoved(localId: string) {\n this.shell.cancel([localId]);\n this.files.delete(localId);\n this.onUploadMsg({ type: 'UploadRemoved', localId });\n }\n\n onLogoRetry(localId: string) {\n const file = this.files.get(localId);\n const up = this.loaded()?.upload.uploads.find((u) => u.localId === localId);\n if (!file || !up) return;\n this.dispatchUpload({ type: 'UploadRetried', localId });\n this.shell.upload({ localId, categoryId: up.categoryId, wizardId: 'org-template', file }, (m) =>\n this.onUploadMsg(m),\n );\n }\n\n private dispatchUpload(msg: UploadMsg) {\n this.store.dispatch({ tag: 'Upload', msg });\n }\n /** Upload effects arriving from the transport: a finished/removed logo edits the\n draft (in the reducer) and needs persisting. */\n private onUploadMsg(msg: UploadMsg) {\n this.dispatchUpload(msg);\n if (msg.type === 'UploadComplete' || msg.type === 'UploadRemoved') this.scheduleSave();\n }\n}\n", + "sourceCode": "import { Injectable, computed, effect, inject, signal } from '@angular/core';\nimport { createStore } from '@shared/application/store';\nimport { ActionState, SaveState } from '@shared/application/action-state';\nimport { createDebouncedSave } from '@shared/application/debounced-save';\nimport { machineRemoteData } from '@shared/application/machine-remote-data';\nimport { UploadAdapter } from '@shared/upload/upload.adapter';\nimport { UploadShellService } from '@shared/upload/upload-shell.service';\nimport { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';\nimport {\n MARGIN_MAX_MM,\n MARGIN_MIN_MM,\n OrgTemplate,\n SubOrgSummary,\n} from '@brief/domain/org-template';\nimport {\n OrgTemplateMsg,\n OrgTemplateState,\n initial,\n reduce,\n} from '@brief/domain/org-template.machine';\nimport { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';\nimport { PendingSave, registerPendingSave } from '@shared/application/pending-saves';\n\ntype LoadedState = Extract;\n\nconst LOGO_CATEGORY = 'org-logo';\nconst NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;\n\n/**\n * Root singleton for the admin org-template editor (WP-26). The Elm machine owns the\n * editable draft; commands here do the debounced save, publish (impact-confirm),\n * rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The\n * logo upload reuses the shared upload transport; its completion mutates the draft\n * (in the reducer) and triggers a save (here). Mirrors `BriefStore`.\n */\n@Injectable({ providedIn: 'root' })\nexport class OrgTemplateStore implements PendingSave {\n private adapter = inject(OrgTemplateAdapter);\n private uploadAdapter = inject(UploadAdapter);\n private shell = inject(UploadShellService);\n private store = createStore(initial, reduce);\n\n readonly model = this.store.model;\n\n readonly subOrgs = signal([]);\n readonly selectedSubOrgId = signal(null);\n\n private actionState = signal({ tag: 'Idle' });\n readonly busy = computed(() => this.actionState().tag === 'Busy');\n readonly lastError = computed(() => {\n const s = this.actionState();\n return s.tag === 'Failed' ? s.error : null;\n });\n readonly saveState = signal({ tag: 'Idle' });\n\n /** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */\n readonly pendingPublish = signal(false);\n\n readonly remoteData = computed(() => machineRemoteData(this.model()));\n\n private loaded = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s : null;\n });\n readonly draft = computed(() => this.loaded()?.draft ?? null);\n readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);\n readonly history = computed(() => this.loaded()?.history ?? []);\n readonly publishedVersion = computed(() => this.loaded()?.publishedVersion ?? 0);\n readonly unsentBriefs = computed(() => this.loaded()?.unsentBriefs ?? 0);\n readonly logoUrl = computed(() => {\n const id = this.draft()?.logoDocumentId;\n return id ? this.uploadAdapter.contentUrl(id) : null;\n });\n\n /** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;\n the server re-validates and stays the authority — publish is gated on this. */\n readonly draftValid = computed(() => {\n const d = this.draft();\n if (!d) return false;\n const marginsOk = [\n d.margins.topMm,\n d.margins.rightMm,\n d.margins.bottomMm,\n d.margins.leftMm,\n ].every((v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM);\n return d.orgName.trim().length > 0 && d.signatureName.trim().length > 0 && marginsOk;\n });\n\n // Live File blobs keyed by localId — needed to retry a failed upload (a reducer can't hold these).\n private files = new Map();\n private categoriesRes = this.uploadAdapter.categoriesResource('org-template');\n\n constructor() {\n // Feed the logo category into the machine's upload sub-state once loaded. Tracks\n // `model()` so it re-fires after a sub-org switch reseeds an empty upload state;\n // the length guard makes it idempotent (no dispatch loop).\n effect(() => {\n const s = this.model();\n if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;\n const status = this.categoriesRes.status();\n if (status === 'resolved' || status === 'local')\n this.dispatchUpload({\n type: 'CategoriesLoaded',\n categories: this.categoriesRes.value() ?? [],\n });\n });\n // Flush a pending debounced edit before navigation/unload (see pending-saves.ts).\n registerPendingSave(this);\n }\n\n async load() {\n this.store.dispatch({ tag: 'Loading' });\n const list = await this.adapter.list();\n if (!list.ok) {\n this.store.dispatch({ tag: 'LoadFailed', reason: list.error });\n return;\n }\n this.subOrgs.set(list.value);\n const first = list.value[0];\n if (!first) {\n this.store.dispatch({ tag: 'LoadFailed', reason: NO_SUBORGS });\n return;\n }\n await this.selectSubOrg(first.subOrgId);\n }\n\n async selectSubOrg(subOrgId: string) {\n this.selectedSubOrgId.set(subOrgId);\n this.saveState.set({ tag: 'Idle' });\n this.debouncedSave.cancel();\n this.store.dispatch({ tag: 'Loading' });\n const r = await this.adapter.load(subOrgId);\n if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value });\n else this.store.dispatch({ tag: 'LoadFailed', reason: r.error });\n }\n\n /** An in-place canvas or margin edit: apply optimistically, then debounce-save. */\n edit(msg: OrgTemplateMsg) {\n this.store.dispatch(msg);\n this.debouncedSave.schedule();\n }\n\n // 600ms debounced autosave (same idiom as BriefStore, WP-31). Timer mechanics live in the\n // shared helper; `flushSave` below is the store-specific write + save-state.\n private debouncedSave = createDebouncedSave({\n canSave: () => this.loaded() !== null,\n flush: () => this.flushSave(),\n });\n /** PendingSave: delegate to the debounce helper so the guard/unload can flush. */\n hasPendingSave = () => this.debouncedSave.hasPendingSave();\n flushPending = () => this.debouncedSave.flushPending();\n private async flushSave() {\n const s = this.loaded();\n if (!s || !s.dirty) return;\n const { subOrgId, draft } = s;\n this.saveState.set({ tag: 'Saving' });\n const r = await this.adapter.save(subOrgId, draft);\n if (r.ok) {\n this.saveState.set({ tag: 'Saved' });\n this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });\n } else {\n this.saveState.set({ tag: 'Error' });\n this.actionState.set({ tag: 'Failed', error: r.error });\n }\n }\n\n // --- publish (impact-confirm) / rollback / proefbrief ---\n\n requestPublish() {\n this.pendingPublish.set(true);\n }\n cancelPublish() {\n this.pendingPublish.set(false);\n }\n async confirmPublish() {\n const s = this.loaded();\n if (!s) return;\n this.pendingPublish.set(false);\n this.actionState.set({ tag: 'Busy' });\n this.debouncedSave.cancel();\n await this.flushSave(); // publish the saved draft — flush any pending edit first\n const r = await this.adapter.publish(s.subOrgId);\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0\n }\n\n async rollback(version: number) {\n const s = this.loaded();\n if (!s) return;\n this.actionState.set({ tag: 'Busy' });\n this.debouncedSave.cancel();\n const r = await this.adapter.rollback(s.subOrgId, version);\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft\n }\n\n async proefbrief() {\n const s = this.loaded();\n if (!s) return;\n this.actionState.set({ tag: 'Busy' });\n this.debouncedSave.cancel();\n await this.flushSave(); // the proefbrief renders the server's draft\n const r = await this.adapter.proefbrief(s.subOrgId);\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n window.open(URL.createObjectURL(r.value), '_blank');\n }\n\n // --- logo upload (reuses the shared upload transport; single `org-logo` file) ---\n\n onLogoSelected(files: File[]) {\n const s = this.loaded();\n const cat = s?.upload.categories.find((c) => c.categoryId === LOGO_CATEGORY);\n const file = files[0];\n if (!s || !cat || !file) return;\n const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 });\n if (reason) {\n this.dispatchUpload({ type: 'FileRejected', categoryId: cat.categoryId, reason });\n return;\n }\n const localId = crypto.randomUUID();\n this.files.set(localId, file);\n this.dispatchUpload({\n type: 'FileSelected',\n categoryId: cat.categoryId,\n localId,\n fileName: file.name,\n fileSizeMb: file.size / 1e6,\n });\n this.shell.upload(\n { localId, categoryId: cat.categoryId, wizardId: 'org-template', file },\n (m) => this.onUploadMsg(m),\n );\n }\n\n onLogoRemoved(localId: string) {\n this.shell.cancel([localId]);\n this.files.delete(localId);\n this.onUploadMsg({ type: 'UploadRemoved', localId });\n }\n\n onLogoRetry(localId: string) {\n const file = this.files.get(localId);\n const up = this.loaded()?.upload.uploads.find((u) => u.localId === localId);\n if (!file || !up) return;\n this.dispatchUpload({ type: 'UploadRetried', localId });\n this.shell.upload({ localId, categoryId: up.categoryId, wizardId: 'org-template', file }, (m) =>\n this.onUploadMsg(m),\n );\n }\n\n private dispatchUpload(msg: UploadMsg) {\n this.store.dispatch({ tag: 'Upload', msg });\n }\n /** Upload effects arriving from the transport: a finished/removed logo edits the\n draft (in the reducer) and needs persisting. */\n private onUploadMsg(msg: UploadMsg) {\n this.dispatchUpload(msg);\n if (msg.type === 'UploadComplete' || msg.type === 'UploadRemoved')\n this.debouncedSave.schedule();\n }\n}\n", "constructorObj": { "name": "constructor", "description": "", "deprecated": false, "deprecationMessage": "", "args": [], - "line": 102 + "line": 91 }, "extends": [], "type": "injectable" @@ -11546,7 +11689,7 @@ }, { "name": "StamdataStore", - "id": "injectable-StamdataStore-325cfe5ab2e820240dc4726addc0cc0fbd3300d6e42b182c15db37cf748902d4e28e7de438a3eb7ec900bb46383ac2cec9a62c84f9e317a1e7c09c0a7c4a9bea", + "id": "injectable-StamdataStore-22eea31809c18e7a9d47baa7f6caea6ae6dcc2510a2fa17d4da3093bab77bde5103ad13bb1cdb87387ad449a96bac3b71beff84a9e24f4dd86003c5ee0d7ba9d", "file": "src/app/beheer/application/stamdata.store.ts", "properties": [ { @@ -11558,7 +11701,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 31, + "line": 32, "modifierKind": [ 123 ] @@ -11572,12 +11715,40 @@ "indexKey": "", "optional": false, "description": "

Download is blocked while previewing (the filtered view is not the full file) or while\nany row has a format error (the CI gate would reject it anyway — fail fast here).

\n", - "line": 74, + "line": 65, "rawdescription": "\nDownload is blocked while previewing (the filtered view is not the full file) or while\nany row has a format error (the CI gate would reject it anyway — fail fast here).", "modifierKind": [ 148 ] }, + { + "name": "canRedo", + "defaultValue": "this.history.canRedo", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 104, + "modifierKind": [ + 148 + ] + }, + { + "name": "canUndo", + "defaultValue": "this.history.canUndo", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 103, + "modifierKind": [ + 148 + ] + }, { "name": "counts", "defaultValue": "computed(() => {\n const s = this.loaded();\n return s ? changeCounts(s.table, s.original, s.rows) : { added: 0, removed: 0, edited: 0 };\n })", @@ -11587,7 +11758,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 64, + "line": 55, "modifierKind": [ 148 ] @@ -11601,7 +11772,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 68, + "line": 59, "modifierKind": [ 148 ] @@ -11615,11 +11786,26 @@ "indexKey": "", "optional": false, "description": "", - "line": 60, + "line": 51, "modifierKind": [ 148 ] }, + { + "name": "history", + "defaultValue": "createHistory(50)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "

Undo/redo over the edited rows (WP-32): the document snapshot is rows; restore via\nthe existing Seed msg. Only real edits are recorded (a no-op reduce leaves no step).

\n", + "line": 102, + "rawdescription": "\nUndo/redo over the edited rows (WP-32): the document snapshot is `rows`; restore via\nthe existing `Seed` msg. Only real edits are recorded (a no-op reduce leaves no step).", + "modifierKind": [ + 123 + ] + }, { "name": "loaded", "defaultValue": "computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s : null;\n })", @@ -11629,7 +11815,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 54, + "line": 45, "modifierKind": [ 123 ] @@ -11643,7 +11829,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 34, + "line": 35, "modifierKind": [ 148 ] @@ -11657,7 +11843,7 @@ "indexKey": "", "optional": false, "description": "

Preview: show only rows valid on this date ('' = show all, editable). A local filter,\nso toggling it never round-trips or drops unsaved edits (see domain activeOn).

\n", - "line": 40, + "line": 41, "rawdescription": "\nPreview: show only rows valid on this date ('' = show all, editable). A local filter,\nso toggling it never round-trips or drops unsaved edits (see domain `activeOn`).", "modifierKind": [ 148 @@ -11665,14 +11851,14 @@ }, { "name": "remoteData", - "defaultValue": "computed>(() => {\n const s = this.model();\n switch (s.tag) {\n case 'loading':\n return { tag: 'Loading' };\n case 'failed':\n return { tag: 'Failure', error: new Error(s.reason) };\n case 'loaded':\n return { tag: 'Success', value: s };\n }\n })", + "defaultValue": "computed(() => machineRemoteData(this.model()))", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "", - "line": 42, + "line": 43, "modifierKind": [ 148 ] @@ -11686,7 +11872,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 59, + "line": 50, "modifierKind": [ 148 ] @@ -11700,7 +11886,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 36, + "line": 37, "modifierKind": [ 148 ] @@ -11714,7 +11900,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 32, + "line": 33, "modifierKind": [ 123 ] @@ -11728,7 +11914,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 58, + "line": 49, "modifierKind": [ 148 ] @@ -11742,7 +11928,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 35, + "line": 36, "modifierKind": [ 148 ] @@ -11755,7 +11941,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 111, + "line": 114, "deprecated": false, "deprecationMessage": "" }, @@ -11765,7 +11951,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 119, + "line": 137, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nEmit the edited data-file for the admin to drop into the repo (see domain `toJson`).", @@ -11802,7 +11988,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 108, + "line": 111, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -11847,13 +12033,58 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 79, + "line": 70, "deprecated": false, "deprecationMessage": "", "modifierKind": [ 134 ] }, + { + "name": "recordThenDispatch", + "args": [ + { + "name": "msg", + "type": "StamdataEditorMsg", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 106, + "deprecated": false, + "deprecationMessage": "", + "modifierKind": [ + 123 + ], + "jsdoctags": [ + { + "name": "msg", + "type": "StamdataEditorMsg", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "redo", + "args": [], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 124, + "deprecated": false, + "deprecationMessage": "" + }, { "name": "removeRow", "args": [ @@ -11869,7 +12100,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 114, + "line": 117, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -11886,6 +12117,61 @@ } ] }, + { + "name": "restore", + "args": [ + { + "name": "step", + "type": "function", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "function": [ + { + "name": "current", + "type": "unknown", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ] + } + ], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 127, + "deprecated": false, + "deprecationMessage": "", + "modifierKind": [ + 123 + ], + "jsdoctags": [ + { + "name": "step", + "type": "function", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "function": [ + { + "name": "current", + "type": "unknown", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ], + "tagName": { + "text": "param" + } + } + ] + }, { "name": "selectTable", "args": [ @@ -11901,7 +12187,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 95, + "line": 86, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -11936,7 +12222,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 104, + "line": 96, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -11952,13 +12238,23 @@ } } ] + }, + { + "name": "undo", + "args": [], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 121, + "deprecated": false, + "deprecationMessage": "" } ], "deprecated": false, "deprecationMessage": "", "description": "

Root singleton for the stamdata maintenance editor (ADR-0004). The Elm machine owns the\ndraft rows; commands here load the catalog + a selected table and produce the download.\nThere is deliberately NO save command — the reducer stays pure and the edit leaves as a\ndownloaded JSON file that the admin drops into the repo (the CI build is the authority).

\n", "rawdescription": "\n\nRoot singleton for the stamdata maintenance editor (ADR-0004). The Elm machine owns the\ndraft rows; commands here load the catalog + a selected table and produce the download.\nThere is deliberately NO save command — the reducer stays pure and the edit leaves as a\ndownloaded JSON file that the admin drops into the repo (the CI build is the authority).\n", - "sourceCode": "import { Injectable, computed, inject, signal } from '@angular/core';\nimport { RemoteData } from '@shared/application/remote-data';\nimport { createStore } from '@shared/application/store';\nimport {\n ChangeCounts,\n StamRow,\n StamTable,\n changeCounts,\n isValid,\n rowErrors,\n toJson,\n} from '@beheer/domain/stamdata';\nimport {\n StamdataEditorMsg,\n StamdataEditorState,\n initial,\n reduce,\n} from '@beheer/domain/stamdata-editor.machine';\nimport { StamdataAdapter } from '@beheer/infrastructure/stamdata.adapter';\n\ntype LoadedState = Extract;\n\n/**\n * Root singleton for the stamdata maintenance editor (ADR-0004). The Elm machine owns the\n * draft rows; commands here load the catalog + a selected table and produce the download.\n * There is deliberately NO save command — the reducer stays pure and the edit leaves as a\n * downloaded JSON file that the admin drops into the repo (the CI build is the authority).\n */\n@Injectable({ providedIn: 'root' })\nexport class StamdataStore {\n private adapter = inject(StamdataAdapter);\n private store = createStore(initial, reduce);\n\n readonly model = this.store.model;\n readonly tables = signal([]);\n readonly selectedTableId = signal(null);\n\n /** Preview: show only rows valid on this date ('' = show all, editable). A local filter,\n so toggling it never round-trips or drops unsaved edits (see domain `activeOn`). */\n readonly previewDate = signal('');\n\n readonly remoteData = computed>(() => {\n const s = this.model();\n switch (s.tag) {\n case 'loading':\n return { tag: 'Loading' };\n case 'failed':\n return { tag: 'Failure', error: new Error(s.reason) };\n case 'loaded':\n return { tag: 'Success', value: s };\n }\n });\n\n private loaded = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s : null;\n });\n readonly table = computed(() => this.loaded()?.table ?? null);\n readonly rows = computed(() => this.loaded()?.rows ?? []);\n readonly errors = computed(() => {\n const s = this.loaded();\n return s ? rowErrors(s.table, s.rows) : [];\n });\n readonly counts = computed(() => {\n const s = this.loaded();\n return s ? changeCounts(s.table, s.original, s.rows) : { added: 0, removed: 0, edited: 0 };\n });\n readonly dirty = computed(() => {\n const c = this.counts();\n return c.added + c.removed + c.edited > 0;\n });\n /** Download is blocked while previewing (the filtered view is not the full file) or while\n any row has a format error (the CI gate would reject it anyway — fail fast here). */\n readonly canDownload = computed(() => {\n const s = this.loaded();\n return this.previewDate() === '' && this.dirty() && s !== null && isValid(s.table, s.rows);\n });\n\n async load() {\n this.store.dispatch({ tag: 'Loading' });\n const list = await this.adapter.list();\n if (!list.ok) {\n this.store.dispatch({ tag: 'LoadFailed', reason: list.error });\n return;\n }\n this.tables.set(list.value);\n const first = list.value[0];\n if (!first) {\n this.store.dispatch({ tag: 'LoadFailed', reason: NO_TABLES });\n return;\n }\n await this.selectTable(first.id);\n }\n\n async selectTable(tableId: string) {\n this.selectedTableId.set(tableId);\n this.previewDate.set('');\n this.store.dispatch({ tag: 'Loading' });\n const r = await this.adapter.load(tableId);\n if (r.ok) this.store.dispatch({ tag: 'Loaded', table: r.value.table, rows: r.value.rows });\n else this.store.dispatch({ tag: 'LoadFailed', reason: r.error });\n }\n\n setPreviewDate(date: string) {\n this.previewDate.set(date);\n }\n\n editCell(row: number, column: string, value: string) {\n this.store.dispatch({ tag: 'CellEdited', row, column, value });\n }\n addRow() {\n this.store.dispatch({ tag: 'RowAdded' });\n }\n removeRow(row: number) {\n this.store.dispatch({ tag: 'RowRemoved', row });\n }\n\n /** Emit the edited data-file for the admin to drop into the repo (see domain `toJson`). */\n download() {\n const s = this.loaded();\n if (!s || !this.canDownload()) return;\n const blob = new Blob([toJson(s.table, s.rows)], { type: 'application/json' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = `${s.table.id}.json`;\n a.click();\n URL.revokeObjectURL(url);\n }\n}\n\nconst NO_TABLES = $localize`:@@beheer.noTables:Er is geen stamdata om te beheren.`;\n", + "sourceCode": "import { Injectable, computed, inject, signal } from '@angular/core';\nimport { createStore } from '@shared/application/store';\nimport { machineRemoteData } from '@shared/application/machine-remote-data';\nimport { createHistory } from '@shared/application/history';\nimport {\n ChangeCounts,\n StamRow,\n StamTable,\n changeCounts,\n isValid,\n rowErrors,\n toJson,\n} from '@beheer/domain/stamdata';\nimport {\n StamdataEditorMsg,\n StamdataEditorState,\n initial,\n reduce,\n} from '@beheer/domain/stamdata-editor.machine';\nimport { StamdataAdapter } from '@beheer/infrastructure/stamdata.adapter';\n\ntype LoadedState = Extract;\n\n/**\n * Root singleton for the stamdata maintenance editor (ADR-0004). The Elm machine owns the\n * draft rows; commands here load the catalog + a selected table and produce the download.\n * There is deliberately NO save command — the reducer stays pure and the edit leaves as a\n * downloaded JSON file that the admin drops into the repo (the CI build is the authority).\n */\n@Injectable({ providedIn: 'root' })\nexport class StamdataStore {\n private adapter = inject(StamdataAdapter);\n private store = createStore(initial, reduce);\n\n readonly model = this.store.model;\n readonly tables = signal([]);\n readonly selectedTableId = signal(null);\n\n /** Preview: show only rows valid on this date ('' = show all, editable). A local filter,\n so toggling it never round-trips or drops unsaved edits (see domain `activeOn`). */\n readonly previewDate = signal('');\n\n readonly remoteData = computed(() => machineRemoteData(this.model()));\n\n private loaded = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s : null;\n });\n readonly table = computed(() => this.loaded()?.table ?? null);\n readonly rows = computed(() => this.loaded()?.rows ?? []);\n readonly errors = computed(() => {\n const s = this.loaded();\n return s ? rowErrors(s.table, s.rows) : [];\n });\n readonly counts = computed(() => {\n const s = this.loaded();\n return s ? changeCounts(s.table, s.original, s.rows) : { added: 0, removed: 0, edited: 0 };\n });\n readonly dirty = computed(() => {\n const c = this.counts();\n return c.added + c.removed + c.edited > 0;\n });\n /** Download is blocked while previewing (the filtered view is not the full file) or while\n any row has a format error (the CI gate would reject it anyway — fail fast here). */\n readonly canDownload = computed(() => {\n const s = this.loaded();\n return this.previewDate() === '' && this.dirty() && s !== null && isValid(s.table, s.rows);\n });\n\n async load() {\n this.store.dispatch({ tag: 'Loading' });\n const list = await this.adapter.list();\n if (!list.ok) {\n this.store.dispatch({ tag: 'LoadFailed', reason: list.error });\n return;\n }\n this.tables.set(list.value);\n const first = list.value[0];\n if (!first) {\n this.store.dispatch({ tag: 'LoadFailed', reason: NO_TABLES });\n return;\n }\n await this.selectTable(first.id);\n }\n\n async selectTable(tableId: string) {\n this.selectedTableId.set(tableId);\n this.previewDate.set('');\n this.history.clear(); // undo history is per-table, not across tables\n this.store.dispatch({ tag: 'Loading' });\n const r = await this.adapter.load(tableId);\n if (r.ok) this.store.dispatch({ tag: 'Loaded', table: r.value.table, rows: r.value.rows });\n else this.store.dispatch({ tag: 'LoadFailed', reason: r.error });\n }\n\n setPreviewDate(date: string) {\n this.previewDate.set(date);\n }\n\n /** Undo/redo over the edited rows (WP-32): the document snapshot is `rows`; restore via\n the existing `Seed` msg. Only real edits are recorded (a no-op reduce leaves no step). */\n private history = createHistory(50);\n readonly canUndo = this.history.canUndo;\n readonly canRedo = this.history.canRedo;\n\n private recordThenDispatch(msg: StamdataEditorMsg) {\n const before = this.rows();\n this.store.dispatch(msg);\n if (this.loaded() && this.rows() !== before) this.history.record(before);\n }\n editCell(row: number, column: string, value: string) {\n this.recordThenDispatch({ tag: 'CellEdited', row, column, value });\n }\n addRow() {\n this.recordThenDispatch({ tag: 'RowAdded' });\n }\n removeRow(row: number) {\n this.recordThenDispatch({ tag: 'RowRemoved', row });\n }\n\n undo() {\n this.restore((rows) => this.history.undo(rows));\n }\n redo() {\n this.restore((rows) => this.history.redo(rows));\n }\n private restore(step: (current: readonly StamRow[]) => readonly StamRow[] | undefined) {\n const s = this.loaded();\n if (!s) return;\n const target = step(s.rows);\n if (target === undefined) return;\n // copy readonly history snapshot into the machine's mutable rows shape\n this.store.dispatch({ tag: 'Seed', state: { ...s, rows: [...target] } });\n }\n\n /** Emit the edited data-file for the admin to drop into the repo (see domain `toJson`). */\n download() {\n const s = this.loaded();\n if (!s || !this.canDownload()) return;\n const blob = new Blob([toJson(s.table, s.rows)], { type: 'application/json' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = `${s.table.id}.json`;\n a.click();\n URL.revokeObjectURL(url);\n }\n}\n\nconst NO_TABLES = $localize`:@@beheer.noTables:Er is geen stamdata om te beheren.`;\n", "extends": [], "type": "injectable" }, @@ -28605,17 +28901,18 @@ }, { "name": "StamdataPage", - "id": "component-StamdataPage-ef18c1f6869c9b49396c1bbae1c9f3f625883185b71d8f9841b24a1c9b1e207f2ef527ba8478b9df36fc122ae87dc2b18fdbc6e065b455ea23b12e072f8a170c", + "id": "component-StamdataPage-e6ae1be3d44e4388c1424b08f5f2a4c6acc60a6347b8e7f7f533e0a904280978b1f55a60b3db6f6bb5787f47c872563089d88c0124964fdeafd9a99fa76a8ed3", "file": "src/app/beheer/ui/stamdata.page.ts", "encapsulation": [], "entryComponents": [], + "host": {}, "inputs": [], "outputs": [], "providers": [], "selector": "app-stamdata-page", "styleUrls": [], "styles": [], - "template": "\n @if (!access.ready()) {\n \n } @else if (!canEdit()) {\n {{ deniedText }}\n } @else {\n \n \n {{ failedText }}\n {{ retryText }}\n \n \n @if (store.table(); as table) {\n \n }\n \n \n }\n\n", + "template": "\n @if (!access.ready()) {\n \n } @else if (!canEdit()) {\n {{ deniedText }}\n } @else {\n \n \n {{ failedText }}\n {{ retryText }}\n \n \n @if (store.table(); as table) {\n \n }\n \n \n }\n\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -28631,7 +28928,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 63, + "line": 68, "modifierKind": [ 124 ] @@ -28645,7 +28942,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 65, + "line": 70, "modifierKind": [ 124 ] @@ -28659,7 +28956,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 69, + "line": 74, "modifierKind": [ 124 ] @@ -28673,7 +28970,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 70, + "line": 75, "modifierKind": [ 124 ] @@ -28687,7 +28984,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 67, + "line": 72, "modifierKind": [ 124 ] @@ -28701,7 +28998,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 68, + "line": 73, "modifierKind": [ 124 ] @@ -28715,7 +29012,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 73, + "line": 78, "modifierKind": [ 123 ] @@ -28729,7 +29026,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 71, + "line": 76, "modifierKind": [ 124 ] @@ -28743,20 +29040,57 @@ "indexKey": "", "optional": false, "description": "", - "line": 62, + "line": 67, "modifierKind": [ 124 ] } ], "methodsClass": [ + { + "name": "onKeydown", + "args": [ + { + "name": "e", + "type": "KeyboardEvent", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 97, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\nCtrl/Cmd+Z undo, Ctrl/Cmd+Shift+Z redo (WP-32). Ignored while focus is in a grid\ncell input so the browser's native text-undo still works there (mirrors brief.page).", + "description": "

Ctrl/Cmd+Z undo, Ctrl/Cmd+Shift+Z redo (WP-32). Ignored while focus is in a grid\ncell input so the browser's native text-undo still works there (mirrors brief.page).

\n", + "modifierKind": [ + 124 + ], + "jsdoctags": [ + { + "name": "e", + "type": "KeyboardEvent", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "reload", "args": [], "optional": false, "returnType": "void", "typeParameters": [], - "line": 86, + "line": 91, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -28793,7 +29127,7 @@ "description": "

Page: thin container for the stamdata maintenance editor (ADR-0004). Deny-by-default\ncapability gate (stamdata:edit) — a denial alert for non-admins, the generic grid for\nadmins. Loads once the capability resolves; wires store commands to the organism.

\n", "rawdescription": "\n\nPage: thin container for the stamdata maintenance editor (ADR-0004). Deny-by-default\ncapability gate (`stamdata:edit`) — a denial alert for non-admins, the generic grid for\nadmins. Loads once the capability resolves; wires store commands to the organism.\n", "type": "component", - "sourceCode": "import { Component, computed, effect, inject } from '@angular/core';\nimport { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { ASYNC } from '@shared/ui/async/async.component';\nimport { AccessStore } from '@shared/application/access.store';\nimport { StamdataStore } from '@beheer/application/stamdata.store';\nimport { StamdataTableEditorComponent } from '@beheer/ui/stamdata-table-editor/stamdata-table-editor.component';\n\n/**\n * Page: thin container for the stamdata maintenance editor (ADR-0004). Deny-by-default\n * capability gate (`stamdata:edit`) — a denial alert for non-admins, the generic grid for\n * admins. Loads once the capability resolves; wires store commands to the organism.\n */\n@Component({\n selector: 'app-stamdata-page',\n imports: [\n PageShellComponent,\n AlertComponent,\n ButtonComponent,\n ...ASYNC,\n StamdataTableEditorComponent,\n ],\n template: `\n \n @if (!access.ready()) {\n \n } @else if (!canEdit()) {\n {{ deniedText }}\n } @else {\n \n \n {{ failedText }}\n {{ retryText }}\n \n \n @if (store.table(); as table) {\n \n }\n \n \n }\n \n `,\n})\nexport class StamdataPage {\n protected store = inject(StamdataStore);\n protected access = inject(AccessStore);\n\n protected canEdit = computed(() => this.access.can('stamdata:edit'));\n\n protected heading = $localize`:@@beheer.page.heading:Stamdata onderhouden`;\n protected intro = $localize`:@@beheer.page.intro:Beheer de business-tabellen die de registratie stuurt. Wijzigingen worden als JSON gedownload en via een pull request toegepast; de build blijft de bewaker.`;\n protected deniedText = $localize`:@@beheer.page.denied:U hebt geen rechten om stamdata te onderhouden.`;\n protected failedText = $localize`:@@beheer.page.failed:De stamdata kon niet worden geladen.`;\n protected retryText = $localize`:@@beheer.page.retry:Opnieuw proberen`;\n\n private loadRequested = false;\n constructor() {\n // Load once the capability resolves to `allowed` (a 403 GET would be wasted otherwise).\n // Depends only on canEdit() + a plain flag — never on the store model, so dispatching\n // `Loading` inside load() can't retrigger this effect (the WP-26 runaway-loop lesson).\n effect(() => {\n if (this.canEdit() && !this.loadRequested) {\n this.loadRequested = true;\n void this.store.load();\n }\n });\n }\n\n protected reload() {\n void this.store.load();\n }\n}\n", + "sourceCode": "import { Component, computed, effect, inject } from '@angular/core';\nimport { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { ASYNC } from '@shared/ui/async/async.component';\nimport { AccessStore } from '@shared/application/access.store';\nimport { StamdataStore } from '@beheer/application/stamdata.store';\nimport { StamdataTableEditorComponent } from '@beheer/ui/stamdata-table-editor/stamdata-table-editor.component';\n\n/**\n * Page: thin container for the stamdata maintenance editor (ADR-0004). Deny-by-default\n * capability gate (`stamdata:edit`) — a denial alert for non-admins, the generic grid for\n * admins. Loads once the capability resolves; wires store commands to the organism.\n */\n@Component({\n selector: 'app-stamdata-page',\n host: { '(document:keydown)': 'onKeydown($event)' },\n imports: [\n PageShellComponent,\n AlertComponent,\n ButtonComponent,\n ...ASYNC,\n StamdataTableEditorComponent,\n ],\n template: `\n \n @if (!access.ready()) {\n \n } @else if (!canEdit()) {\n {{ deniedText }}\n } @else {\n \n \n {{ failedText }}\n {{ retryText }}\n \n \n @if (store.table(); as table) {\n \n }\n \n \n }\n \n `,\n})\nexport class StamdataPage {\n protected store = inject(StamdataStore);\n protected access = inject(AccessStore);\n\n protected canEdit = computed(() => this.access.can('stamdata:edit'));\n\n protected heading = $localize`:@@beheer.page.heading:Stamdata onderhouden`;\n protected intro = $localize`:@@beheer.page.intro:Beheer de business-tabellen die de registratie stuurt. Wijzigingen worden als JSON gedownload en via een pull request toegepast; de build blijft de bewaker.`;\n protected deniedText = $localize`:@@beheer.page.denied:U hebt geen rechten om stamdata te onderhouden.`;\n protected failedText = $localize`:@@beheer.page.failed:De stamdata kon niet worden geladen.`;\n protected retryText = $localize`:@@beheer.page.retry:Opnieuw proberen`;\n\n private loadRequested = false;\n constructor() {\n // Load once the capability resolves to `allowed` (a 403 GET would be wasted otherwise).\n // Depends only on canEdit() + a plain flag — never on the store model, so dispatching\n // `Loading` inside load() can't retrigger this effect (the WP-26 runaway-loop lesson).\n effect(() => {\n if (this.canEdit() && !this.loadRequested) {\n this.loadRequested = true;\n void this.store.load();\n }\n });\n }\n\n protected reload() {\n void this.store.load();\n }\n\n /** Ctrl/Cmd+Z undo, Ctrl/Cmd+Shift+Z redo (WP-32). Ignored while focus is in a grid\n cell input so the browser's native text-undo still works there (mirrors brief.page). */\n protected onKeydown(e: KeyboardEvent) {\n if (!this.canEdit() || !(e.ctrlKey || e.metaKey) || (e.key !== 'z' && e.key !== 'Z')) return;\n const t = e.target as HTMLElement | null;\n if (t && (t.isContentEditable || ['INPUT', 'SELECT', 'TEXTAREA'].includes(t.tagName))) return;\n e.preventDefault();\n if (e.shiftKey) this.store.redo();\n else this.store.undo();\n }\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -28803,13 +29137,13 @@ "deprecated": false, "deprecationMessage": "", "args": [], - "line": 73 + "line": 78 }, "extends": [] }, { "name": "StamdataTableEditorComponent", - "id": "component-StamdataTableEditorComponent-6a5aeaa8ec97f6f8c54ca5436c496ec1b6c8d7b400fe73d1e25136c6d9fc54b04d78f7cd27c163fa17c2c3b256207fae7d90ac95cff23a5748a89b563fcbeaba", + "id": "component-StamdataTableEditorComponent-c2c497936813284d0fd2f39d0d4236ff08834dc50a5659329af0547d899fd8e83c8ad23eb828c270d7bf62eae8069561233b44599a5da9d2ebc9591687f8cc0f", "file": "src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts", "encapsulation": [], "entryComponents": [], @@ -28821,7 +29155,7 @@ "styles": [ "\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n align-items: end;\n margin-block-end: 1rem;\n }\n .field label {\n display: block;\n font-size: 0.85em;\n color: var(--rhc-color-foreground-subtle);\n }\n table {\n inline-size: 100%;\n }\n .err {\n color: var(--rhc-color-rood-500);\n font-size: 0.85em;\n }\n .footer {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n align-items: center;\n margin-block-start: 1rem;\n }\n .counts {\n color: var(--rhc-color-foreground-subtle);\n }\n .hint {\n margin-block-start: 0.5rem;\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n " ], - "template": "
\n @if (tables().length > 1) {\n
\n \n \n @for (t of tables(); track t.id) {\n \n }\n \n
\n }\n\n @if (table().temporal) {\n
\n \n \n
\n @if (previewing()) {\n {{\n showAll\n }}\n }\n }\n
\n\n@if (previewing()) {\n

{{ previewNote }}

\n}\n\n\n \n \n @for (col of table().columns; track col.name) {\n \n }\n \n \n \n \n @for (item of display(); track item.index) {\n \n @for (col of table().columns; track col.name) {\n \n }\n \n \n }\n \n
{{ col.name }}{{ previewing() ? '' : actionsLabel }}
\n @if (col.type === 'enum') {\n \n \n @for (opt of col.options; track opt) {\n \n }\n \n } @else {\n \n }\n \n @if (!previewing()) {\n {{ removeLabel }}\n }\n @if (errors()[item.index]) {\n {{ errors()[item.index] }}\n }\n
\n\n@if (!previewing()) {\n
\n {{ addRowLabel }}\n {{ countsLabel() }}\n {{\n downloadLabel\n }}\n
\n

{{ applyHint }}

\n}\n", + "template": "
\n @if (tables().length > 1) {\n
\n \n \n @for (t of tables(); track t.id) {\n \n }\n \n
\n }\n\n @if (table().temporal) {\n
\n \n \n
\n @if (previewing()) {\n {{\n showAll\n }}\n }\n }\n
\n\n@if (previewing()) {\n

{{ previewNote }}

\n}\n\n\n \n \n @for (col of table().columns; track col.name) {\n \n }\n \n \n \n \n @for (item of display(); track item.index) {\n \n @for (col of table().columns; track col.name) {\n \n }\n \n \n }\n \n
{{ col.name }}{{ previewing() ? '' : actionsLabel }}
\n @if (col.type === 'enum') {\n \n \n @for (opt of col.options; track opt) {\n \n }\n \n } @else {\n \n }\n \n @if (!previewing()) {\n {{ removeLabel }}\n }\n @if (errors()[item.index]) {\n {{ errors()[item.index] }}\n }\n
\n\n@if (!previewing()) {\n
\n {{\n undoLabel\n }}\n {{\n redoLabel\n }}\n {{ addRowLabel }}\n {{ countsLabel() }}\n {{\n downloadLabel\n }}\n
\n

{{ applyHint }}

\n}\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -28834,7 +29168,29 @@ "indexKey": "", "optional": false, "description": "", - "line": 182, + "line": 188, + "required": false + }, + { + "name": "canRedo", + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 190, + "required": false + }, + { + "name": "canUndo", + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 189, "required": false }, { @@ -28845,7 +29201,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 180, + "line": 186, "required": true }, { @@ -28856,7 +29212,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 179, + "line": 185, "required": true }, { @@ -28867,7 +29223,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 181, + "line": 187, "required": false }, { @@ -28878,7 +29234,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 178, + "line": 184, "required": true }, { @@ -28890,7 +29246,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 184, + "line": 192, "required": false }, { @@ -28901,7 +29257,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 177, + "line": 183, "required": true }, { @@ -28913,7 +29269,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 183, + "line": 191, "required": false } ], @@ -28926,7 +29282,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 187, + "line": 195, "required": false }, { @@ -28937,7 +29293,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 191, + "line": 199, "required": false }, { @@ -28948,7 +29304,18 @@ "indexKey": "", "optional": false, "description": "", - "line": 190, + "line": 198, + "required": false + }, + { + "name": "redo", + "deprecated": false, + "deprecationMessage": "", + "type": "void", + "indexKey": "", + "optional": false, + "description": "", + "line": 201, "required": false }, { @@ -28959,7 +29326,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 188, + "line": 196, "required": false }, { @@ -28970,7 +29337,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 189, + "line": 197, "required": false }, { @@ -28981,7 +29348,18 @@ "indexKey": "", "optional": false, "description": "", - "line": 186, + "line": 194, + "required": false + }, + { + "name": "undo", + "deprecated": false, + "deprecationMessage": "", + "type": "void", + "indexKey": "", + "optional": false, + "description": "", + "line": 200, "required": false } ], @@ -28995,7 +29373,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 225, + "line": 235, "modifierKind": [ 124 ] @@ -29009,7 +29387,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 213, + "line": 223, "modifierKind": [ 123 ] @@ -29023,7 +29401,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 227, + "line": 239, "modifierKind": [ 124 ] @@ -29037,7 +29415,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 229, + "line": 241, "modifierKind": [ 124 ] @@ -29051,7 +29429,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 216, + "line": 226, "modifierKind": [ 124 ] @@ -29065,7 +29443,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 195, + "line": 205, "modifierKind": [ 124 ] @@ -29079,7 +29457,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 228, + "line": 240, "modifierKind": [ 124 ] @@ -29093,7 +29471,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 214, + "line": 224, "modifierKind": [ 123 ] @@ -29107,7 +29485,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 222, + "line": 232, "modifierKind": [ 124 ] @@ -29121,7 +29499,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 193, + "line": 203, "modifierKind": [ 124 ] @@ -29135,7 +29513,21 @@ "indexKey": "", "optional": false, "description": "", - "line": 224, + "line": 234, + "modifierKind": [ + 124 + ] + }, + { + "name": "redoLabel", + "defaultValue": "$localize`:@@beheer.redo:Opnieuw uitvoeren`", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 238, "modifierKind": [ 124 ] @@ -29149,7 +29541,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 215, + "line": 225, "modifierKind": [ 123 ] @@ -29163,7 +29555,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 226, + "line": 236, "modifierKind": [ 124 ] @@ -29177,7 +29569,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 223, + "line": 233, "modifierKind": [ 124 ] @@ -29191,7 +29583,21 @@ "indexKey": "", "optional": false, "description": "", - "line": 221, + "line": 231, + "modifierKind": [ + 124 + ] + }, + { + "name": "undoLabel", + "defaultValue": "$localize`:@@beheer.undo:Ongedaan maken`", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 237, "modifierKind": [ 124 ] @@ -29213,7 +29619,7 @@ "optional": false, "returnType": "string", "typeParameters": [], - "line": 209, + "line": 219, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -29256,7 +29662,7 @@ "optional": false, "returnType": "string", "typeParameters": [], - "line": 205, + "line": 215, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -29302,7 +29708,7 @@ "optional": false, "returnType": "string", "typeParameters": [], - "line": 201, + "line": 211, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -29337,7 +29743,7 @@ "description": "

Organism: the GENERIC stamdata grid. It renders entirely from the reflected column\nschema — one input per column type (native date/number, enum select, text) — so a\nnew stamdata table needs zero UI code here. The "geldig op" control filters to the rows\nvalid on a date (read-only preview); edits and download work on the full set. Emits\nintent; the store owns state (CLAUDE.md §1).

\n", "rawdescription": "\n\nOrganism: the GENERIC stamdata grid. It renders entirely from the reflected column\nschema — one input per column type (native `date`/`number`, `enum` select, text) — so a\nnew stamdata table needs zero UI code here. The \"geldig op\" control filters to the rows\nvalid on a date (read-only preview); edits and download work on the full set. Emits\nintent; the store owns state (CLAUDE.md §1).\n", "type": "component", - "sourceCode": "import { Component, computed, input, output } from '@angular/core';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { ChangeCounts, StamColumn, StamRow, StamTable, activeOn } from '@beheer/domain/stamdata';\n\ninterface DisplayRow {\n row: StamRow;\n index: number;\n}\n\n/**\n * Organism: the GENERIC stamdata grid. It renders entirely from the reflected column\n * schema — one input per column type (native `date`/`number`, `enum` select, text) — so a\n * new stamdata table needs zero UI code here. The \"geldig op\" control filters to the rows\n * valid on a date (read-only preview); edits and download work on the full set. Emits\n * intent; the store owns state (CLAUDE.md §1).\n */\n@Component({\n selector: 'app-stamdata-table-editor',\n imports: [ButtonComponent],\n styles: [\n `\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n align-items: end;\n margin-block-end: 1rem;\n }\n .field label {\n display: block;\n font-size: 0.85em;\n color: var(--rhc-color-foreground-subtle);\n }\n table {\n inline-size: 100%;\n }\n .err {\n color: var(--rhc-color-rood-500);\n font-size: 0.85em;\n }\n .footer {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n align-items: center;\n margin-block-start: 1rem;\n }\n .counts {\n color: var(--rhc-color-foreground-subtle);\n }\n .hint {\n margin-block-start: 0.5rem;\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n `,\n ],\n template: `\n
\n @if (tables().length > 1) {\n
\n \n \n @for (t of tables(); track t.id) {\n \n }\n \n
\n }\n\n @if (table().temporal) {\n
\n \n \n
\n @if (previewing()) {\n {{\n showAll\n }}\n }\n }\n
\n\n @if (previewing()) {\n

{{ previewNote }}

\n }\n\n \n \n \n @for (col of table().columns; track col.name) {\n \n }\n \n \n \n \n @for (item of display(); track item.index) {\n \n @for (col of table().columns; track col.name) {\n \n }\n \n \n }\n \n
{{ col.name }}{{ previewing() ? '' : actionsLabel }}
\n @if (col.type === 'enum') {\n \n \n @for (opt of col.options; track opt) {\n \n }\n \n } @else {\n \n }\n \n @if (!previewing()) {\n {{ removeLabel }}\n }\n @if (errors()[item.index]) {\n {{ errors()[item.index] }}\n }\n
\n\n @if (!previewing()) {\n
\n {{ addRowLabel }}\n {{ countsLabel() }}\n {{\n downloadLabel\n }}\n
\n

{{ applyHint }}

\n }\n `,\n})\nexport class StamdataTableEditorComponent {\n table = input.required();\n rows = input.required();\n errors = input.required();\n counts = input.required();\n previewDate = input('');\n canDownload = input(false);\n tables = input([]);\n selectedTableId = input(null);\n\n selectTable = output();\n cellEdited = output<{ row: number; column: string; value: string }>();\n rowAdded = output();\n rowRemoved = output();\n previewDateChanged = output();\n download = output();\n\n protected previewing = computed(() => this.previewDate() !== '');\n\n protected display = computed(() =>\n this.rows()\n .map((row, index) => ({ row, index }))\n .filter(({ row }) => !this.previewing() || activeOn(this.table(), row, this.previewDate())),\n );\n\n protected inputType(col: StamColumn): string {\n return col.type === 'date' ? 'date' : col.type === 'number' ? 'number' : 'text';\n }\n\n protected cellLabel(col: StamColumn, index: number): string {\n return `${col.name} — rij ${index + 1}`;\n }\n\n protected asValue(e: Event): string {\n return (e.target as HTMLInputElement | HTMLSelectElement).value;\n }\n\n private addedWord = $localize`:@@beheer.added:toegevoegd`;\n private editedWord = $localize`:@@beheer.edited:gewijzigd`;\n private removedWord = $localize`:@@beheer.removed:verwijderd`;\n protected countsLabel = computed(() => {\n const c = this.counts();\n return `${c.added} ${this.addedWord} · ${c.edited} ${this.editedWord} · ${c.removed} ${this.removedWord}`;\n });\n\n protected tableLabel = $localize`:@@beheer.table:Tabel`;\n protected peildatumLabel = $localize`:@@beheer.peildatum:Toon geldig op`;\n protected showAll = $localize`:@@beheer.showAll:Toon alles`;\n protected previewNote = $localize`:@@beheer.previewNote:Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit.`;\n protected actionsLabel = $localize`:@@beheer.actions:Acties`;\n protected removeLabel = $localize`:@@beheer.remove:Verwijderen`;\n protected addRowLabel = $localize`:@@beheer.addRow:Rij toevoegen`;\n protected downloadLabel = $localize`:@@beheer.download:Download JSON`;\n protected applyHint = $localize`:@@beheer.applyHint:Wijzigingen worden als JSON-bestand gedownload en via een pull request toegepast — de build (CI) controleert ze.`;\n}\n", + "sourceCode": "import { Component, computed, input, output } from '@angular/core';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { ChangeCounts, StamColumn, StamRow, StamTable, activeOn } from '@beheer/domain/stamdata';\n\ninterface DisplayRow {\n row: StamRow;\n index: number;\n}\n\n/**\n * Organism: the GENERIC stamdata grid. It renders entirely from the reflected column\n * schema — one input per column type (native `date`/`number`, `enum` select, text) — so a\n * new stamdata table needs zero UI code here. The \"geldig op\" control filters to the rows\n * valid on a date (read-only preview); edits and download work on the full set. Emits\n * intent; the store owns state (CLAUDE.md §1).\n */\n@Component({\n selector: 'app-stamdata-table-editor',\n imports: [ButtonComponent],\n styles: [\n `\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n align-items: end;\n margin-block-end: 1rem;\n }\n .field label {\n display: block;\n font-size: 0.85em;\n color: var(--rhc-color-foreground-subtle);\n }\n table {\n inline-size: 100%;\n }\n .err {\n color: var(--rhc-color-rood-500);\n font-size: 0.85em;\n }\n .footer {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n align-items: center;\n margin-block-start: 1rem;\n }\n .counts {\n color: var(--rhc-color-foreground-subtle);\n }\n .hint {\n margin-block-start: 0.5rem;\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n `,\n ],\n template: `\n
\n @if (tables().length > 1) {\n
\n \n \n @for (t of tables(); track t.id) {\n \n }\n \n
\n }\n\n @if (table().temporal) {\n
\n \n \n
\n @if (previewing()) {\n {{\n showAll\n }}\n }\n }\n
\n\n @if (previewing()) {\n

{{ previewNote }}

\n }\n\n \n \n \n @for (col of table().columns; track col.name) {\n \n }\n \n \n \n \n @for (item of display(); track item.index) {\n \n @for (col of table().columns; track col.name) {\n \n }\n \n \n }\n \n
{{ col.name }}{{ previewing() ? '' : actionsLabel }}
\n @if (col.type === 'enum') {\n \n \n @for (opt of col.options; track opt) {\n \n }\n \n } @else {\n \n }\n \n @if (!previewing()) {\n {{ removeLabel }}\n }\n @if (errors()[item.index]) {\n {{ errors()[item.index] }}\n }\n
\n\n @if (!previewing()) {\n
\n {{\n undoLabel\n }}\n {{\n redoLabel\n }}\n {{ addRowLabel }}\n {{ countsLabel() }}\n {{\n downloadLabel\n }}\n
\n

{{ applyHint }}

\n }\n `,\n})\nexport class StamdataTableEditorComponent {\n table = input.required();\n rows = input.required();\n errors = input.required();\n counts = input.required();\n previewDate = input('');\n canDownload = input(false);\n canUndo = input(false);\n canRedo = input(false);\n tables = input([]);\n selectedTableId = input(null);\n\n selectTable = output();\n cellEdited = output<{ row: number; column: string; value: string }>();\n rowAdded = output();\n rowRemoved = output();\n previewDateChanged = output();\n download = output();\n undo = output();\n redo = output();\n\n protected previewing = computed(() => this.previewDate() !== '');\n\n protected display = computed(() =>\n this.rows()\n .map((row, index) => ({ row, index }))\n .filter(({ row }) => !this.previewing() || activeOn(this.table(), row, this.previewDate())),\n );\n\n protected inputType(col: StamColumn): string {\n return col.type === 'date' ? 'date' : col.type === 'number' ? 'number' : 'text';\n }\n\n protected cellLabel(col: StamColumn, index: number): string {\n return `${col.name} — rij ${index + 1}`;\n }\n\n protected asValue(e: Event): string {\n return (e.target as HTMLInputElement | HTMLSelectElement).value;\n }\n\n private addedWord = $localize`:@@beheer.added:toegevoegd`;\n private editedWord = $localize`:@@beheer.edited:gewijzigd`;\n private removedWord = $localize`:@@beheer.removed:verwijderd`;\n protected countsLabel = computed(() => {\n const c = this.counts();\n return `${c.added} ${this.addedWord} · ${c.edited} ${this.editedWord} · ${c.removed} ${this.removedWord}`;\n });\n\n protected tableLabel = $localize`:@@beheer.table:Tabel`;\n protected peildatumLabel = $localize`:@@beheer.peildatum:Toon geldig op`;\n protected showAll = $localize`:@@beheer.showAll:Toon alles`;\n protected previewNote = $localize`:@@beheer.previewNote:Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit.`;\n protected actionsLabel = $localize`:@@beheer.actions:Acties`;\n protected removeLabel = $localize`:@@beheer.remove:Verwijderen`;\n protected undoLabel = $localize`:@@beheer.undo:Ongedaan maken`;\n protected redoLabel = $localize`:@@beheer.redo:Opnieuw uitvoeren`;\n protected addRowLabel = $localize`:@@beheer.addRow:Rij toevoegen`;\n protected downloadLabel = $localize`:@@beheer.download:Download JSON`;\n protected applyHint = $localize`:@@beheer.applyHint:Wijzigingen worden als JSON-bestand gedownload en via een pull request toegepast — de build (CI) controleert ze.`;\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n align-items: end;\n margin-block-end: 1rem;\n }\n .field label {\n display: block;\n font-size: 0.85em;\n color: var(--rhc-color-foreground-subtle);\n }\n table {\n inline-size: 100%;\n }\n .err {\n color: var(--rhc-color-rood-500);\n font-size: 0.85em;\n }\n .footer {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n align-items: center;\n margin-block-start: 1rem;\n }\n .counts {\n color: var(--rhc-color-foreground-subtle);\n }\n .hint {\n margin-block-start: 0.5rem;\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n \n", @@ -30807,6 +31213,16 @@ "type": "unknown", "defaultValue": "(error: E): Result => ({ ok: false, error })" }, + { + "name": "FAILED", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/beheer/infrastructure/stamdata.adapter.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "defaultValue": "$localize`:@@beheer.load.failed:De stamdata kon niet worden geladen.`" + }, { "name": "FAILED", "ctype": "miscellaneous", @@ -30819,16 +31235,6 @@ "rawdescription": "The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/\nrollback go through the generated client (X-Role added by `roleInterceptor`);\n`parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and\n`ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.", "description": "

The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/\nrollback go through the generated client (X-Role added by roleInterceptor);\nparse* narrows the untrusted wire shape. The proefbrief is text/html and\nExcludeFromDescription'd — a hand-written fetch, same seam as letter-preview.adapter.

\n" }, - { - "name": "FAILED", - "ctype": "miscellaneous", - "subtype": "variable", - "file": "src/app/beheer/infrastructure/stamdata.adapter.ts", - "deprecated": false, - "deprecationMessage": "", - "type": "unknown", - "defaultValue": "$localize`:@@beheer.load.failed:De stamdata kon niet worden geladen.`" - }, { "name": "find", "ctype": "miscellaneous", @@ -30913,6 +31319,16 @@ "rawdescription": "Used by the shell to find what to poll on return: still-in-flight uploads.", "description": "

Used by the shell to find what to poll on return: still-in-flight uploads.

\n" }, + { + "name": "initial", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/beheer/domain/stamdata-editor.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "StamdataEditorState", + "defaultValue": "{ tag: 'loading' }" + }, { "name": "initial", "ctype": "miscellaneous", @@ -30933,16 +31349,6 @@ "type": "OrgTemplateState", "defaultValue": "{ tag: 'loading' }" }, - { - "name": "initial", - "ctype": "miscellaneous", - "subtype": "variable", - "file": "src/app/beheer/domain/stamdata-editor.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "type": "StamdataEditorState", - "defaultValue": "{ tag: 'loading' }" - }, { "name": "initial", "ctype": "miscellaneous", @@ -32487,6 +32893,33 @@ } ] }, + { + "name": "createDebouncedSave", + "file": "src/app/shared/application/debounced-save.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

The debounced-autosave timer shared by the editor stores (WP-31). It owns ONLY the timer\nbookkeeping; the actual write + save-state transitions live in the caller's flush\n(store-specific — it touches that store's SaveState/ActionState + adapter). The handle is\nnulled the moment it fires, so hasPendingSave() means "a write is still owed". Integrates\nwith the PendingSave seam (pending-saves.ts): a store delegates hasPendingSave/flushPending\nhere so the CanDeactivate guard / beforeunload handler can flush a pending edit.

\n", + "args": [ + { + "name": "opts", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "DebouncedSave", + "jsdoctags": [ + { + "name": "opts", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "createDraftSync", "file": "src/app/registratie/application/draft-sync.ts", @@ -32515,6 +32948,37 @@ } ] }, + { + "name": "createHistory", + "file": "src/app/shared/application/history.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Generic undo/redo history over an immutable "document" value T. Elm-store editors\nrestore a returned snapshot by re-dispatching a Seed-style Msg — this helper only\nshuffles references, it never mutates them, so the caller must hold copy-on-write state\n(every edit produces a fresh value). Both stacks are capped so a long session can't grow\nunbounded. Extracted from BriefStore's WP-27 undo/redo (WP-31); reused by the stamdata\neditor (WP-32).

\n", + "args": [ + { + "name": "cap", + "type": "number", + "deprecated": false, + "deprecationMessage": "", + "defaultValue": "50" + } + ], + "returnType": "History", + "jsdoctags": [ + { + "name": "cap", + "type": "number", + "deprecated": false, + "deprecationMessage": "", + "defaultValue": "50", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "createStore", "file": "src/app/shared/application/store.ts", @@ -34328,6 +34792,35 @@ } ] }, + { + "name": "machineRemoteData", + "file": "src/app/shared/application/machine-remote-data.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Project an Elm-machine state onto RemoteData for the <app-async> seam. The machine\nkeeps owning its own domain lifecycle (draft/submitted/…); this is purely the\nloading/failed/loaded → async mapping, which was byte-identical across BriefStore,\nOrgTemplateStore and StamdataStore (WP-31). Wrap the call in a computed.

\n", + "args": [ + { + "name": "s", + "type": "S", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RemoteData>", + "jsdoctags": [ + { + "name": "s", + "type": "S", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "map", "file": "src/app/shared/application/remote-data.ts", @@ -36397,6 +36890,50 @@ } ] }, + { + "name": "reduce", + "file": "src/app/beheer/domain/stamdata-editor.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "StamdataEditorState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "m", + "type": "StamdataEditorMsg", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "StamdataEditorState", + "jsdoctags": [ + { + "name": "s", + "type": "StamdataEditorState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "m", + "type": "StamdataEditorMsg", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "reduce", "file": "src/app/brief/domain/brief.machine.ts", @@ -36485,50 +37022,6 @@ } ] }, - { - "name": "reduce", - "file": "src/app/beheer/domain/stamdata-editor.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "args": [ - { - "name": "s", - "type": "StamdataEditorState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "m", - "type": "StamdataEditorMsg", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "StamdataEditorState", - "jsdoctags": [ - { - "name": "s", - "type": "StamdataEditorState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "m", - "type": "StamdataEditorMsg", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, { "name": "reduce", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", @@ -38971,21 +39464,10 @@ "ctype": "miscellaneous", "subtype": "typealias", "rawtype": "literal type | literal type | literal type", - "file": "src/app/brief/application/brief.store.ts", + "file": "src/app/shared/application/action-state.ts", "deprecated": false, "deprecationMessage": "", - "description": "

Transient action state (submit/approve/reject/send/resetDemo) — one tagged union\ninstead of a busy boolean + a nullable error sitting side by side.

\n", - "kind": 193 - }, - { - "name": "ActionState", - "ctype": "miscellaneous", - "subtype": "typealias", - "rawtype": "literal type | literal type | literal type", - "file": "src/app/brief/application/org-template.store.ts", - "deprecated": false, - "deprecationMessage": "", - "description": "

Transient action state for publish/rollback/proefbrief — the BriefStore idiom.

\n", + "description": "

Transient state of a one-shot action (submit/approve/publish/reset/…): one tagged\nunion instead of a busy boolean + a nullable error sitting side by side. Shared by the\neditor stores (WP-31).

\n", "kind": 193 }, { @@ -39352,11 +39834,11 @@ "kind": 193 }, { - "name": "LoadedBriefState", + "name": "LoadedState", "ctype": "miscellaneous", "subtype": "typealias", - "rawtype": "Extract", - "file": "src/app/brief/application/brief.store.ts", + "rawtype": "Extract", + "file": "src/app/beheer/application/stamdata.store.ts", "deprecated": false, "deprecationMessage": "", "description": "", @@ -39374,15 +39856,15 @@ "kind": 184 }, { - "name": "LoadedState", + "name": "LoadLifecycle", "ctype": "miscellaneous", "subtype": "typealias", - "rawtype": "Extract", - "file": "src/app/beheer/application/stamdata.store.ts", + "rawtype": "literal type | literal type | literal type", + "file": "src/app/shared/application/machine-remote-data.ts", "deprecated": false, "deprecationMessage": "", - "description": "", - "kind": 184 + "description": "

The standard load-lifecycle tags an editor machine exposes.

\n", + "kind": 193 }, { "name": "Mark", @@ -39554,21 +40036,10 @@ "ctype": "miscellaneous", "subtype": "typealias", "rawtype": "literal type | literal type | literal type | literal type", - "file": "src/app/brief/application/brief.store.ts", + "file": "src/app/shared/application/action-state.ts", "deprecated": false, "deprecationMessage": "", - "description": "

Debounced-autosave indicator, shown in a small status line near the toolbar —\na separate concern from ActionState (a stale autosave error doesn't block\nsubmit/approve/reject), but tag-aligned with it for one consistent idiom.

\n", - "kind": 193 - }, - { - "name": "SaveState", - "ctype": "miscellaneous", - "subtype": "typealias", - "rawtype": "literal type | literal type | literal type | literal type", - "file": "src/app/brief/application/org-template.store.ts", - "deprecated": false, - "deprecationMessage": "", - "description": "", + "description": "

Debounced-autosave indicator, shown in a small status line near a toolbar — a separate\nconcern from ActionState (a stale autosave error doesn't block submit/approve), but\ntag-aligned with it for one consistent idiom.

\n", "kind": 193 }, { @@ -45516,6 +45987,35 @@ ] } ], + "src/app/shared/application/debounced-save.ts": [ + { + "name": "createDebouncedSave", + "file": "src/app/shared/application/debounced-save.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

The debounced-autosave timer shared by the editor stores (WP-31). It owns ONLY the timer\nbookkeeping; the actual write + save-state transitions live in the caller's flush\n(store-specific — it touches that store's SaveState/ActionState + adapter). The handle is\nnulled the moment it fires, so hasPendingSave() means "a write is still owed". Integrates\nwith the PendingSave seam (pending-saves.ts): a store delegates hasPendingSave/flushPending\nhere so the CanDeactivate guard / beforeunload handler can flush a pending edit.

\n", + "args": [ + { + "name": "opts", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "DebouncedSave", + "jsdoctags": [ + { + "name": "opts", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + ], "src/app/registratie/application/draft-sync.ts": [ { "name": "createDraftSync", @@ -45546,6 +46046,39 @@ ] } ], + "src/app/shared/application/history.ts": [ + { + "name": "createHistory", + "file": "src/app/shared/application/history.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Generic undo/redo history over an immutable "document" value T. Elm-store editors\nrestore a returned snapshot by re-dispatching a Seed-style Msg — this helper only\nshuffles references, it never mutates them, so the caller must hold copy-on-write state\n(every edit produces a fresh value). Both stacks are capped so a long session can't grow\nunbounded. Extracted from BriefStore's WP-27 undo/redo (WP-31); reused by the stamdata\neditor (WP-32).

\n", + "args": [ + { + "name": "cap", + "type": "number", + "deprecated": false, + "deprecationMessage": "", + "defaultValue": "50" + } + ], + "returnType": "History", + "jsdoctags": [ + { + "name": "cap", + "type": "number", + "deprecated": false, + "deprecationMessage": "", + "defaultValue": "50", + "tagName": { + "text": "param" + } + } + ] + } + ], "src/app/shared/application/store.ts": [ { "name": "createStore", @@ -46843,6 +47376,37 @@ ] } ], + "src/app/shared/application/machine-remote-data.ts": [ + { + "name": "machineRemoteData", + "file": "src/app/shared/application/machine-remote-data.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Project an Elm-machine state onto RemoteData for the <app-async> seam. The machine\nkeeps owning its own domain lifecycle (draft/submitted/…); this is purely the\nloading/failed/loaded → async mapping, which was byte-identical across BriefStore,\nOrgTemplateStore and StamdataStore (WP-31). Wrap the call in a computed.

\n", + "args": [ + { + "name": "s", + "type": "S", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RemoteData>", + "jsdoctags": [ + { + "name": "s", + "type": "S", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + ], "src/app/shared/ui/debug-state/mask.ts": [ { "name": "maskBsn", @@ -48375,73 +48939,27 @@ "kind": 193 } ], - "src/app/brief/application/brief.store.ts": [ + "src/app/shared/application/action-state.ts": [ { "name": "ActionState", "ctype": "miscellaneous", "subtype": "typealias", "rawtype": "literal type | literal type | literal type", - "file": "src/app/brief/application/brief.store.ts", + "file": "src/app/shared/application/action-state.ts", "deprecated": false, "deprecationMessage": "", - "description": "

Transient action state (submit/approve/reject/send/resetDemo) — one tagged union\ninstead of a busy boolean + a nullable error sitting side by side.

\n", + "description": "

Transient state of a one-shot action (submit/approve/publish/reset/…): one tagged\nunion instead of a busy boolean + a nullable error sitting side by side. Shared by the\neditor stores (WP-31).

\n", "kind": 193 }, - { - "name": "LoadedBriefState", - "ctype": "miscellaneous", - "subtype": "typealias", - "rawtype": "Extract", - "file": "src/app/brief/application/brief.store.ts", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "kind": 184 - }, { "name": "SaveState", "ctype": "miscellaneous", "subtype": "typealias", "rawtype": "literal type | literal type | literal type | literal type", - "file": "src/app/brief/application/brief.store.ts", + "file": "src/app/shared/application/action-state.ts", "deprecated": false, "deprecationMessage": "", - "description": "

Debounced-autosave indicator, shown in a small status line near the toolbar —\na separate concern from ActionState (a stale autosave error doesn't block\nsubmit/approve/reject), but tag-aligned with it for one consistent idiom.

\n", - "kind": 193 - } - ], - "src/app/brief/application/org-template.store.ts": [ - { - "name": "ActionState", - "ctype": "miscellaneous", - "subtype": "typealias", - "rawtype": "literal type | literal type | literal type", - "file": "src/app/brief/application/org-template.store.ts", - "deprecated": false, - "deprecationMessage": "", - "description": "

Transient action state for publish/rollback/proefbrief — the BriefStore idiom.

\n", - "kind": 193 - }, - { - "name": "LoadedState", - "ctype": "miscellaneous", - "subtype": "typealias", - "rawtype": "Extract", - "file": "src/app/brief/application/org-template.store.ts", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "kind": 184 - }, - { - "name": "SaveState", - "ctype": "miscellaneous", - "subtype": "typealias", - "rawtype": "literal type | literal type | literal type | literal type", - "file": "src/app/brief/application/org-template.store.ts", - "deprecated": false, - "deprecationMessage": "", - "description": "", + "description": "

Debounced-autosave indicator, shown in a small status line near a toolbar — a separate\nconcern from ActionState (a stale autosave error doesn't block submit/approve), but\ntag-aligned with it for one consistent idiom.

\n", "kind": 193 } ], @@ -48962,6 +49480,32 @@ "kind": 184 } ], + "src/app/brief/application/org-template.store.ts": [ + { + "name": "LoadedState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "Extract", + "file": "src/app/brief/application/org-template.store.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 184 + } + ], + "src/app/shared/application/machine-remote-data.ts": [ + { + "name": "LoadLifecycle", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type", + "file": "src/app/shared/application/machine-remote-data.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

The standard load-lifecycle tags an editor machine exposes.

\n", + "kind": 193 + } + ], "src/app/shared/kernel/rich-text.ts": [ { "name": "Mark", @@ -49437,8 +49981,8 @@ "type": "injectable", "linktype": "injectable", "name": "StamdataStore", - "coveragePercent": 18, - "coverageCount": "4/22", + "coveragePercent": 17, + "coverageCount": "5/29", "status": "low" }, { @@ -49798,8 +50342,8 @@ "type": "component", "linktype": "component", "name": "StamdataTableEditorComponent", - "coveragePercent": 3, - "coverageCount": "1/33", + "coveragePercent": 2, + "coverageCount": "1/39", "status": "low" }, { @@ -49816,8 +50360,8 @@ "type": "component", "linktype": "component", "name": "StamdataPage", - "coveragePercent": 8, - "coverageCount": "1/12", + "coveragePercent": 15, + "coverageCount": "2/13", "status": "low" }, { @@ -49825,47 +50369,17 @@ "type": "injectable", "linktype": "injectable", "name": "BriefStore", - "coveragePercent": 36, - "coverageCount": "20/55", + "coveragePercent": 37, + "coverageCount": "19/51", "status": "medium" }, - { - "filePath": "src/app/brief/application/brief.store.ts", - "type": "type alias", - "linktype": "miscellaneous", - "linksubtype": "typealias", - "name": "ActionState", - "coveragePercent": 100, - "coverageCount": "1/1", - "status": "very-good" - }, - { - "filePath": "src/app/brief/application/brief.store.ts", - "type": "type alias", - "linktype": "miscellaneous", - "linksubtype": "typealias", - "name": "LoadedBriefState", - "coveragePercent": 0, - "coverageCount": "0/1", - "status": "low" - }, - { - "filePath": "src/app/brief/application/brief.store.ts", - "type": "type alias", - "linktype": "miscellaneous", - "linksubtype": "typealias", - "name": "SaveState", - "coveragePercent": 100, - "coverageCount": "1/1", - "status": "very-good" - }, { "filePath": "src/app/brief/application/org-template.store.ts", "type": "injectable", "linktype": "injectable", "name": "OrgTemplateStore", - "coveragePercent": 16, - "coverageCount": "7/43", + "coveragePercent": 14, + "coverageCount": "6/42", "status": "low" }, { @@ -49888,16 +50402,6 @@ "coverageCount": "0/1", "status": "low" }, - { - "filePath": "src/app/brief/application/org-template.store.ts", - "type": "type alias", - "linktype": "miscellaneous", - "linksubtype": "typealias", - "name": "ActionState", - "coveragePercent": 100, - "coverageCount": "1/1", - "status": "very-good" - }, { "filePath": "src/app/brief/application/org-template.store.ts", "type": "type alias", @@ -49908,16 +50412,6 @@ "coverageCount": "0/1", "status": "low" }, - { - "filePath": "src/app/brief/application/org-template.store.ts", - "type": "type alias", - "linktype": "miscellaneous", - "linksubtype": "typealias", - "name": "SaveState", - "coveragePercent": 0, - "coverageCount": "0/1", - "status": "low" - }, { "filePath": "src/app/brief/domain/besluit.ts", "type": "interface", @@ -52825,6 +53319,84 @@ "coverageCount": "0/1", "status": "low" }, + { + "filePath": "src/app/shared/application/action-state.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "ActionState", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/shared/application/action-state.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "SaveState", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/shared/application/debounced-save.ts", + "type": "interface", + "linktype": "interface", + "name": "DebouncedSave", + "coveragePercent": 80, + "coverageCount": "4/5", + "status": "very-good" + }, + { + "filePath": "src/app/shared/application/debounced-save.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "createDebouncedSave", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/shared/application/history.ts", + "type": "interface", + "linktype": "interface", + "name": "History", + "coveragePercent": 42, + "coverageCount": "3/7", + "status": "medium" + }, + { + "filePath": "src/app/shared/application/history.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "createHistory", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/shared/application/machine-remote-data.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "machineRemoteData", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/shared/application/machine-remote-data.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "LoadLifecycle", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, { "filePath": "src/app/shared/application/pending-saves.ts", "type": "injectable", diff --git a/src/app/beheer/application/stamdata.store.spec.ts b/src/app/beheer/application/stamdata.store.spec.ts new file mode 100644 index 0000000..d81c350 --- /dev/null +++ b/src/app/beheer/application/stamdata.store.spec.ts @@ -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 = { + list: (): Promise> => Promise.resolve(ok([table])), + load: (): Promise> => + 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); + }); +}); diff --git a/src/app/beheer/application/stamdata.store.ts b/src/app/beheer/application/stamdata.store.ts index a313548..c3f9154 100644 --- a/src/app/beheer/application/stamdata.store.ts +++ b/src/app/beheer/application/stamdata.store.ts @@ -1,6 +1,7 @@ import { Injectable, computed, inject, signal } from '@angular/core'; import { createStore } from '@shared/application/store'; import { machineRemoteData } from '@shared/application/machine-remote-data'; +import { createHistory } from '@shared/application/history'; import { ChangeCounts, StamRow, @@ -85,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 }); @@ -95,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(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`). */ diff --git a/src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts b/src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts index 172b497..b312532 100644 --- a/src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts +++ b/src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts @@ -163,6 +163,12 @@ interface DisplayRow { @if (!previewing()) {