From 5761b13dd26b97c48522ce2eb6d7db57904de805 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Tue, 21 Jul 2026 17:20:42 +0200 Subject: [PATCH] style: format the repo with prettier (green format:check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm run format:check` (a CI gate) had drifted red across 44 files — pre-existing files plus recently-added ones committed without formatting. Ran `prettier --write .`; no logic changes. Also regenerates documentation.json (compodoc reflects the reformatted component sources). Co-Authored-By: Claude Opus 4.8 --- .vscode/launch.json | 1 - docs/README.md | 34 +- docs/project/backlog/README.md | 62 +- .../backlog/WP-18-abac-capability-spine.md | 1 + docs/project/backlog/WP-24-letter-canvas.md | 2 +- .../backlog/WP-25-letter-preview-html.md | 2 +- .../backlog/WP-26-org-template-editor.md | 2 +- .../backlog/WP-29-stamdata-beheer-editor.md | 4 +- .../0002-attribute-based-access-control.md | 2 +- .../architecture/0004-stamdata-as-code.md | 18 +- documentation.json | 616 +++++++++--------- src/app/app.routes.ts | 3 +- .../domain/stamdata-editor.machine.spec.ts | 10 +- src/app/beheer/domain/stamdata.spec.ts | 18 +- src/app/beheer/domain/stamdata.ts | 10 +- .../infrastructure/stamdata.adapter.spec.ts | 6 +- .../beheer/infrastructure/stamdata.adapter.ts | 7 +- .../stamdata-table-editor.component.ts | 20 +- .../stamdata-table-editor.stories.ts | 20 +- src/app/beheer/ui/stamdata.page.ts | 8 +- src/app/brief/application/brief.store.spec.ts | 22 +- .../brief/application/org-template.store.ts | 19 +- src/app/brief/domain/besluit.spec.ts | 31 +- src/app/brief/domain/besluit.ts | 3 +- src/app/brief/domain/brief-diff.spec.ts | 6 +- .../brief/domain/org-template.machine.spec.ts | 15 +- src/app/brief/domain/org-template.machine.ts | 12 +- .../infrastructure/brief.adapter.spec.ts | 14 +- .../org-template.adapter.spec.ts | 4 +- .../infrastructure/org-template.adapter.ts | 5 +- .../reveal-bignummer.adapter.ts | 6 +- .../behandel-scherm.component.ts | 13 +- .../behandel-scherm.stories.ts | 92 ++- .../ui/besluit-panel/besluit-panel.stories.ts | 46 +- .../letter-canvas/letter-canvas.component.ts | 9 +- .../org-template-editor.component.ts | 13 +- src/app/brief/ui/org-template.page.ts | 8 +- src/app/shared/application/pending-saves.ts | 7 +- src/app/shared/domain/capability.ts | 6 +- src/app/shared/kernel/rich-text.ts | 4 +- src/docs/atomic-design.mdx | 14 +- src/docs/bdd.mdx | 26 +- src/docs/i18n.mdx | 16 +- src/docs/learning-path.mdx | 44 +- src/docs/overview.mdx | 2 +- src/docs/testing.mdx | 22 +- 46 files changed, 762 insertions(+), 543 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 318f547..925af83 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,7 +2,6 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ - { "name": "ng serve", "type": "chrome", diff --git a/docs/README.md b/docs/README.md index 05c3652..cd377af 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,28 +10,28 @@ Docs are split by **kind**, and kept out of each other's way: process-facing material. Teaching material that is best read **next to the components** lives in Storybook, not -here — see the **Foundations** section (`src/docs/*.mdx`), starting at *Foundations → -Overview*. The `reference/` docs are the long-form source; the Foundations pages are the +here — see the **Foundations** section (`src/docs/*.mdx`), starting at _Foundations → +Overview_. The `reference/` docs are the long-form source; the Foundations pages are the condensed, cross-linked curriculum. ## `reference/` — information -| Doc | What it is | -| --- | --- | -| [architecture/ARCHITECTURE.md](reference/architecture/ARCHITECTURE.md) | The architecture walkthrough: contexts/layers, state management, parse-don't-validate, the feature recipe, the .NET backend seam. | -| [architecture/0001-bff-lite-decision-dtos.md](reference/architecture/0001-bff-lite-decision-dtos.md) | ADR — BFF-lite endpoints + decision DTOs (backend decides, FE renders). | -| [architecture/0002-user-groups-and-bounded-contexts.md](reference/architecture/0002-user-groups-and-bounded-contexts.md) | ADR — user groups as actors; identity vs authorization. | -| [architecture/0003-cibg-huisstijl.md](reference/architecture/0003-cibg-huisstijl.md) | ADR — adopt CIBG Huisstijl (vendored Bootstrap 5.2) + the token bridge. | -| [architecture/0004-stamdata-as-code.md](reference/architecture/0004-stamdata-as-code.md) | ADR — business-tunable reference data as typed, compile-time-validated config (not a production DB). | -| [fp-tea-atomic-design.md](reference/fp-tea-atomic-design.md) | Long-form learning guide: FP + The Elm Architecture + atomic design. | -| [wcag-checklist.md](reference/wcag-checklist.md) | Manual WCAG checks automation can't catch (tab order, focus traps, reflow). | -| [ui-ux-audit.md](reference/ui-ux-audit.md) | Early UI/UX audit against NL Design System (predates ADR-0003 — read in that light). | +| Doc | What it is | +| ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| [architecture/ARCHITECTURE.md](reference/architecture/ARCHITECTURE.md) | The architecture walkthrough: contexts/layers, state management, parse-don't-validate, the feature recipe, the .NET backend seam. | +| [architecture/0001-bff-lite-decision-dtos.md](reference/architecture/0001-bff-lite-decision-dtos.md) | ADR — BFF-lite endpoints + decision DTOs (backend decides, FE renders). | +| [architecture/0002-user-groups-and-bounded-contexts.md](reference/architecture/0002-user-groups-and-bounded-contexts.md) | ADR — user groups as actors; identity vs authorization. | +| [architecture/0003-cibg-huisstijl.md](reference/architecture/0003-cibg-huisstijl.md) | ADR — adopt CIBG Huisstijl (vendored Bootstrap 5.2) + the token bridge. | +| [architecture/0004-stamdata-as-code.md](reference/architecture/0004-stamdata-as-code.md) | ADR — business-tunable reference data as typed, compile-time-validated config (not a production DB). | +| [fp-tea-atomic-design.md](reference/fp-tea-atomic-design.md) | Long-form learning guide: FP + The Elm Architecture + atomic design. | +| [wcag-checklist.md](reference/wcag-checklist.md) | Manual WCAG checks automation can't catch (tab order, focus traps, reflow). | +| [ui-ux-audit.md](reference/ui-ux-audit.md) | Early UI/UX audit against NL Design System (predates ADR-0003 — read in that light). | ## `project/` — administration -| Doc | What it is | -| --- | --- | -| [backlog/README.md](project/backlog/README.md) | The work-package backlog index (WP-01…WP-28) — the live tracker. | +| Doc | What it is | +| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | +| [backlog/README.md](project/backlog/README.md) | The work-package backlog index (WP-01…WP-28) — the live tracker. | | [prd/0001-mijn-aanvragen-en-wizardstatus.md](project/prd/0001-mijn-aanvragen-en-wizardstatus.md) | PRD — "Mijn aanvragen": running wizards, application status, document preview. | -| [prd/0002-attribute-based-access-control.md](project/prd/0002-attribute-based-access-control.md) | PRD — attribute-based access control in the UI. | -| [SHOWCASE-ROADMAP.md](project/SHOWCASE-ROADMAP.md) | Superseded roadmap (absorbed into `project/backlog/`) — kept for history. | +| [prd/0002-attribute-based-access-control.md](project/prd/0002-attribute-based-access-control.md) | PRD — attribute-based access control in the UI. | +| [SHOWCASE-ROADMAP.md](project/SHOWCASE-ROADMAP.md) | Superseded roadmap (absorbed into `project/backlog/`) — kept for history. | diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index 883ff30..880540a 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -43,37 +43,37 @@ WP-19's own file), so it's a separate manual/CI step, not chained into the other Gates land before the work they cover; each lint rule lands in the same WP as the fixes for its existing violations, so every WP ends green. -| WP | Title | Phase | Status | -| --------------------------------------- | ------------------------------------------------------------ | --------------------------- | ------ | -| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done | -| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done | -| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done | -| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done | -| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done | -| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done | -| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done | -| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done | -| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done | -| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done | -| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done | -| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done | -| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done | -| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done | -| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done | -| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done | -| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done | -| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done | -| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done | -| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done | -| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done | -| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done | -| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done | -| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done | -| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | todo | -| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done | -| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | todo | -| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo | -| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done | +| WP | Title | Phase | Status | +| ---------------------------------------- | ------------------------------------------------------------ | --------------------------- | ------ | +| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done | +| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done | +| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done | +| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done | +| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done | +| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done | +| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done | +| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done | +| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done | +| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done | +| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done | +| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done | +| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done | +| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done | +| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done | +| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done | +| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done | +| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done | +| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done | +| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done | +| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done | +| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done | +| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done | +| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done | +| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | todo | +| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done | +| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | todo | +| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo | +| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done | Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn); 03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed diff --git a/docs/project/backlog/WP-18-abac-capability-spine.md b/docs/project/backlog/WP-18-abac-capability-spine.md index 2e51d6e..f646ff3 100644 --- a/docs/project/backlog/WP-18-abac-capability-spine.md +++ b/docs/project/backlog/WP-18-abac-capability-spine.md @@ -4,6 +4,7 @@ Status: done (7ec13d8) Phase: 5 — productie-volwassenheid > **Follow-up (P2/P3-lite delivered later).** On top of this P1 spine: +> > - **P2 field-level PII (§5c):** the case screen's **BIG-nummer** now ships masked > (`Authz.CanRevealBigNummer` + `BriefDecisionsDto.CanRevealBigNummer`); a > step-up-stubbed (`X-Step-Up` header), audited `POST /brief/reveal-bignummer` unmasks diff --git a/docs/project/backlog/WP-24-letter-canvas.md b/docs/project/backlog/WP-24-letter-canvas.md index 58ef4bf..ebe9b6c 100644 --- a/docs/project/backlog/WP-24-letter-canvas.md +++ b/docs/project/backlog/WP-24-letter-canvas.md @@ -94,6 +94,6 @@ polish + standaardbrief + diff badges (WP-27). ## Risks The canvas duplicates the letter markup that WP-25's backend renderer will emit — -acceptable *only* because `letter.css` is shared and WP-25 adds the class-parity +acceptable _only_ because `letter.css` is shared and WP-25 adds the class-parity test; until WP-25 lands, the canvas is the sole consumer, so no drift is possible. Deleting `letter-preview` breaks any deep import of it — repo-wide grep before delete. diff --git a/docs/project/backlog/WP-25-letter-preview-html.md b/docs/project/backlog/WP-25-letter-preview-html.md index 3df8afd..4b2abee 100644 --- a/docs/project/backlog/WP-25-letter-preview-html.md +++ b/docs/project/backlog/WP-25-letter-preview-html.md @@ -24,7 +24,7 @@ same composition is archived with the brief, making sent letters immutable. composed, print-ready letter (`@page` CSS; browser print-to-PDF is the manual affordance). The endpoint is the seam where a headless-Chromium PDF render slots in later; mark it `// ponytail: HTML today, Chromium PDF behind this same route if - the POC ever needs real PDF bytes`. +the POC ever needs real PDF bytes`. - **`LetterHtml.Render(brief, orgTemplate)`** is a pure static composer: mirrors the canvas class vocabulary exactly, inlines `public/letter.css` from disk, inlines the logo bytes as a data-URI. Placeholders: auto-resolvable keys resolve from diff --git a/docs/project/backlog/WP-26-org-template-editor.md b/docs/project/backlog/WP-26-org-template-editor.md index 773c9e1..c8d59c8 100644 --- a/docs/project/backlog/WP-26-org-template-editor.md +++ b/docs/project/backlog/WP-26-org-template-editor.md @@ -73,7 +73,7 @@ content a read-only sample). PRD Brief v2 §5, §7h. ## Deviations / notes (as built) - **`check:tokens` was already red on `main`** (WP-24's canvas landed `var(--rhc-*, - #hex)` fallbacks + an `rgb()` paper shadow, and WP-25 committed over it). Fixed here +#hex)` fallbacks + an `rgb()` paper shadow, and WP-25 committed over it). Fixed here to end GREEN: dropped the redundant hex fallbacks (the token bridge defines every one) and marked the paper drop-shadow `token-ok`; also fixed a pre-existing `passage-picker` `var(--rhc-color-wit, #fff)` hit. diff --git a/docs/project/backlog/WP-29-stamdata-beheer-editor.md b/docs/project/backlog/WP-29-stamdata-beheer-editor.md index fc204d9..d64d80c 100644 --- a/docs/project/backlog/WP-29-stamdata-beheer-editor.md +++ b/docs/project/backlog/WP-29-stamdata-beheer-editor.md @@ -24,7 +24,7 @@ authority. No production database, no runtime write path (ADR-0004 unchanged). - **Read-only endpoints only.** `GET /stamdata` (catalog) and `GET /stamdata/{table}?peildatum=` (schema + rows). No POST/PUT/DELETE — the edit lands as a reviewed PR, not a write. The - `stamdata:edit` capability / `CanEditStamdata` gate the *reads* (naming is the enforce-twin + `stamdata:edit` capability / `CanEditStamdata` gate the _reads_ (naming is the enforce-twin of a future edit capability; deliberate). - **Generic, schema-driven.** One endpoint pair + one grid editor serve every table. Columns are reflected from the typed record (`StamdataTable.Of`); the FE renders inputs by column @@ -32,7 +32,7 @@ authority. No production database, no runtime write path (ADR-0004 unchanged). endpoint, UI, or test. (Catalog of one today; this is the shape ADR-0004 prescribed.) - **Data-file format for `professions`.** `professions.json` (embedded resource) replaces the hardcoded C# dictionary, deserialized into `ProfessionMapping`. This trades compile-time - *value* checking (gate #1) for editor ergonomics — the value gate becomes + _value_ checking (gate #1) for editor ergonomics — the value gate becomes `StamdataValidationTests` (gate #2), exactly the trade-off ADR-0004's consequences listed. - **Valid-time.** `geldigVan`/`geldigTot` (half-open `[van, tot)`); a table is temporal iff it has both columns. `peildatum` previews "which rows applied on date X". `Professions.ByProgram` diff --git a/docs/project/prd/0002-attribute-based-access-control.md b/docs/project/prd/0002-attribute-based-access-control.md index 4b5862f..cb8ef4b 100644 --- a/docs/project/prd/0002-attribute-based-access-control.md +++ b/docs/project/prd/0002-attribute-based-access-control.md @@ -138,7 +138,7 @@ canonical case (art. 9 / special-category data): **audited** (§8). > **Implementation note.** No **BSN** actually travels the wire in this POC (the BSN lives only in -> the faked login and is never persisted). The sensitive identifier the backend *does* serve is the +> the faked login and is never persisted). The sensitive identifier the backend _does_ serve is the > **BIG-nummer** on the backoffice case screen (`CaseContextDto`), so the delivered field-level reveal > is realized there (`canRevealBigNummer`, `POST /brief/reveal-bignummer`) — the on-the-wire > equivalent of this BSN illustration. diff --git a/docs/reference/architecture/0004-stamdata-as-code.md b/docs/reference/architecture/0004-stamdata-as-code.md index f432799..1e7d362 100644 --- a/docs/reference/architecture/0004-stamdata-as-code.md +++ b/docs/reference/architecture/0004-stamdata-as-code.md @@ -23,7 +23,7 @@ was neither isolated nor validated: - User-facing UI copy is already **`$localize`** (`src/locale/*.xlf`) — git-tracked, and a second locale is a translation file, not a code change. That is already the compile-time model for text. -- The profession↔diploma map lived as a *private* `Dictionary` inside `DiplomaRules`, mixed +- The profession↔diploma map lived as a _private_ `Dictionary` inside `DiplomaRules`, mixed in with the rules that consume it, with **no cross-reference check**: a diploma whose program wasn't in the map silently rendered `"Onbekend"`. @@ -38,11 +38,11 @@ production database, never runtime-editable. concern. A table lives **either** as plain typed C# data (records / dictionaries) **or** as a typed JSON data-file deserialized into a record (`professions.json` → `ProfessionMapping`, loaded via `StamdataFile`). Both are checked-in config-as-code, gated the same way; the - data-file trades the compiler's *value* check (gate #1 sees only the shape, not a wrong + data-file trades the compiler's _value_ check (gate #1 sees only the shape, not a wrong `beroep`) for hand-editing ergonomics and the low-code editor below — the value gate becomes `StamdataValidationTests`. Separate the **data** (what the business tunes) from the **rules** - (dev-owned logic that consumes it): the profession *table* is `Stamdata.Professions`; the - *rule* "an English diploma needs a B2 question" stays in `DiplomaRules`. Tables may carry + (dev-owned logic that consumes it): the profession _table_ is `Stamdata.Professions`; the + _rule_ "an English diploma needs a B2 question" stays in `DiplomaRules`. Tables may carry **valid-time** (`geldigVan`/`geldigTot`, half-open `[van, tot)`); `StamdataCatalog` + `StamdataTable.Of` describe every table generically (columns reflected from the record) so one endpoint pair and one grid editor serve all of them. @@ -59,17 +59,17 @@ production database, never runtime-editable. ### Where each kind of business-controllable thing lives -| Kind | Home | Gate | -| --- | --- | --- | +| Kind | Home | Gate | +| ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | Reference tables + tunable numbers (professions↔diplomas, thresholds, policy questions, document categories) | `Stamdata/` typed C# **or** typed JSON data-file (`professions.json`), optionally valid-timed | compiler (shape; + values when C#) + `StamdataValidationTests` (values, references, validity windows) | -| User-facing UI copy | `$localize` → `src/locale/*.xlf` | build (`i18nMissingTranslation: error`) | -| Letter / brief passage content | config-as-code in the backend (seed content), **not** the DB | compiler + endpoint tests | +| User-facing UI copy | `$localize` → `src/locale/*.xlf` | build (`i18nMissingTranslation: error`) | +| Letter / brief passage content | config-as-code in the backend (seed content), **not** the DB | compiler + endpoint tests | ### The deliberate exception: org-templates Per-organization letterhead (return address, footer, signature, margins) **is** runtime-editable in SQLite, via the org-template admin editor (WP-23/26). That is -intentional and does not contradict this ADR: it is *operational configuration* owned by an +intentional and does not contradict this ADR: it is _operational configuration_ owned by an admin persona, versioned with publish/rollback inside the app, and specific to one sub-organization's identity — not the shared business rules a wrong value would break for everyone. Stamdata (the rules and reference tables the whole register runs on) stays code. diff --git a/documentation.json b/documentation.json index 405d360..800816d 100644 --- a/documentation.json +++ b/documentation.json @@ -1711,12 +1711,12 @@ }, { "name": "ChangeCounts", - "id": "interface-ChangeCounts-44b7eb24f5c51f4d91840dd26a3a2ad06586443b868141be6eafaaa8c443df7290d73a565282282a5afa96b978457235504ac8d5afbf1921fbc1e6a62c63444f", + "id": "interface-ChangeCounts-09fc2c71465594f8e6f56fd6f37d6b3a876d4e7bfc0fbd13ebc75b877a4e5a9fa920982ff07a6ffdea3f1dc08fbde0f872f3779087d22a2453c729f5b7678da6", "file": "src/app/beheer/domain/stamdata.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "export type ColumnType = 'text' | 'date' | 'number' | 'enum';\n\nexport interface StamColumn {\n name: string;\n type: ColumnType;\n isKey: boolean;\n options: readonly string[];\n}\n\nexport interface StamTable {\n id: string;\n label: string;\n columns: readonly StamColumn[];\n temporal: boolean;\n}\n\n/**\n * An editable row: every cell is the text the user edits ('' = empty). Typed values\n * (number, null geldigTot) are reconstructed at export time — see {@link toJson}.\n */\nexport type StamRow = Record;\n\nexport interface ChangeCounts {\n added: number;\n removed: number;\n edited: number;\n}\n\nconst GELDIG_VAN = 'geldigVan';\nconst GELDIG_TOT = 'geldigTot';\n\nexport function keyColumn(table: StamTable): StamColumn {\n return table.columns.find((c) => c.isKey) ?? table.columns[0];\n}\n\nexport function keyOf(table: StamTable, row: StamRow): string {\n return row[keyColumn(table).name] ?? '';\n}\n\nexport function emptyRow(table: StamTable): StamRow {\n const row: StamRow = {};\n for (const c of table.columns) row[c.name] = '';\n return row;\n}\n\n/**\n * Valid-time membership, half-open [van, tot) — the same rule the backend applies, done\n * client-side so the editor's \"geldig op\" preview is instant and never drops unsaved edits.\n * ISO yyyy-MM-dd strings compare correctly lexicographically. Non-temporal tables: all rows.\n */\nexport function activeOn(table: StamTable, row: StamRow, on: string): boolean {\n if (!table.temporal) return true;\n const van = row[GELDIG_VAN] ?? '';\n const tot = row[GELDIG_TOT] ?? '';\n return van !== '' && van <= on && (tot === '' || on < tot);\n}\n\n/** Per-row FORMAT error (index-aligned; '' = valid). Cross-row overlap is the CI gate's job. */\nexport function rowErrors(table: StamTable, rows: readonly StamRow[]): string[] {\n const key = keyColumn(table).name;\n return rows.map((row) => {\n if ((row[key] ?? '').trim() === '')\n return $localize`:@@beheer.validation.key:Vul de sleutelkolom in.`;\n if (table.temporal) {\n const van = row[GELDIG_VAN] ?? '';\n const tot = row[GELDIG_TOT] ?? '';\n if (van === '') return $localize`:@@beheer.validation.van:Vul een 'geldig van'-datum in.`;\n if (tot !== '' && tot <= van)\n return $localize`:@@beheer.validation.range:'Geldig tot' moet ná 'geldig van' liggen.`;\n }\n return '';\n });\n}\n\nexport function isValid(table: StamTable, rows: readonly StamRow[]): boolean {\n return rowErrors(table, rows).every((e) => e === '');\n}\n\n/** Diff draft against the loaded snapshot, matched by key value. */\nexport function changeCounts(\n table: StamTable,\n original: readonly StamRow[],\n draft: readonly StamRow[],\n): ChangeCounts {\n const origByKey = new Map(original.map((r) => [keyOf(table, r), r]));\n const draftKeys = new Set(draft.map((r) => keyOf(table, r)));\n let added = 0;\n let edited = 0;\n for (const row of draft) {\n const prev = origByKey.get(keyOf(table, row));\n if (!prev) added++;\n else if (!sameRow(table, prev, row)) edited++;\n }\n const removed = original.filter((r) => !draftKeys.has(keyOf(table, r))).length;\n return { added, removed, edited };\n}\n\nfunction sameRow(table: StamTable, a: StamRow, b: StamRow): boolean {\n return table.columns.every((c) => (a[c.name] ?? '') === (b[c.name] ?? ''));\n}\n\n/**\n * Serialize the draft back to the data-file's JSON shape, reconstructing typed values per\n * column: an empty date/number cell becomes null (an open-ended geldigTot), a number cell\n * becomes a number, everything else a string. This is the file the admin drops into the\n * repo — the existing CI build re-validates it (a bad edit fails the build, never prod).\n */\nexport function toJson(table: StamTable, rows: readonly StamRow[]): string {\n const objects = rows.map((row) => {\n const out: Record = {};\n for (const c of table.columns) {\n const cell = (row[c.name] ?? '').trim();\n out[c.name] =\n cell === '' ? (c.type === 'text' || c.type === 'enum' ? '' : null)\n : c.type === 'number' ? Number(cell)\n : cell;\n }\n return out;\n });\n return JSON.stringify(objects, null, 2) + '\\n';\n}\n", + "sourceCode": "export type ColumnType = 'text' | 'date' | 'number' | 'enum';\n\nexport interface StamColumn {\n name: string;\n type: ColumnType;\n isKey: boolean;\n options: readonly string[];\n}\n\nexport interface StamTable {\n id: string;\n label: string;\n columns: readonly StamColumn[];\n temporal: boolean;\n}\n\n/**\n * An editable row: every cell is the text the user edits ('' = empty). Typed values\n * (number, null geldigTot) are reconstructed at export time — see {@link toJson}.\n */\nexport type StamRow = Record;\n\nexport interface ChangeCounts {\n added: number;\n removed: number;\n edited: number;\n}\n\nconst GELDIG_VAN = 'geldigVan';\nconst GELDIG_TOT = 'geldigTot';\n\nexport function keyColumn(table: StamTable): StamColumn {\n return table.columns.find((c) => c.isKey) ?? table.columns[0];\n}\n\nexport function keyOf(table: StamTable, row: StamRow): string {\n return row[keyColumn(table).name] ?? '';\n}\n\nexport function emptyRow(table: StamTable): StamRow {\n const row: StamRow = {};\n for (const c of table.columns) row[c.name] = '';\n return row;\n}\n\n/**\n * Valid-time membership, half-open [van, tot) — the same rule the backend applies, done\n * client-side so the editor's \"geldig op\" preview is instant and never drops unsaved edits.\n * ISO yyyy-MM-dd strings compare correctly lexicographically. Non-temporal tables: all rows.\n */\nexport function activeOn(table: StamTable, row: StamRow, on: string): boolean {\n if (!table.temporal) return true;\n const van = row[GELDIG_VAN] ?? '';\n const tot = row[GELDIG_TOT] ?? '';\n return van !== '' && van <= on && (tot === '' || on < tot);\n}\n\n/** Per-row FORMAT error (index-aligned; '' = valid). Cross-row overlap is the CI gate's job. */\nexport function rowErrors(table: StamTable, rows: readonly StamRow[]): string[] {\n const key = keyColumn(table).name;\n return rows.map((row) => {\n if ((row[key] ?? '').trim() === '')\n return $localize`:@@beheer.validation.key:Vul de sleutelkolom in.`;\n if (table.temporal) {\n const van = row[GELDIG_VAN] ?? '';\n const tot = row[GELDIG_TOT] ?? '';\n if (van === '') return $localize`:@@beheer.validation.van:Vul een 'geldig van'-datum in.`;\n if (tot !== '' && tot <= van)\n return $localize`:@@beheer.validation.range:'Geldig tot' moet ná 'geldig van' liggen.`;\n }\n return '';\n });\n}\n\nexport function isValid(table: StamTable, rows: readonly StamRow[]): boolean {\n return rowErrors(table, rows).every((e) => e === '');\n}\n\n/** Diff draft against the loaded snapshot, matched by key value. */\nexport function changeCounts(\n table: StamTable,\n original: readonly StamRow[],\n draft: readonly StamRow[],\n): ChangeCounts {\n const origByKey = new Map(original.map((r) => [keyOf(table, r), r]));\n const draftKeys = new Set(draft.map((r) => keyOf(table, r)));\n let added = 0;\n let edited = 0;\n for (const row of draft) {\n const prev = origByKey.get(keyOf(table, row));\n if (!prev) added++;\n else if (!sameRow(table, prev, row)) edited++;\n }\n const removed = original.filter((r) => !draftKeys.has(keyOf(table, r))).length;\n return { added, removed, edited };\n}\n\nfunction sameRow(table: StamTable, a: StamRow, b: StamRow): boolean {\n return table.columns.every((c) => (a[c.name] ?? '') === (b[c.name] ?? ''));\n}\n\n/**\n * Serialize the draft back to the data-file's JSON shape, reconstructing typed values per\n * column: an empty date/number cell becomes null (an open-ended geldigTot), a number cell\n * becomes a number, everything else a string. This is the file the admin drops into the\n * repo — the existing CI build re-validates it (a bad edit fails the build, never prod).\n */\nexport function toJson(table: StamTable, rows: readonly StamRow[]): string {\n const objects = rows.map((row) => {\n const out: Record = {};\n for (const c of table.columns) {\n const cell = (row[c.name] ?? '').trim();\n out[c.name] =\n cell === ''\n ? c.type === 'text' || c.type === 'enum'\n ? ''\n : null\n : c.type === 'number'\n ? Number(cell)\n : cell;\n }\n return out;\n });\n return JSON.stringify(objects, null, 2) + '\\n';\n}\n", "properties": [ { "name": "added", @@ -2127,12 +2127,12 @@ }, { "name": "DisplayRow", - "id": "interface-DisplayRow-46d5738b5376faf6de39e68f0b4c5d89d4e8f7c4d2521f52947161185f2657f08f20defd80232fcffd0d66963b8b8566c0b0f91a18774baa3ff612d6d542ce5f", + "id": "interface-DisplayRow-6a5aeaa8ec97f6f8c54ca5436c496ec1b6c8d7b400fe73d1e25136c6d9fc54b04d78f7cd27c163fa17c2c3b256207fae7d90ac95cff23a5748a89b563fcbeaba", "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 {\n ChangeCounts,\n StamColumn,\n StamRow,\n StamTable,\n activeOn,\n} 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 {{ showAll }}\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 {{ 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", "properties": [ { "name": "index", @@ -2142,7 +2142,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 13 + "line": 7 }, { "name": "row", @@ -2152,7 +2152,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 12 + "line": 6 } ], "indexSignatures": [], @@ -3861,12 +3861,12 @@ }, { "name": "LoadedTable", - "id": "interface-LoadedTable-58a283c888c4e66fccdd23a9a617e3f82d7a59c986b1470455efbb29bef1c4d6cc90a733a2d583736634675ef5a7cc3136a3ea2e9563fecd7a824992500fcd60", + "id": "interface-LoadedTable-2aae978c9ed9eacc6ada93dbd44cc40112bffdaceefaaec46f3dd97d8ef848e7ff2d96e57d74ab99f27e943725106e2991582251cac567971bfa8ed9455df7d4", "file": "src/app/beheer/infrastructure/stamdata.adapter.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "import { Injectable, inject } from '@angular/core';\nimport { Result, ok, err } from '@shared/kernel/fp';\nimport { runSubmit } from '@shared/application/submit';\nimport { ApiClient, StamdataColumnDto } from '@shared/infrastructure/api-client';\nimport { ColumnType, StamColumn, StamRow, StamTable } from '@beheer/domain/stamdata';\n\n/** A loaded table: its schema plus the rows for editing (or the peildatum-filtered view). */\nexport interface LoadedTable {\n table: StamTable;\n rows: StamRow[];\n}\n\nconst FAILED = $localize`:@@beheer.load.failed:De stamdata kon niet worden geladen.`;\nconst COLUMN_TYPES: readonly ColumnType[] = ['text', 'date', 'number', 'enum'];\n\n/**\n * The only place stamdata HTTP lives (ADR-0001 boundary). Both endpoints are reads; the\n * generic `parse*` narrows the untrusted wire shape (schema + opaque rows) into the domain\n * model. There is no write method — the edit is downloaded and lands as a PR.\n */\n@Injectable({ providedIn: 'root' })\nexport class StamdataAdapter {\n private client = inject(ApiClient);\n\n /** The tables in the catalog (schema only, no rows) — for the table switcher. */\n async list(): Promise> {\n const r = await runSubmit(() => this.client.stamdataTables(), FAILED);\n if (!r.ok) return r;\n const out: StamTable[] = [];\n for (const t of r.value ?? []) {\n const parsed = parseTable(t);\n if (!parsed.ok) return parsed;\n out.push(parsed.value);\n }\n return ok(out);\n }\n\n /** One table's schema + rows. `peildatum` (yyyy-MM-dd) asks the server for only the rows\n valid on that date; the editor uses it for a server-side cross-check, previewing\n locally for instant feedback (see `activeOn`). */\n async load(tableId: string, peildatum?: string): Promise> {\n const r = await runSubmit(() => this.client.stamdataTable(tableId, peildatum), FAILED);\n return r.ok ? parseStamdataTable(r.value) : r;\n }\n}\n\n// --- parse: wire → domain, validating at the boundary ---\n\n/** Trust-boundary parse for one table response: schema + rows → domain. Exported so its\n spec can exercise it without HTTP (the house `parse*` seam, ADR-0001). */\nexport function parseStamdataTable(dto: {\n id?: string;\n label?: string;\n columns?: StamdataColumnDto[];\n temporal?: boolean;\n rows?: readonly unknown[];\n}): Result {\n const table = parseTable(dto);\n if (!table.ok) return table;\n return ok({ table: table.value, rows: parseRows(dto.rows ?? [], table.value.columns) });\n}\n\nfunction parseColumn(dto: StamdataColumnDto): Result {\n if (typeof dto.name !== 'string' || dto.name === '') return err('stamdata column: bad name');\n const raw = dto.type ?? '';\n const type = (COLUMN_TYPES as string[]).includes(raw) ? (raw as ColumnType) : 'text';\n return ok({ name: dto.name, type, isKey: dto.isKey === true, options: dto.options ?? [] });\n}\n\nfunction parseTable(dto: { id?: string; label?: string; columns?: StamdataColumnDto[]; temporal?: boolean }): Result {\n if (typeof dto.id !== 'string' || !Array.isArray(dto.columns))\n return err('stamdata table: bad shape');\n const columns: StamColumn[] = [];\n for (const c of dto.columns) {\n const parsed = parseColumn(c);\n if (!parsed.ok) return parsed;\n columns.push(parsed.value);\n }\n if (columns.length === 0) return err('stamdata table: no columns');\n return ok({ id: dto.id, label: dto.label ?? dto.id, columns, temporal: dto.temporal === true });\n}\n\n/** Every cell becomes editable text: null → '' (open-ended), number/bool → its string form. */\nfunction parseRows(rows: readonly unknown[], columns: readonly StamColumn[]): StamRow[] {\n return rows.map((raw) => {\n const row: StamRow = {};\n const obj = (raw ?? {}) as Record;\n for (const c of columns) {\n const v = obj[c.name];\n row[c.name] = v === null || v === undefined ? '' : String(v);\n }\n return row;\n });\n}\n", + "sourceCode": "import { Injectable, inject } from '@angular/core';\nimport { Result, ok, err } from '@shared/kernel/fp';\nimport { runSubmit } from '@shared/application/submit';\nimport { ApiClient, StamdataColumnDto } from '@shared/infrastructure/api-client';\nimport { ColumnType, StamColumn, StamRow, StamTable } from '@beheer/domain/stamdata';\n\n/** A loaded table: its schema plus the rows for editing (or the peildatum-filtered view). */\nexport interface LoadedTable {\n table: StamTable;\n rows: StamRow[];\n}\n\nconst FAILED = $localize`:@@beheer.load.failed:De stamdata kon niet worden geladen.`;\nconst COLUMN_TYPES: readonly ColumnType[] = ['text', 'date', 'number', 'enum'];\n\n/**\n * The only place stamdata HTTP lives (ADR-0001 boundary). Both endpoints are reads; the\n * generic `parse*` narrows the untrusted wire shape (schema + opaque rows) into the domain\n * model. There is no write method — the edit is downloaded and lands as a PR.\n */\n@Injectable({ providedIn: 'root' })\nexport class StamdataAdapter {\n private client = inject(ApiClient);\n\n /** The tables in the catalog (schema only, no rows) — for the table switcher. */\n async list(): Promise> {\n const r = await runSubmit(() => this.client.stamdataTables(), FAILED);\n if (!r.ok) return r;\n const out: StamTable[] = [];\n for (const t of r.value ?? []) {\n const parsed = parseTable(t);\n if (!parsed.ok) return parsed;\n out.push(parsed.value);\n }\n return ok(out);\n }\n\n /** One table's schema + rows. `peildatum` (yyyy-MM-dd) asks the server for only the rows\n valid on that date; the editor uses it for a server-side cross-check, previewing\n locally for instant feedback (see `activeOn`). */\n async load(tableId: string, peildatum?: string): Promise> {\n const r = await runSubmit(() => this.client.stamdataTable(tableId, peildatum), FAILED);\n return r.ok ? parseStamdataTable(r.value) : r;\n }\n}\n\n// --- parse: wire → domain, validating at the boundary ---\n\n/** Trust-boundary parse for one table response: schema + rows → domain. Exported so its\n spec can exercise it without HTTP (the house `parse*` seam, ADR-0001). */\nexport function parseStamdataTable(dto: {\n id?: string;\n label?: string;\n columns?: StamdataColumnDto[];\n temporal?: boolean;\n rows?: readonly unknown[];\n}): Result {\n const table = parseTable(dto);\n if (!table.ok) return table;\n return ok({ table: table.value, rows: parseRows(dto.rows ?? [], table.value.columns) });\n}\n\nfunction parseColumn(dto: StamdataColumnDto): Result {\n if (typeof dto.name !== 'string' || dto.name === '') return err('stamdata column: bad name');\n const raw = dto.type ?? '';\n const type = (COLUMN_TYPES as string[]).includes(raw) ? (raw as ColumnType) : 'text';\n return ok({ name: dto.name, type, isKey: dto.isKey === true, options: dto.options ?? [] });\n}\n\nfunction parseTable(dto: {\n id?: string;\n label?: string;\n columns?: StamdataColumnDto[];\n temporal?: boolean;\n}): Result {\n if (typeof dto.id !== 'string' || !Array.isArray(dto.columns))\n return err('stamdata table: bad shape');\n const columns: StamColumn[] = [];\n for (const c of dto.columns) {\n const parsed = parseColumn(c);\n if (!parsed.ok) return parsed;\n columns.push(parsed.value);\n }\n if (columns.length === 0) return err('stamdata table: no columns');\n return ok({ id: dto.id, label: dto.label ?? dto.id, columns, temporal: dto.temporal === true });\n}\n\n/** Every cell becomes editable text: null → '' (open-ended), number/bool → its string form. */\nfunction parseRows(rows: readonly unknown[], columns: readonly StamColumn[]): StamRow[] {\n return rows.map((raw) => {\n const row: StamRow = {};\n const obj = (raw ?? {}) as Record;\n for (const c of columns) {\n const v = obj[c.name];\n row[c.name] = v === null || v === undefined ? '' : String(v);\n }\n return row;\n });\n}\n", "properties": [ { "name": "rows", @@ -4632,12 +4632,12 @@ }, { "name": "Paragraph", - "id": "interface-Paragraph-982cd282a209bd3d6214a42d1e9f8ad4098e535185973aa61cb4a606fbdb6c5b019e73120878bd2baeb372497da188f3e515c8a8c105f74bb78cb115a4c4186e", + "id": "interface-Paragraph-c9d146d453f69102e338f7c5917a771eede6cf7c865b4d08fef2baef38bbabc1e00b430b37a8595378a4369e16681c9b13547f1d0983b459307bbd2f7774af56", "file": "src/app/shared/kernel/rich-text.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "export type Mark = 'bold' | 'italic' | 'underline';\n\nexport type RichTextNode =\n | { readonly type: 'text'; readonly text: string; readonly marks?: readonly Mark[] }\n | { readonly type: 'placeholder'; readonly key: string } // resolved to a value at send\n | { readonly type: 'lineBreak' };\n\nexport interface Paragraph {\n readonly nodes: readonly RichTextNode[];\n // A line can be a plain paragraph (undefined) or an item in a bullet/numbered list.\n // Consecutive lines with the same list kind render as one
    /
      .\n readonly list?: 'bullet' | 'number';\n}\n\nexport interface RichTextBlock {\n readonly paragraphs: readonly Paragraph[];\n}\n\n/** An empty editable block is one empty paragraph — never zero paragraphs, so the\n editor always has a caret line. */\nexport function emptyBlock(): RichTextBlock {\n return { paragraphs: [{ nodes: [] }] };\n}\n\n/** True when the block carries no visible content (used for \"required section empty\"). */\nexport function isBlockEmpty(block: RichTextBlock): boolean {\n return block.paragraphs.every((p) =>\n p.nodes.every((n) => (n.type === 'text' ? n.text.trim() === '' : false)),\n );\n}\n\n/** The frozen-snapshot primitive: a deep VALUE copy of a block. Inserting a library\n passage into a letter copies its tree through here, so the letter never shares a\n reference with the library — later library edits can't mutate an existing letter. */\nexport function deepCopyBlock(block: RichTextBlock): RichTextBlock {\n // ponytail: structuredClone is exactly a deep value copy of a JSON-shaped tree;\n // a hand-rolled walk would be more code for the same result.\n return structuredClone(block) as RichTextBlock;\n}\n\n/** All visible text of a block as one lowercased string — for client-side search over\n passages. Placeholders contribute their key so \"naam\" matches a `naam_zorgverlener` chip. */\nexport function textOf(block: RichTextBlock): string {\n return block.paragraphs\n .flatMap((p) => p.nodes.map((n) => (n.type === 'text' ? n.text : n.type === 'placeholder' ? n.key : '')))\n .join(' ')\n .toLowerCase();\n}\n\n/** Every placeholder key used in a block, in document order (duplicates kept — the\n caller dedupes when it wants a set). */\nexport function placeholderKeysIn(block: RichTextBlock): string[] {\n const keys: string[] = [];\n for (const p of block.paragraphs) {\n for (const n of p.nodes) {\n if (n.type === 'placeholder') keys.push(n.key);\n }\n }\n return keys;\n}\n", + "sourceCode": "export type Mark = 'bold' | 'italic' | 'underline';\n\nexport type RichTextNode =\n | { readonly type: 'text'; readonly text: string; readonly marks?: readonly Mark[] }\n | { readonly type: 'placeholder'; readonly key: string } // resolved to a value at send\n | { readonly type: 'lineBreak' };\n\nexport interface Paragraph {\n readonly nodes: readonly RichTextNode[];\n // A line can be a plain paragraph (undefined) or an item in a bullet/numbered list.\n // Consecutive lines with the same list kind render as one
        /
          .\n readonly list?: 'bullet' | 'number';\n}\n\nexport interface RichTextBlock {\n readonly paragraphs: readonly Paragraph[];\n}\n\n/** An empty editable block is one empty paragraph — never zero paragraphs, so the\n editor always has a caret line. */\nexport function emptyBlock(): RichTextBlock {\n return { paragraphs: [{ nodes: [] }] };\n}\n\n/** True when the block carries no visible content (used for \"required section empty\"). */\nexport function isBlockEmpty(block: RichTextBlock): boolean {\n return block.paragraphs.every((p) =>\n p.nodes.every((n) => (n.type === 'text' ? n.text.trim() === '' : false)),\n );\n}\n\n/** The frozen-snapshot primitive: a deep VALUE copy of a block. Inserting a library\n passage into a letter copies its tree through here, so the letter never shares a\n reference with the library — later library edits can't mutate an existing letter. */\nexport function deepCopyBlock(block: RichTextBlock): RichTextBlock {\n // ponytail: structuredClone is exactly a deep value copy of a JSON-shaped tree;\n // a hand-rolled walk would be more code for the same result.\n return structuredClone(block) as RichTextBlock;\n}\n\n/** All visible text of a block as one lowercased string — for client-side search over\n passages. Placeholders contribute their key so \"naam\" matches a `naam_zorgverlener` chip. */\nexport function textOf(block: RichTextBlock): string {\n return block.paragraphs\n .flatMap((p) =>\n p.nodes.map((n) => (n.type === 'text' ? n.text : n.type === 'placeholder' ? n.key : '')),\n )\n .join(' ')\n .toLowerCase();\n}\n\n/** Every placeholder key used in a block, in document order (duplicates kept — the\n caller dedupes when it wants a set). */\nexport function placeholderKeysIn(block: RichTextBlock): string[] {\n const keys: string[] = [];\n for (const p of block.paragraphs) {\n for (const n of p.nodes) {\n if (n.type === 'placeholder') keys.push(n.key);\n }\n }\n return keys;\n}\n", "properties": [ { "name": "list", @@ -4708,12 +4708,12 @@ }, { "name": "PendingSave", - "id": "interface-PendingSave-2727407341bf27a69b5552e48492a92b5bb54df722ffb29bbcc0c427a84e69504b1a2f84c22487134702040754c185bd1ee35db43abfec753fa42630eeec81e8", + "id": "interface-PendingSave-ac22e9d6a135c81e975387e5d582fb8f2217c7ee1c2f45ba1391507ff4f9ae21a3182ef56c201e3ca50c2367cfe1b3faaf4ae64ad3a1dd21bc02ec4c820723aa", "file": "src/app/shared/application/pending-saves.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "import {\n DestroyRef,\n ENVIRONMENT_INITIALIZER,\n Injectable,\n inject,\n} from '@angular/core';\nimport { CanDeactivateFn } from '@angular/router';\n\n/**\n * A source of debounced, not-yet-flushed writes (autosave). The two autosave owners in\n * this app have different lifetimes — root singleton stores (`BriefStore`,\n * `OrgTemplateStore`) and per-wizard `createDraftSync` controllers living inside child\n * organisms — so both register here instead of the guard/unload handler needing to know\n * which page or store owns the pending write.\n */\nexport interface PendingSave {\n /** True while a debounced edit hasn't been written to the backend yet. */\n hasPendingSave(): boolean;\n /** Flush that pending write now and await it. No-op when nothing is pending. */\n flushPending(): Promise;\n}\n\n/** Registry of every active autosave owner. The `CanDeactivate` guard and the\n `beforeunload` handler flush through this — one seam, both callers. */\n@Injectable({ providedIn: 'root' })\nexport class PendingSaves {\n private readonly owners = new Set();\n\n /** Register an owner; returns an unregister function. */\n register(owner: PendingSave): () => void {\n this.owners.add(owner);\n return () => this.owners.delete(owner);\n }\n\n hasPending(): boolean {\n return [...this.owners].some((o) => o.hasPendingSave());\n }\n\n /** Flush every owner that has a pending write, awaiting all. Best-effort: a rejected\n flush is swallowed (a failed autosave surfaces its own error state; navigation must\n not be blocked by it). */\n async flushAll(): Promise {\n await Promise.allSettled(\n [...this.owners].filter((o) => o.hasPendingSave()).map((o) => o.flushPending()),\n );\n }\n}\n\n/** Register the current injection context's owner for the life of its `DestroyRef`.\n Call from a constructor or field initializer (root store, or `createDraftSync`). */\nexport function registerPendingSave(owner: PendingSave): void {\n const unregister = inject(PendingSaves).register(owner);\n inject(DestroyRef).onDestroy(unregister);\n}\n\n/** `CanDeactivate` guard: flush any pending debounced write before an in-app route change,\n then allow navigation. Awaitable, so the write lands before the page tears down (which\n would otherwise drop a sub-debounce edit). We never block leaving — the flush is a\n guarantee of effort, not a gate. */\nexport const flushPendingGuard: CanDeactivateFn = () => {\n const pending = inject(PendingSaves);\n return pending.hasPending() ? pending.flushAll().then(() => true) : true;\n};\n\n/** Wire a `beforeunload` handler that guards the last-mile save on a hard tab-close/reload.\n ponytail: the HTTP seam is Angular `HttpClient` (no `keepalive`/`sendBeacon`), so an\n async flush can't be guaranteed to finish as the page tears down — we fire it best-effort\n AND trigger the browser's native \"unsaved changes\" prompt, which lets the ~600ms debounce\n land if the user stays. Upgrade path: a `sendBeacon`/keepalive last-mile if this ever\n needs to be guaranteed. */\nexport function provideUnloadFlush() {\n return {\n provide: ENVIRONMENT_INITIALIZER,\n multi: true,\n useValue: () => {\n const pending = inject(PendingSaves);\n window.addEventListener('beforeunload', (e) => {\n if (!pending.hasPending()) return;\n void pending.flushAll();\n e.preventDefault();\n e.returnValue = '';\n });\n },\n };\n}\n", + "sourceCode": "import { DestroyRef, ENVIRONMENT_INITIALIZER, Injectable, inject } from '@angular/core';\nimport { CanDeactivateFn } from '@angular/router';\n\n/**\n * A source of debounced, not-yet-flushed writes (autosave). The two autosave owners in\n * this app have different lifetimes — root singleton stores (`BriefStore`,\n * `OrgTemplateStore`) and per-wizard `createDraftSync` controllers living inside child\n * organisms — so both register here instead of the guard/unload handler needing to know\n * which page or store owns the pending write.\n */\nexport interface PendingSave {\n /** True while a debounced edit hasn't been written to the backend yet. */\n hasPendingSave(): boolean;\n /** Flush that pending write now and await it. No-op when nothing is pending. */\n flushPending(): Promise;\n}\n\n/** Registry of every active autosave owner. The `CanDeactivate` guard and the\n `beforeunload` handler flush through this — one seam, both callers. */\n@Injectable({ providedIn: 'root' })\nexport class PendingSaves {\n private readonly owners = new Set();\n\n /** Register an owner; returns an unregister function. */\n register(owner: PendingSave): () => void {\n this.owners.add(owner);\n return () => this.owners.delete(owner);\n }\n\n hasPending(): boolean {\n return [...this.owners].some((o) => o.hasPendingSave());\n }\n\n /** Flush every owner that has a pending write, awaiting all. Best-effort: a rejected\n flush is swallowed (a failed autosave surfaces its own error state; navigation must\n not be blocked by it). */\n async flushAll(): Promise {\n await Promise.allSettled(\n [...this.owners].filter((o) => o.hasPendingSave()).map((o) => o.flushPending()),\n );\n }\n}\n\n/** Register the current injection context's owner for the life of its `DestroyRef`.\n Call from a constructor or field initializer (root store, or `createDraftSync`). */\nexport function registerPendingSave(owner: PendingSave): void {\n const unregister = inject(PendingSaves).register(owner);\n inject(DestroyRef).onDestroy(unregister);\n}\n\n/** `CanDeactivate` guard: flush any pending debounced write before an in-app route change,\n then allow navigation. Awaitable, so the write lands before the page tears down (which\n would otherwise drop a sub-debounce edit). We never block leaving — the flush is a\n guarantee of effort, not a gate. */\nexport const flushPendingGuard: CanDeactivateFn = () => {\n const pending = inject(PendingSaves);\n return pending.hasPending() ? pending.flushAll().then(() => true) : true;\n};\n\n/** Wire a `beforeunload` handler that guards the last-mile save on a hard tab-close/reload.\n ponytail: the HTTP seam is Angular `HttpClient` (no `keepalive`/`sendBeacon`), so an\n async flush can't be guaranteed to finish as the page tears down — we fire it best-effort\n AND trigger the browser's native \"unsaved changes\" prompt, which lets the ~600ms debounce\n land if the user stays. Upgrade path: a `sendBeacon`/keepalive last-mile if this ever\n needs to be guaranteed. */\nexport function provideUnloadFlush() {\n return {\n provide: ENVIRONMENT_INITIALIZER,\n multi: true,\n useValue: () => {\n const pending = inject(PendingSaves);\n window.addEventListener('beforeunload', (e) => {\n if (!pending.hasPending()) return;\n void pending.flushAll();\n e.preventDefault();\n e.returnValue = '';\n });\n },\n };\n}\n", "properties": [], "indexSignatures": [], "kind": 174, @@ -4726,7 +4726,7 @@ "optional": false, "returnType": "Promise", "typeParameters": [], - "line": 20, + "line": 15, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nFlush that pending write now and await it. No-op when nothing is pending.", @@ -4738,7 +4738,7 @@ "optional": false, "returnType": "boolean", "typeParameters": [], - "line": 18, + "line": 13, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nTrue while a debounced edit hasn't been written to the backend yet.", @@ -5436,12 +5436,12 @@ }, { "name": "Reden", - "id": "interface-Reden-b56620a59c7199d9c724510a7cfdb8b083a4bb3a3c19d2bd538cdf0976fa6e1aa47aa0c5483d2c9b3968159097ead3614f04c33b51968eb1e023f37b707b9d15", + "id": "interface-Reden-eafc3b7f2b6aa96733eda2b73f5eae68d5c6a2e6d48e23f6f212caee8ae89418850ad3b76624cdcf09f761f820a08e3368a8d8649b94249d9a25edc1fc91ae38", "file": "src/app/brief/domain/besluit.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "import { Besluit, LetterBlock, LibraryPassage } from './brief';\n\n/**\n * Guided drafting: given the behandelaar's besluit + chosen redenen, which library\n * passages belong in the kern. This is the \"don't make them a detective\" logic —\n * pure, so it's unit-tested directly and the UI just renders the result.\n *\n * A passage is offered when:\n * - it has no besluit tag (a shared intro/toelichting, relevant to any besluit), OR\n * - its besluit matches AND either it isn't reason-specific, or its reason is ticked.\n *\n * Kept in library order (server order = reading order), so an inserted set already\n * flows as a letter.\n */\nexport function passagesForBesluit(\n passages: readonly LibraryPassage[],\n besluit: Besluit,\n reasons: readonly string[],\n): LibraryPassage[] {\n return passages.filter((p) => {\n if (p.sectionKey !== 'kern') return false;\n if (p.besluit === undefined) return true; // shared, any besluit\n if (p.besluit !== besluit) return false;\n if (p.reason === undefined) return true; // besluit-level, not reason-specific\n return reasons.includes(p.reason);\n });\n}\n\n/** A selectable reden for a besluit, derived from the reason-specific passages — no\n separate catalog. `code` drives `passagesForBesluit`; `label` is the checkbox text.\n ponytail: assumes one passage per reason (true for the seed); dedupes on code if not. */\nexport interface Reden {\n readonly code: string;\n readonly label: string;\n}\n\nexport function redenenFor(passages: readonly LibraryPassage[], besluit: Besluit): Reden[] {\n const seen = new Set();\n const out: Reden[] = [];\n for (const p of passages) {\n if (p.sectionKey !== 'kern' || p.besluit !== besluit || p.reason === undefined) continue;\n if (seen.has(p.reason)) continue;\n seen.add(p.reason);\n out.push({ code: p.reason, label: p.label });\n }\n return out;\n}\n\n/**\n * The inverse of `passagesForBesluit`: read the current besluit + redenen back off the\n * kern's passage blocks (each carries its `sourcePassageId`), so the panel can re-seed\n * itself on reload/undo without persisting the selection separately. Kern passage blocks\n * are besluit-derived by construction (the only way passages enter the kern), so this\n * round-trips: `inferSelection(kern(passagesForBesluit(lib, b, r)), lib) === { b, r }`.\n * Free-text blocks carry no provenance and are ignored.\n */\nexport function inferSelection(\n kernBlocks: readonly LetterBlock[],\n passages: readonly LibraryPassage[],\n): { besluit: Besluit | null; reasons: string[] } {\n const byId = new Map(passages.map((p) => [p.passageId, p]));\n let besluit: Besluit | null = null;\n const reasons: string[] = [];\n for (const b of kernBlocks) {\n if (b.type !== 'passage') continue;\n const source = byId.get(b.sourcePassageId);\n if (!source) continue;\n if (source.besluit !== undefined) besluit = source.besluit;\n if (source.reason !== undefined && !reasons.includes(source.reason)) reasons.push(source.reason);\n }\n return { besluit, reasons };\n}\n", + "sourceCode": "import { Besluit, LetterBlock, LibraryPassage } from './brief';\n\n/**\n * Guided drafting: given the behandelaar's besluit + chosen redenen, which library\n * passages belong in the kern. This is the \"don't make them a detective\" logic —\n * pure, so it's unit-tested directly and the UI just renders the result.\n *\n * A passage is offered when:\n * - it has no besluit tag (a shared intro/toelichting, relevant to any besluit), OR\n * - its besluit matches AND either it isn't reason-specific, or its reason is ticked.\n *\n * Kept in library order (server order = reading order), so an inserted set already\n * flows as a letter.\n */\nexport function passagesForBesluit(\n passages: readonly LibraryPassage[],\n besluit: Besluit,\n reasons: readonly string[],\n): LibraryPassage[] {\n return passages.filter((p) => {\n if (p.sectionKey !== 'kern') return false;\n if (p.besluit === undefined) return true; // shared, any besluit\n if (p.besluit !== besluit) return false;\n if (p.reason === undefined) return true; // besluit-level, not reason-specific\n return reasons.includes(p.reason);\n });\n}\n\n/** A selectable reden for a besluit, derived from the reason-specific passages — no\n separate catalog. `code` drives `passagesForBesluit`; `label` is the checkbox text.\n ponytail: assumes one passage per reason (true for the seed); dedupes on code if not. */\nexport interface Reden {\n readonly code: string;\n readonly label: string;\n}\n\nexport function redenenFor(passages: readonly LibraryPassage[], besluit: Besluit): Reden[] {\n const seen = new Set();\n const out: Reden[] = [];\n for (const p of passages) {\n if (p.sectionKey !== 'kern' || p.besluit !== besluit || p.reason === undefined) continue;\n if (seen.has(p.reason)) continue;\n seen.add(p.reason);\n out.push({ code: p.reason, label: p.label });\n }\n return out;\n}\n\n/**\n * The inverse of `passagesForBesluit`: read the current besluit + redenen back off the\n * kern's passage blocks (each carries its `sourcePassageId`), so the panel can re-seed\n * itself on reload/undo without persisting the selection separately. Kern passage blocks\n * are besluit-derived by construction (the only way passages enter the kern), so this\n * round-trips: `inferSelection(kern(passagesForBesluit(lib, b, r)), lib) === { b, r }`.\n * Free-text blocks carry no provenance and are ignored.\n */\nexport function inferSelection(\n kernBlocks: readonly LetterBlock[],\n passages: readonly LibraryPassage[],\n): { besluit: Besluit | null; reasons: string[] } {\n const byId = new Map(passages.map((p) => [p.passageId, p]));\n let besluit: Besluit | null = null;\n const reasons: string[] = [];\n for (const b of kernBlocks) {\n if (b.type !== 'passage') continue;\n const source = byId.get(b.sourcePassageId);\n if (!source) continue;\n if (source.besluit !== undefined) besluit = source.besluit;\n if (source.reason !== undefined && !reasons.includes(source.reason))\n reasons.push(source.reason);\n }\n return { besluit, reasons };\n}\n", "properties": [ { "name": "code", @@ -5857,12 +5857,12 @@ }, { "name": "RichTextBlock", - "id": "interface-RichTextBlock-982cd282a209bd3d6214a42d1e9f8ad4098e535185973aa61cb4a606fbdb6c5b019e73120878bd2baeb372497da188f3e515c8a8c105f74bb78cb115a4c4186e", + "id": "interface-RichTextBlock-c9d146d453f69102e338f7c5917a771eede6cf7c865b4d08fef2baef38bbabc1e00b430b37a8595378a4369e16681c9b13547f1d0983b459307bbd2f7774af56", "file": "src/app/shared/kernel/rich-text.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "export type Mark = 'bold' | 'italic' | 'underline';\n\nexport type RichTextNode =\n | { readonly type: 'text'; readonly text: string; readonly marks?: readonly Mark[] }\n | { readonly type: 'placeholder'; readonly key: string } // resolved to a value at send\n | { readonly type: 'lineBreak' };\n\nexport interface Paragraph {\n readonly nodes: readonly RichTextNode[];\n // A line can be a plain paragraph (undefined) or an item in a bullet/numbered list.\n // Consecutive lines with the same list kind render as one
            /
              .\n readonly list?: 'bullet' | 'number';\n}\n\nexport interface RichTextBlock {\n readonly paragraphs: readonly Paragraph[];\n}\n\n/** An empty editable block is one empty paragraph — never zero paragraphs, so the\n editor always has a caret line. */\nexport function emptyBlock(): RichTextBlock {\n return { paragraphs: [{ nodes: [] }] };\n}\n\n/** True when the block carries no visible content (used for \"required section empty\"). */\nexport function isBlockEmpty(block: RichTextBlock): boolean {\n return block.paragraphs.every((p) =>\n p.nodes.every((n) => (n.type === 'text' ? n.text.trim() === '' : false)),\n );\n}\n\n/** The frozen-snapshot primitive: a deep VALUE copy of a block. Inserting a library\n passage into a letter copies its tree through here, so the letter never shares a\n reference with the library — later library edits can't mutate an existing letter. */\nexport function deepCopyBlock(block: RichTextBlock): RichTextBlock {\n // ponytail: structuredClone is exactly a deep value copy of a JSON-shaped tree;\n // a hand-rolled walk would be more code for the same result.\n return structuredClone(block) as RichTextBlock;\n}\n\n/** All visible text of a block as one lowercased string — for client-side search over\n passages. Placeholders contribute their key so \"naam\" matches a `naam_zorgverlener` chip. */\nexport function textOf(block: RichTextBlock): string {\n return block.paragraphs\n .flatMap((p) => p.nodes.map((n) => (n.type === 'text' ? n.text : n.type === 'placeholder' ? n.key : '')))\n .join(' ')\n .toLowerCase();\n}\n\n/** Every placeholder key used in a block, in document order (duplicates kept — the\n caller dedupes when it wants a set). */\nexport function placeholderKeysIn(block: RichTextBlock): string[] {\n const keys: string[] = [];\n for (const p of block.paragraphs) {\n for (const n of p.nodes) {\n if (n.type === 'placeholder') keys.push(n.key);\n }\n }\n return keys;\n}\n", + "sourceCode": "export type Mark = 'bold' | 'italic' | 'underline';\n\nexport type RichTextNode =\n | { readonly type: 'text'; readonly text: string; readonly marks?: readonly Mark[] }\n | { readonly type: 'placeholder'; readonly key: string } // resolved to a value at send\n | { readonly type: 'lineBreak' };\n\nexport interface Paragraph {\n readonly nodes: readonly RichTextNode[];\n // A line can be a plain paragraph (undefined) or an item in a bullet/numbered list.\n // Consecutive lines with the same list kind render as one
                /
                  .\n readonly list?: 'bullet' | 'number';\n}\n\nexport interface RichTextBlock {\n readonly paragraphs: readonly Paragraph[];\n}\n\n/** An empty editable block is one empty paragraph — never zero paragraphs, so the\n editor always has a caret line. */\nexport function emptyBlock(): RichTextBlock {\n return { paragraphs: [{ nodes: [] }] };\n}\n\n/** True when the block carries no visible content (used for \"required section empty\"). */\nexport function isBlockEmpty(block: RichTextBlock): boolean {\n return block.paragraphs.every((p) =>\n p.nodes.every((n) => (n.type === 'text' ? n.text.trim() === '' : false)),\n );\n}\n\n/** The frozen-snapshot primitive: a deep VALUE copy of a block. Inserting a library\n passage into a letter copies its tree through here, so the letter never shares a\n reference with the library — later library edits can't mutate an existing letter. */\nexport function deepCopyBlock(block: RichTextBlock): RichTextBlock {\n // ponytail: structuredClone is exactly a deep value copy of a JSON-shaped tree;\n // a hand-rolled walk would be more code for the same result.\n return structuredClone(block) as RichTextBlock;\n}\n\n/** All visible text of a block as one lowercased string — for client-side search over\n passages. Placeholders contribute their key so \"naam\" matches a `naam_zorgverlener` chip. */\nexport function textOf(block: RichTextBlock): string {\n return block.paragraphs\n .flatMap((p) =>\n p.nodes.map((n) => (n.type === 'text' ? n.text : n.type === 'placeholder' ? n.key : '')),\n )\n .join(' ')\n .toLowerCase();\n}\n\n/** Every placeholder key used in a block, in document order (duplicates kept — the\n caller dedupes when it wants a set). */\nexport function placeholderKeysIn(block: RichTextBlock): string[] {\n const keys: string[] = [];\n for (const p of block.paragraphs) {\n for (const n of p.nodes) {\n if (n.type === 'placeholder') keys.push(n.key);\n }\n }\n return keys;\n}\n", "properties": [ { "name": "paragraphs", @@ -6099,12 +6099,12 @@ }, { "name": "StamColumn", - "id": "interface-StamColumn-44b7eb24f5c51f4d91840dd26a3a2ad06586443b868141be6eafaaa8c443df7290d73a565282282a5afa96b978457235504ac8d5afbf1921fbc1e6a62c63444f", + "id": "interface-StamColumn-09fc2c71465594f8e6f56fd6f37d6b3a876d4e7bfc0fbd13ebc75b877a4e5a9fa920982ff07a6ffdea3f1dc08fbde0f872f3779087d22a2453c729f5b7678da6", "file": "src/app/beheer/domain/stamdata.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "export type ColumnType = 'text' | 'date' | 'number' | 'enum';\n\nexport interface StamColumn {\n name: string;\n type: ColumnType;\n isKey: boolean;\n options: readonly string[];\n}\n\nexport interface StamTable {\n id: string;\n label: string;\n columns: readonly StamColumn[];\n temporal: boolean;\n}\n\n/**\n * An editable row: every cell is the text the user edits ('' = empty). Typed values\n * (number, null geldigTot) are reconstructed at export time — see {@link toJson}.\n */\nexport type StamRow = Record;\n\nexport interface ChangeCounts {\n added: number;\n removed: number;\n edited: number;\n}\n\nconst GELDIG_VAN = 'geldigVan';\nconst GELDIG_TOT = 'geldigTot';\n\nexport function keyColumn(table: StamTable): StamColumn {\n return table.columns.find((c) => c.isKey) ?? table.columns[0];\n}\n\nexport function keyOf(table: StamTable, row: StamRow): string {\n return row[keyColumn(table).name] ?? '';\n}\n\nexport function emptyRow(table: StamTable): StamRow {\n const row: StamRow = {};\n for (const c of table.columns) row[c.name] = '';\n return row;\n}\n\n/**\n * Valid-time membership, half-open [van, tot) — the same rule the backend applies, done\n * client-side so the editor's \"geldig op\" preview is instant and never drops unsaved edits.\n * ISO yyyy-MM-dd strings compare correctly lexicographically. Non-temporal tables: all rows.\n */\nexport function activeOn(table: StamTable, row: StamRow, on: string): boolean {\n if (!table.temporal) return true;\n const van = row[GELDIG_VAN] ?? '';\n const tot = row[GELDIG_TOT] ?? '';\n return van !== '' && van <= on && (tot === '' || on < tot);\n}\n\n/** Per-row FORMAT error (index-aligned; '' = valid). Cross-row overlap is the CI gate's job. */\nexport function rowErrors(table: StamTable, rows: readonly StamRow[]): string[] {\n const key = keyColumn(table).name;\n return rows.map((row) => {\n if ((row[key] ?? '').trim() === '')\n return $localize`:@@beheer.validation.key:Vul de sleutelkolom in.`;\n if (table.temporal) {\n const van = row[GELDIG_VAN] ?? '';\n const tot = row[GELDIG_TOT] ?? '';\n if (van === '') return $localize`:@@beheer.validation.van:Vul een 'geldig van'-datum in.`;\n if (tot !== '' && tot <= van)\n return $localize`:@@beheer.validation.range:'Geldig tot' moet ná 'geldig van' liggen.`;\n }\n return '';\n });\n}\n\nexport function isValid(table: StamTable, rows: readonly StamRow[]): boolean {\n return rowErrors(table, rows).every((e) => e === '');\n}\n\n/** Diff draft against the loaded snapshot, matched by key value. */\nexport function changeCounts(\n table: StamTable,\n original: readonly StamRow[],\n draft: readonly StamRow[],\n): ChangeCounts {\n const origByKey = new Map(original.map((r) => [keyOf(table, r), r]));\n const draftKeys = new Set(draft.map((r) => keyOf(table, r)));\n let added = 0;\n let edited = 0;\n for (const row of draft) {\n const prev = origByKey.get(keyOf(table, row));\n if (!prev) added++;\n else if (!sameRow(table, prev, row)) edited++;\n }\n const removed = original.filter((r) => !draftKeys.has(keyOf(table, r))).length;\n return { added, removed, edited };\n}\n\nfunction sameRow(table: StamTable, a: StamRow, b: StamRow): boolean {\n return table.columns.every((c) => (a[c.name] ?? '') === (b[c.name] ?? ''));\n}\n\n/**\n * Serialize the draft back to the data-file's JSON shape, reconstructing typed values per\n * column: an empty date/number cell becomes null (an open-ended geldigTot), a number cell\n * becomes a number, everything else a string. This is the file the admin drops into the\n * repo — the existing CI build re-validates it (a bad edit fails the build, never prod).\n */\nexport function toJson(table: StamTable, rows: readonly StamRow[]): string {\n const objects = rows.map((row) => {\n const out: Record = {};\n for (const c of table.columns) {\n const cell = (row[c.name] ?? '').trim();\n out[c.name] =\n cell === '' ? (c.type === 'text' || c.type === 'enum' ? '' : null)\n : c.type === 'number' ? Number(cell)\n : cell;\n }\n return out;\n });\n return JSON.stringify(objects, null, 2) + '\\n';\n}\n", + "sourceCode": "export type ColumnType = 'text' | 'date' | 'number' | 'enum';\n\nexport interface StamColumn {\n name: string;\n type: ColumnType;\n isKey: boolean;\n options: readonly string[];\n}\n\nexport interface StamTable {\n id: string;\n label: string;\n columns: readonly StamColumn[];\n temporal: boolean;\n}\n\n/**\n * An editable row: every cell is the text the user edits ('' = empty). Typed values\n * (number, null geldigTot) are reconstructed at export time — see {@link toJson}.\n */\nexport type StamRow = Record;\n\nexport interface ChangeCounts {\n added: number;\n removed: number;\n edited: number;\n}\n\nconst GELDIG_VAN = 'geldigVan';\nconst GELDIG_TOT = 'geldigTot';\n\nexport function keyColumn(table: StamTable): StamColumn {\n return table.columns.find((c) => c.isKey) ?? table.columns[0];\n}\n\nexport function keyOf(table: StamTable, row: StamRow): string {\n return row[keyColumn(table).name] ?? '';\n}\n\nexport function emptyRow(table: StamTable): StamRow {\n const row: StamRow = {};\n for (const c of table.columns) row[c.name] = '';\n return row;\n}\n\n/**\n * Valid-time membership, half-open [van, tot) — the same rule the backend applies, done\n * client-side so the editor's \"geldig op\" preview is instant and never drops unsaved edits.\n * ISO yyyy-MM-dd strings compare correctly lexicographically. Non-temporal tables: all rows.\n */\nexport function activeOn(table: StamTable, row: StamRow, on: string): boolean {\n if (!table.temporal) return true;\n const van = row[GELDIG_VAN] ?? '';\n const tot = row[GELDIG_TOT] ?? '';\n return van !== '' && van <= on && (tot === '' || on < tot);\n}\n\n/** Per-row FORMAT error (index-aligned; '' = valid). Cross-row overlap is the CI gate's job. */\nexport function rowErrors(table: StamTable, rows: readonly StamRow[]): string[] {\n const key = keyColumn(table).name;\n return rows.map((row) => {\n if ((row[key] ?? '').trim() === '')\n return $localize`:@@beheer.validation.key:Vul de sleutelkolom in.`;\n if (table.temporal) {\n const van = row[GELDIG_VAN] ?? '';\n const tot = row[GELDIG_TOT] ?? '';\n if (van === '') return $localize`:@@beheer.validation.van:Vul een 'geldig van'-datum in.`;\n if (tot !== '' && tot <= van)\n return $localize`:@@beheer.validation.range:'Geldig tot' moet ná 'geldig van' liggen.`;\n }\n return '';\n });\n}\n\nexport function isValid(table: StamTable, rows: readonly StamRow[]): boolean {\n return rowErrors(table, rows).every((e) => e === '');\n}\n\n/** Diff draft against the loaded snapshot, matched by key value. */\nexport function changeCounts(\n table: StamTable,\n original: readonly StamRow[],\n draft: readonly StamRow[],\n): ChangeCounts {\n const origByKey = new Map(original.map((r) => [keyOf(table, r), r]));\n const draftKeys = new Set(draft.map((r) => keyOf(table, r)));\n let added = 0;\n let edited = 0;\n for (const row of draft) {\n const prev = origByKey.get(keyOf(table, row));\n if (!prev) added++;\n else if (!sameRow(table, prev, row)) edited++;\n }\n const removed = original.filter((r) => !draftKeys.has(keyOf(table, r))).length;\n return { added, removed, edited };\n}\n\nfunction sameRow(table: StamTable, a: StamRow, b: StamRow): boolean {\n return table.columns.every((c) => (a[c.name] ?? '') === (b[c.name] ?? ''));\n}\n\n/**\n * Serialize the draft back to the data-file's JSON shape, reconstructing typed values per\n * column: an empty date/number cell becomes null (an open-ended geldigTot), a number cell\n * becomes a number, everything else a string. This is the file the admin drops into the\n * repo — the existing CI build re-validates it (a bad edit fails the build, never prod).\n */\nexport function toJson(table: StamTable, rows: readonly StamRow[]): string {\n const objects = rows.map((row) => {\n const out: Record = {};\n for (const c of table.columns) {\n const cell = (row[c.name] ?? '').trim();\n out[c.name] =\n cell === ''\n ? c.type === 'text' || c.type === 'enum'\n ? ''\n : null\n : c.type === 'number'\n ? Number(cell)\n : cell;\n }\n return out;\n });\n return JSON.stringify(objects, null, 2) + '\\n';\n}\n", "properties": [ { "name": "isKey", @@ -6513,12 +6513,12 @@ }, { "name": "StamTable", - "id": "interface-StamTable-44b7eb24f5c51f4d91840dd26a3a2ad06586443b868141be6eafaaa8c443df7290d73a565282282a5afa96b978457235504ac8d5afbf1921fbc1e6a62c63444f", + "id": "interface-StamTable-09fc2c71465594f8e6f56fd6f37d6b3a876d4e7bfc0fbd13ebc75b877a4e5a9fa920982ff07a6ffdea3f1dc08fbde0f872f3779087d22a2453c729f5b7678da6", "file": "src/app/beheer/domain/stamdata.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "export type ColumnType = 'text' | 'date' | 'number' | 'enum';\n\nexport interface StamColumn {\n name: string;\n type: ColumnType;\n isKey: boolean;\n options: readonly string[];\n}\n\nexport interface StamTable {\n id: string;\n label: string;\n columns: readonly StamColumn[];\n temporal: boolean;\n}\n\n/**\n * An editable row: every cell is the text the user edits ('' = empty). Typed values\n * (number, null geldigTot) are reconstructed at export time — see {@link toJson}.\n */\nexport type StamRow = Record;\n\nexport interface ChangeCounts {\n added: number;\n removed: number;\n edited: number;\n}\n\nconst GELDIG_VAN = 'geldigVan';\nconst GELDIG_TOT = 'geldigTot';\n\nexport function keyColumn(table: StamTable): StamColumn {\n return table.columns.find((c) => c.isKey) ?? table.columns[0];\n}\n\nexport function keyOf(table: StamTable, row: StamRow): string {\n return row[keyColumn(table).name] ?? '';\n}\n\nexport function emptyRow(table: StamTable): StamRow {\n const row: StamRow = {};\n for (const c of table.columns) row[c.name] = '';\n return row;\n}\n\n/**\n * Valid-time membership, half-open [van, tot) — the same rule the backend applies, done\n * client-side so the editor's \"geldig op\" preview is instant and never drops unsaved edits.\n * ISO yyyy-MM-dd strings compare correctly lexicographically. Non-temporal tables: all rows.\n */\nexport function activeOn(table: StamTable, row: StamRow, on: string): boolean {\n if (!table.temporal) return true;\n const van = row[GELDIG_VAN] ?? '';\n const tot = row[GELDIG_TOT] ?? '';\n return van !== '' && van <= on && (tot === '' || on < tot);\n}\n\n/** Per-row FORMAT error (index-aligned; '' = valid). Cross-row overlap is the CI gate's job. */\nexport function rowErrors(table: StamTable, rows: readonly StamRow[]): string[] {\n const key = keyColumn(table).name;\n return rows.map((row) => {\n if ((row[key] ?? '').trim() === '')\n return $localize`:@@beheer.validation.key:Vul de sleutelkolom in.`;\n if (table.temporal) {\n const van = row[GELDIG_VAN] ?? '';\n const tot = row[GELDIG_TOT] ?? '';\n if (van === '') return $localize`:@@beheer.validation.van:Vul een 'geldig van'-datum in.`;\n if (tot !== '' && tot <= van)\n return $localize`:@@beheer.validation.range:'Geldig tot' moet ná 'geldig van' liggen.`;\n }\n return '';\n });\n}\n\nexport function isValid(table: StamTable, rows: readonly StamRow[]): boolean {\n return rowErrors(table, rows).every((e) => e === '');\n}\n\n/** Diff draft against the loaded snapshot, matched by key value. */\nexport function changeCounts(\n table: StamTable,\n original: readonly StamRow[],\n draft: readonly StamRow[],\n): ChangeCounts {\n const origByKey = new Map(original.map((r) => [keyOf(table, r), r]));\n const draftKeys = new Set(draft.map((r) => keyOf(table, r)));\n let added = 0;\n let edited = 0;\n for (const row of draft) {\n const prev = origByKey.get(keyOf(table, row));\n if (!prev) added++;\n else if (!sameRow(table, prev, row)) edited++;\n }\n const removed = original.filter((r) => !draftKeys.has(keyOf(table, r))).length;\n return { added, removed, edited };\n}\n\nfunction sameRow(table: StamTable, a: StamRow, b: StamRow): boolean {\n return table.columns.every((c) => (a[c.name] ?? '') === (b[c.name] ?? ''));\n}\n\n/**\n * Serialize the draft back to the data-file's JSON shape, reconstructing typed values per\n * column: an empty date/number cell becomes null (an open-ended geldigTot), a number cell\n * becomes a number, everything else a string. This is the file the admin drops into the\n * repo — the existing CI build re-validates it (a bad edit fails the build, never prod).\n */\nexport function toJson(table: StamTable, rows: readonly StamRow[]): string {\n const objects = rows.map((row) => {\n const out: Record = {};\n for (const c of table.columns) {\n const cell = (row[c.name] ?? '').trim();\n out[c.name] =\n cell === '' ? (c.type === 'text' || c.type === 'enum' ? '' : null)\n : c.type === 'number' ? Number(cell)\n : cell;\n }\n return out;\n });\n return JSON.stringify(objects, null, 2) + '\\n';\n}\n", + "sourceCode": "export type ColumnType = 'text' | 'date' | 'number' | 'enum';\n\nexport interface StamColumn {\n name: string;\n type: ColumnType;\n isKey: boolean;\n options: readonly string[];\n}\n\nexport interface StamTable {\n id: string;\n label: string;\n columns: readonly StamColumn[];\n temporal: boolean;\n}\n\n/**\n * An editable row: every cell is the text the user edits ('' = empty). Typed values\n * (number, null geldigTot) are reconstructed at export time — see {@link toJson}.\n */\nexport type StamRow = Record;\n\nexport interface ChangeCounts {\n added: number;\n removed: number;\n edited: number;\n}\n\nconst GELDIG_VAN = 'geldigVan';\nconst GELDIG_TOT = 'geldigTot';\n\nexport function keyColumn(table: StamTable): StamColumn {\n return table.columns.find((c) => c.isKey) ?? table.columns[0];\n}\n\nexport function keyOf(table: StamTable, row: StamRow): string {\n return row[keyColumn(table).name] ?? '';\n}\n\nexport function emptyRow(table: StamTable): StamRow {\n const row: StamRow = {};\n for (const c of table.columns) row[c.name] = '';\n return row;\n}\n\n/**\n * Valid-time membership, half-open [van, tot) — the same rule the backend applies, done\n * client-side so the editor's \"geldig op\" preview is instant and never drops unsaved edits.\n * ISO yyyy-MM-dd strings compare correctly lexicographically. Non-temporal tables: all rows.\n */\nexport function activeOn(table: StamTable, row: StamRow, on: string): boolean {\n if (!table.temporal) return true;\n const van = row[GELDIG_VAN] ?? '';\n const tot = row[GELDIG_TOT] ?? '';\n return van !== '' && van <= on && (tot === '' || on < tot);\n}\n\n/** Per-row FORMAT error (index-aligned; '' = valid). Cross-row overlap is the CI gate's job. */\nexport function rowErrors(table: StamTable, rows: readonly StamRow[]): string[] {\n const key = keyColumn(table).name;\n return rows.map((row) => {\n if ((row[key] ?? '').trim() === '')\n return $localize`:@@beheer.validation.key:Vul de sleutelkolom in.`;\n if (table.temporal) {\n const van = row[GELDIG_VAN] ?? '';\n const tot = row[GELDIG_TOT] ?? '';\n if (van === '') return $localize`:@@beheer.validation.van:Vul een 'geldig van'-datum in.`;\n if (tot !== '' && tot <= van)\n return $localize`:@@beheer.validation.range:'Geldig tot' moet ná 'geldig van' liggen.`;\n }\n return '';\n });\n}\n\nexport function isValid(table: StamTable, rows: readonly StamRow[]): boolean {\n return rowErrors(table, rows).every((e) => e === '');\n}\n\n/** Diff draft against the loaded snapshot, matched by key value. */\nexport function changeCounts(\n table: StamTable,\n original: readonly StamRow[],\n draft: readonly StamRow[],\n): ChangeCounts {\n const origByKey = new Map(original.map((r) => [keyOf(table, r), r]));\n const draftKeys = new Set(draft.map((r) => keyOf(table, r)));\n let added = 0;\n let edited = 0;\n for (const row of draft) {\n const prev = origByKey.get(keyOf(table, row));\n if (!prev) added++;\n else if (!sameRow(table, prev, row)) edited++;\n }\n const removed = original.filter((r) => !draftKeys.has(keyOf(table, r))).length;\n return { added, removed, edited };\n}\n\nfunction sameRow(table: StamTable, a: StamRow, b: StamRow): boolean {\n return table.columns.every((c) => (a[c.name] ?? '') === (b[c.name] ?? ''));\n}\n\n/**\n * Serialize the draft back to the data-file's JSON shape, reconstructing typed values per\n * column: an empty date/number cell becomes null (an open-ended geldigTot), a number cell\n * becomes a number, everything else a string. This is the file the admin drops into the\n * repo — the existing CI build re-validates it (a bad edit fails the build, never prod).\n */\nexport function toJson(table: StamTable, rows: readonly StamRow[]): string {\n const objects = rows.map((row) => {\n const out: Record = {};\n for (const c of table.columns) {\n const cell = (row[c.name] ?? '').trim();\n out[c.name] =\n cell === ''\n ? c.type === 'text' || c.type === 'enum'\n ? ''\n : null\n : c.type === 'number'\n ? Number(cell)\n : cell;\n }\n return out;\n });\n return JSON.stringify(objects, null, 2) + '\\n';\n}\n", "properties": [ { "name": "columns", @@ -10038,7 +10038,7 @@ }, { "name": "OrgTemplateAdapter", - "id": "injectable-OrgTemplateAdapter-9fde78e9a74485a20d4386c3e131e81cf8906dfc64348cb6e67a2893c5a506cda17cfa506791fb85d6fb95442bf94802bc27279c9f6571f3a71bcb7f452a80c3", + "id": "injectable-OrgTemplateAdapter-b93b88798116c968fb69043a274c376c6807a311e33e7230343abe2de57833fe22ec6f646b7bfa672ef78f8ab0f73f9e6fd2da315110f101af68285f74f207b7", "file": "src/app/brief/infrastructure/org-template.adapter.ts", "properties": [ { @@ -10120,7 +10120,7 @@ "optional": false, "returnType": "Promise>", "typeParameters": [], - "line": 77, + "line": 74, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nProefbrief: the unpublished draft rendered over a fixture letter, opened as a Blob.", @@ -10290,13 +10290,13 @@ "deprecationMessage": "", "description": "", "rawdescription": "\n", - "sourceCode": "import { Injectable, inject } from '@angular/core';\nimport { Result, ok, err } from '@shared/kernel/fp';\nimport { runSubmit } from '@shared/application/submit';\nimport { currentRole } from '@shared/infrastructure/role';\nimport { problemDetail } from '@shared/infrastructure/api-error';\nimport { environment } from '../../../environments/environment';\nimport {\n ApiClient,\n OrgTemplateAdminViewDto,\n OrgTemplateDto,\n OrgTemplateVersionDto,\n PublishOrgTemplateResponse,\n SubOrgSummaryDto,\n} from '@shared/infrastructure/api-client';\nimport {\n OrgTemplate,\n OrgTemplateAdminView,\n OrgTemplateVersion,\n PublishResult,\n SubOrgSummary,\n} from '@brief/domain/org-template';\nimport { parseOrgTemplate } from '@brief/infrastructure/brief.adapter';\n\n/**\n * The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/\n * rollback 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`.\n */\n\nconst FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;\nconst PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;\n\n@Injectable({ providedIn: 'root' })\nexport class OrgTemplateAdapter {\n private client = inject(ApiClient);\n\n async list(): Promise> {\n const r = await runSubmit(() => this.client.orgTemplates(), FAILED);\n if (!r.ok) return r;\n const out: SubOrgSummary[] = [];\n for (const s of r.value ?? []) {\n const parsed = parseSubOrg(s);\n if (!parsed.ok) return parsed;\n out.push(parsed.value);\n }\n return ok(out);\n }\n\n async load(subOrgId: string): Promise> {\n const r = await runSubmit(() => this.client.orgTemplateGET(subOrgId), FAILED);\n return r.ok ? parseAdminView(r.value) : r;\n }\n\n async save(subOrgId: string, draft: OrgTemplate): Promise> {\n const r = await runSubmit(\n () => this.client.orgTemplatePUT(subOrgId, { draft: toDto(draft) }),\n FAILED,\n );\n return r.ok ? parseAdminView(r.value) : r;\n }\n\n async publish(subOrgId: string): Promise> {\n const r = await runSubmit(() => this.client.orgTemplatePublish(subOrgId), FAILED);\n return r.ok ? parsePublish(r.value) : r;\n }\n\n async rollback(\n subOrgId: string,\n version: number,\n ): Promise> {\n const r = await runSubmit(() => this.client.orgTemplateRollback(subOrgId, version), FAILED);\n return r.ok ? parseAdminView(r.value) : r;\n }\n\n /** Proefbrief: the unpublished draft rendered over a fixture letter, opened as a Blob. */\n async proefbrief(subOrgId: string): Promise> {\n let res: Response;\n try {\n res = await fetch(\n `${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,\n { headers: { 'X-Role': currentRole() } },\n );\n } catch {\n return err(PROEFBRIEF_FAILED);\n }\n if (!res.ok) {\n try {\n return err(problemDetail(await res.json(), PROEFBRIEF_FAILED));\n } catch {\n return err(PROEFBRIEF_FAILED);\n }\n }\n return ok(await res.blob());\n }\n}\n\n// --- parse: wire → domain, validating at the boundary ---\n\nfunction parseSubOrg(dto: SubOrgSummaryDto): Result {\n if (typeof dto.subOrgId !== 'string' || typeof dto.orgName !== 'string')\n return err('sub-org: bad shape');\n return ok({\n subOrgId: dto.subOrgId,\n orgName: dto.orgName,\n publishedVersion: dto.publishedVersion ?? 0,\n });\n}\n\nfunction parseVersion(dto: OrgTemplateVersionDto): Result {\n if (typeof dto.version !== 'number' || typeof dto.publishedAt !== 'string')\n return err('version: bad shape');\n const template = parseOrgTemplate(dto.template);\n if (!template.ok) return template;\n return ok({ version: dto.version, publishedAt: dto.publishedAt, template: template.value });\n}\n\nexport function parseOrgTemplateAdminView(\n dto: OrgTemplateAdminViewDto,\n): Result {\n const draft = parseOrgTemplate(dto.draft);\n if (!draft.ok) return draft;\n if (typeof dto.publishedVersion !== 'number' || typeof dto.unsentBriefs !== 'number')\n return err('admin-view: bad shape');\n const history: OrgTemplateVersion[] = [];\n for (const v of dto.history ?? []) {\n const parsed = parseVersion(v);\n if (!parsed.ok) return parsed;\n history.push(parsed.value);\n }\n return ok({\n draft: draft.value,\n publishedVersion: dto.publishedVersion,\n history,\n unsentBriefs: dto.unsentBriefs,\n });\n}\n\nconst parseAdminView = parseOrgTemplateAdminView;\n\nfunction parsePublish(dto: PublishOrgTemplateResponse): Result {\n if (typeof dto.version !== 'number' || typeof dto.affectedUnsentBriefs !== 'number')\n return err('publish: bad shape');\n return ok({ version: dto.version, affectedUnsentBriefs: dto.affectedUnsentBriefs });\n}\n\n// --- toDto: domain → wire (for save) ---\n\nfunction toDto(t: OrgTemplate): OrgTemplateDto {\n return {\n subOrgId: t.subOrgId,\n orgName: t.orgName,\n returnAddress: t.returnAddress,\n ...(t.logoDocumentId != null ? { logoDocumentId: t.logoDocumentId } : {}),\n footerContact: t.footerContact,\n footerLegal: t.footerLegal,\n signatureName: t.signatureName,\n signatureRole: t.signatureRole,\n signatureClosing: t.signatureClosing,\n margins: { ...t.margins },\n version: t.version,\n };\n}\n", + "sourceCode": "import { Injectable, inject } from '@angular/core';\nimport { Result, ok, err } from '@shared/kernel/fp';\nimport { runSubmit } from '@shared/application/submit';\nimport { currentRole } from '@shared/infrastructure/role';\nimport { problemDetail } from '@shared/infrastructure/api-error';\nimport { environment } from '../../../environments/environment';\nimport {\n ApiClient,\n OrgTemplateAdminViewDto,\n OrgTemplateDto,\n OrgTemplateVersionDto,\n PublishOrgTemplateResponse,\n SubOrgSummaryDto,\n} from '@shared/infrastructure/api-client';\nimport {\n OrgTemplate,\n OrgTemplateAdminView,\n OrgTemplateVersion,\n PublishResult,\n SubOrgSummary,\n} from '@brief/domain/org-template';\nimport { parseOrgTemplate } from '@brief/infrastructure/brief.adapter';\n\n/**\n * The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/\n * rollback 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`.\n */\n\nconst FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;\nconst PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;\n\n@Injectable({ providedIn: 'root' })\nexport class OrgTemplateAdapter {\n private client = inject(ApiClient);\n\n async list(): Promise> {\n const r = await runSubmit(() => this.client.orgTemplates(), FAILED);\n if (!r.ok) return r;\n const out: SubOrgSummary[] = [];\n for (const s of r.value ?? []) {\n const parsed = parseSubOrg(s);\n if (!parsed.ok) return parsed;\n out.push(parsed.value);\n }\n return ok(out);\n }\n\n async load(subOrgId: string): Promise> {\n const r = await runSubmit(() => this.client.orgTemplateGET(subOrgId), FAILED);\n return r.ok ? parseAdminView(r.value) : r;\n }\n\n async save(subOrgId: string, draft: OrgTemplate): Promise> {\n const r = await runSubmit(\n () => this.client.orgTemplatePUT(subOrgId, { draft: toDto(draft) }),\n FAILED,\n );\n return r.ok ? parseAdminView(r.value) : r;\n }\n\n async publish(subOrgId: string): Promise> {\n const r = await runSubmit(() => this.client.orgTemplatePublish(subOrgId), FAILED);\n return r.ok ? parsePublish(r.value) : r;\n }\n\n async rollback(subOrgId: string, version: number): Promise> {\n const r = await runSubmit(() => this.client.orgTemplateRollback(subOrgId, version), FAILED);\n return r.ok ? parseAdminView(r.value) : r;\n }\n\n /** Proefbrief: the unpublished draft rendered over a fixture letter, opened as a Blob. */\n async proefbrief(subOrgId: string): Promise> {\n let res: Response;\n try {\n res = await fetch(\n `${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,\n { headers: { 'X-Role': currentRole() } },\n );\n } catch {\n return err(PROEFBRIEF_FAILED);\n }\n if (!res.ok) {\n try {\n return err(problemDetail(await res.json(), PROEFBRIEF_FAILED));\n } catch {\n return err(PROEFBRIEF_FAILED);\n }\n }\n return ok(await res.blob());\n }\n}\n\n// --- parse: wire → domain, validating at the boundary ---\n\nfunction parseSubOrg(dto: SubOrgSummaryDto): Result {\n if (typeof dto.subOrgId !== 'string' || typeof dto.orgName !== 'string')\n return err('sub-org: bad shape');\n return ok({\n subOrgId: dto.subOrgId,\n orgName: dto.orgName,\n publishedVersion: dto.publishedVersion ?? 0,\n });\n}\n\nfunction parseVersion(dto: OrgTemplateVersionDto): Result {\n if (typeof dto.version !== 'number' || typeof dto.publishedAt !== 'string')\n return err('version: bad shape');\n const template = parseOrgTemplate(dto.template);\n if (!template.ok) return template;\n return ok({ version: dto.version, publishedAt: dto.publishedAt, template: template.value });\n}\n\nexport function parseOrgTemplateAdminView(\n dto: OrgTemplateAdminViewDto,\n): Result {\n const draft = parseOrgTemplate(dto.draft);\n if (!draft.ok) return draft;\n if (typeof dto.publishedVersion !== 'number' || typeof dto.unsentBriefs !== 'number')\n return err('admin-view: bad shape');\n const history: OrgTemplateVersion[] = [];\n for (const v of dto.history ?? []) {\n const parsed = parseVersion(v);\n if (!parsed.ok) return parsed;\n history.push(parsed.value);\n }\n return ok({\n draft: draft.value,\n publishedVersion: dto.publishedVersion,\n history,\n unsentBriefs: dto.unsentBriefs,\n });\n}\n\nconst parseAdminView = parseOrgTemplateAdminView;\n\nfunction parsePublish(dto: PublishOrgTemplateResponse): Result {\n if (typeof dto.version !== 'number' || typeof dto.affectedUnsentBriefs !== 'number')\n return err('publish: bad shape');\n return ok({ version: dto.version, affectedUnsentBriefs: dto.affectedUnsentBriefs });\n}\n\n// --- toDto: domain → wire (for save) ---\n\nfunction toDto(t: OrgTemplate): OrgTemplateDto {\n return {\n subOrgId: t.subOrgId,\n orgName: t.orgName,\n returnAddress: t.returnAddress,\n ...(t.logoDocumentId != null ? { logoDocumentId: t.logoDocumentId } : {}),\n footerContact: t.footerContact,\n footerLegal: t.footerLegal,\n signatureName: t.signatureName,\n signatureRole: t.signatureRole,\n signatureClosing: t.signatureClosing,\n margins: { ...t.margins },\n version: t.version,\n };\n}\n", "extends": [], "type": "injectable" }, { "name": "OrgTemplateStore", - "id": "injectable-OrgTemplateStore-1eecac2749af5b60f90c7b922e02f70be919d949a246db9375dd0e42b0cd9878e352ea1ca746c4bdcc3228aaba2c0a523fe277e2d8083369420c6aed689ab938", + "id": "injectable-OrgTemplateStore-04f8a902ef42dbfff32f2b2d0c45f69e94939457413040784cb8b0a266ffa10bdba819449b7071360861fcfd1da37b52169d67f8fa2010e759c719d35894dd89", "file": "src/app/brief/application/org-template.store.ts", "properties": [ { @@ -10350,7 +10350,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 99, + "line": 102, "modifierKind": [ 123 ] @@ -10371,7 +10371,7 @@ }, { "name": "draftValid", - "defaultValue": "computed(() => {\n const d = this.draft();\n if (!d) return false;\n const marginsOk = [d.margins.topMm, d.margins.rightMm, d.margins.bottomMm, d.margins.leftMm].every(\n (v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM,\n );\n return d.orgName.trim().length > 0 && d.signatureName.trim().length > 0 && marginsOk;\n })", + "defaultValue": "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 })", "deprecated": false, "deprecationMessage": "", "type": "unknown", @@ -10393,7 +10393,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 98, + "line": 101, "modifierKind": [ 123 ] @@ -10407,7 +10407,7 @@ "indexKey": "", "optional": false, "description": "

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

                  \n", - "line": 162, + "line": 168, "rawdescription": "\nTrue while a debounced edit hasn't been written yet (PendingSave)." }, { @@ -10545,7 +10545,7 @@ "indexKey": "", "optional": true, "description": "", - "line": 149, + "line": 155, "modifierKind": [ 123 ] @@ -10656,7 +10656,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 190, + "line": 196, "deprecated": false, "deprecationMessage": "" }, @@ -10666,7 +10666,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 193, + "line": 199, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10688,7 +10688,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 283, + "line": 290, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10723,7 +10723,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 144, + "line": 150, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nAn in-place canvas or margin edit: apply optimistically, then debounce-save.", @@ -10748,7 +10748,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 164, + "line": 170, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nFlush a pending debounced save now and await it; no-op when nothing is pending.", @@ -10763,7 +10763,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 170, + "line": 176, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10777,7 +10777,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 116, + "line": 122, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10799,7 +10799,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 267, + "line": 274, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -10831,7 +10831,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 273, + "line": 280, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -10863,7 +10863,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 243, + "line": 249, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -10895,7 +10895,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 288, + "line": 295, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nUpload effects arriving from the transport: a finished/removed logo edits the\ndraft (in the reducer) and needs persisting.", @@ -10923,7 +10923,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 225, + "line": 231, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10936,7 +10936,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 187, + "line": 193, "deprecated": false, "deprecationMessage": "" }, @@ -10955,7 +10955,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 210, + "line": 216, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10981,7 +10981,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 150, + "line": 156, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -11003,7 +11003,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 132, + "line": 138, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -11028,21 +11028,21 @@ "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 = [d.margins.topMm, d.margins.rightMm, d.margins.bottomMm, d.margins.leftMm].every(\n (v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM,\n );\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({ type: 'CategoriesLoaded', categories: this.categoriesRes.value() ?? [] });\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({ localId, categoryId: cat.categoryId, wizardId: 'org-template', file }, (m) =>\n 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 { 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", "constructorObj": { "name": "constructor", "description": "", "deprecated": false, "deprecationMessage": "", "args": [], - "line": 99 + "line": 102 }, "extends": [], "type": "injectable" }, { "name": "PendingSaves", - "id": "injectable-PendingSaves-2727407341bf27a69b5552e48492a92b5bb54df722ffb29bbcc0c427a84e69504b1a2f84c22487134702040754c185bd1ee35db43abfec753fa42630eeec81e8", + "id": "injectable-PendingSaves-ac22e9d6a135c81e975387e5d582fb8f2217c7ee1c2f45ba1391507ff4f9ae21a3182ef56c201e3ca50c2367cfe1b3faaf4ae64ad3a1dd21bc02ec4c820723aa", "file": "src/app/shared/application/pending-saves.ts", "properties": [ { @@ -11054,7 +11054,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 27, + "line": 22, "modifierKind": [ 123, 148 @@ -11068,7 +11068,7 @@ "optional": false, "returnType": "Promise", "typeParameters": [], - "line": 42, + "line": 37, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nFlush every owner that has a pending write, awaiting all. Best-effort: a rejected\nflush is swallowed (a failed autosave surfaces its own error state; navigation must\nnot be blocked by it).", @@ -11083,7 +11083,7 @@ "optional": false, "returnType": "boolean", "typeParameters": [], - "line": 35, + "line": 30, "deprecated": false, "deprecationMessage": "" }, @@ -11102,7 +11102,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 30, + "line": 25, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nRegister an owner; returns an unregister function.", @@ -11126,7 +11126,7 @@ "deprecationMessage": "", "description": "

                  Registry of every active autosave owner. The CanDeactivate guard and the\nbeforeunload handler flush through this — one seam, both callers.

                  \n", "rawdescription": "\nRegistry of every active autosave owner. The `CanDeactivate` guard and the\n`beforeunload` handler flush through this — one seam, both callers.", - "sourceCode": "import {\n DestroyRef,\n ENVIRONMENT_INITIALIZER,\n Injectable,\n inject,\n} from '@angular/core';\nimport { CanDeactivateFn } from '@angular/router';\n\n/**\n * A source of debounced, not-yet-flushed writes (autosave). The two autosave owners in\n * this app have different lifetimes — root singleton stores (`BriefStore`,\n * `OrgTemplateStore`) and per-wizard `createDraftSync` controllers living inside child\n * organisms — so both register here instead of the guard/unload handler needing to know\n * which page or store owns the pending write.\n */\nexport interface PendingSave {\n /** True while a debounced edit hasn't been written to the backend yet. */\n hasPendingSave(): boolean;\n /** Flush that pending write now and await it. No-op when nothing is pending. */\n flushPending(): Promise;\n}\n\n/** Registry of every active autosave owner. The `CanDeactivate` guard and the\n `beforeunload` handler flush through this — one seam, both callers. */\n@Injectable({ providedIn: 'root' })\nexport class PendingSaves {\n private readonly owners = new Set();\n\n /** Register an owner; returns an unregister function. */\n register(owner: PendingSave): () => void {\n this.owners.add(owner);\n return () => this.owners.delete(owner);\n }\n\n hasPending(): boolean {\n return [...this.owners].some((o) => o.hasPendingSave());\n }\n\n /** Flush every owner that has a pending write, awaiting all. Best-effort: a rejected\n flush is swallowed (a failed autosave surfaces its own error state; navigation must\n not be blocked by it). */\n async flushAll(): Promise {\n await Promise.allSettled(\n [...this.owners].filter((o) => o.hasPendingSave()).map((o) => o.flushPending()),\n );\n }\n}\n\n/** Register the current injection context's owner for the life of its `DestroyRef`.\n Call from a constructor or field initializer (root store, or `createDraftSync`). */\nexport function registerPendingSave(owner: PendingSave): void {\n const unregister = inject(PendingSaves).register(owner);\n inject(DestroyRef).onDestroy(unregister);\n}\n\n/** `CanDeactivate` guard: flush any pending debounced write before an in-app route change,\n then allow navigation. Awaitable, so the write lands before the page tears down (which\n would otherwise drop a sub-debounce edit). We never block leaving — the flush is a\n guarantee of effort, not a gate. */\nexport const flushPendingGuard: CanDeactivateFn = () => {\n const pending = inject(PendingSaves);\n return pending.hasPending() ? pending.flushAll().then(() => true) : true;\n};\n\n/** Wire a `beforeunload` handler that guards the last-mile save on a hard tab-close/reload.\n ponytail: the HTTP seam is Angular `HttpClient` (no `keepalive`/`sendBeacon`), so an\n async flush can't be guaranteed to finish as the page tears down — we fire it best-effort\n AND trigger the browser's native \"unsaved changes\" prompt, which lets the ~600ms debounce\n land if the user stays. Upgrade path: a `sendBeacon`/keepalive last-mile if this ever\n needs to be guaranteed. */\nexport function provideUnloadFlush() {\n return {\n provide: ENVIRONMENT_INITIALIZER,\n multi: true,\n useValue: () => {\n const pending = inject(PendingSaves);\n window.addEventListener('beforeunload', (e) => {\n if (!pending.hasPending()) return;\n void pending.flushAll();\n e.preventDefault();\n e.returnValue = '';\n });\n },\n };\n}\n", + "sourceCode": "import { DestroyRef, ENVIRONMENT_INITIALIZER, Injectable, inject } from '@angular/core';\nimport { CanDeactivateFn } from '@angular/router';\n\n/**\n * A source of debounced, not-yet-flushed writes (autosave). The two autosave owners in\n * this app have different lifetimes — root singleton stores (`BriefStore`,\n * `OrgTemplateStore`) and per-wizard `createDraftSync` controllers living inside child\n * organisms — so both register here instead of the guard/unload handler needing to know\n * which page or store owns the pending write.\n */\nexport interface PendingSave {\n /** True while a debounced edit hasn't been written to the backend yet. */\n hasPendingSave(): boolean;\n /** Flush that pending write now and await it. No-op when nothing is pending. */\n flushPending(): Promise;\n}\n\n/** Registry of every active autosave owner. The `CanDeactivate` guard and the\n `beforeunload` handler flush through this — one seam, both callers. */\n@Injectable({ providedIn: 'root' })\nexport class PendingSaves {\n private readonly owners = new Set();\n\n /** Register an owner; returns an unregister function. */\n register(owner: PendingSave): () => void {\n this.owners.add(owner);\n return () => this.owners.delete(owner);\n }\n\n hasPending(): boolean {\n return [...this.owners].some((o) => o.hasPendingSave());\n }\n\n /** Flush every owner that has a pending write, awaiting all. Best-effort: a rejected\n flush is swallowed (a failed autosave surfaces its own error state; navigation must\n not be blocked by it). */\n async flushAll(): Promise {\n await Promise.allSettled(\n [...this.owners].filter((o) => o.hasPendingSave()).map((o) => o.flushPending()),\n );\n }\n}\n\n/** Register the current injection context's owner for the life of its `DestroyRef`.\n Call from a constructor or field initializer (root store, or `createDraftSync`). */\nexport function registerPendingSave(owner: PendingSave): void {\n const unregister = inject(PendingSaves).register(owner);\n inject(DestroyRef).onDestroy(unregister);\n}\n\n/** `CanDeactivate` guard: flush any pending debounced write before an in-app route change,\n then allow navigation. Awaitable, so the write lands before the page tears down (which\n would otherwise drop a sub-debounce edit). We never block leaving — the flush is a\n guarantee of effort, not a gate. */\nexport const flushPendingGuard: CanDeactivateFn = () => {\n const pending = inject(PendingSaves);\n return pending.hasPending() ? pending.flushAll().then(() => true) : true;\n};\n\n/** Wire a `beforeunload` handler that guards the last-mile save on a hard tab-close/reload.\n ponytail: the HTTP seam is Angular `HttpClient` (no `keepalive`/`sendBeacon`), so an\n async flush can't be guaranteed to finish as the page tears down — we fire it best-effort\n AND trigger the browser's native \"unsaved changes\" prompt, which lets the ~600ms debounce\n land if the user stays. Upgrade path: a `sendBeacon`/keepalive last-mile if this ever\n needs to be guaranteed. */\nexport function provideUnloadFlush() {\n return {\n provide: ENVIRONMENT_INITIALIZER,\n multi: true,\n useValue: () => {\n const pending = inject(PendingSaves);\n window.addEventListener('beforeunload', (e) => {\n if (!pending.hasPending()) return;\n void pending.flushAll();\n e.preventDefault();\n e.returnValue = '';\n });\n },\n };\n}\n", "extends": [], "type": "injectable" }, @@ -11261,7 +11261,7 @@ }, { "name": "RevealBigNummerAdapter", - "id": "injectable-RevealBigNummerAdapter-403bdf1b147203085e2a7128a87c538908c321b5cdd0d0ba45fb56d1ecfcac1d5dbce8153d1cee0575f84ea9fc0839536727b6dd6832c414bd6cb909874c315b", + "id": "injectable-RevealBigNummerAdapter-cc7db6f350c58c5f51ee7d6a50710a950b03eebac8dde63f7651d9e96b07c003940d618b3ab5d7b9efe13ae7dc864b803c877ec94aaeaa16726ee676f4d7f88d", "file": "src/app/brief/infrastructure/reveal-bignummer.adapter.ts", "properties": [], "methods": [ @@ -11283,7 +11283,7 @@ "deprecationMessage": "", "description": "

                  Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked;\nthis unmasks it, gated server-side by the reveal capability AND a step-up. The\nstep-up is stubbed as the X-Step-Up header — the caller sends it only after the\nuser's confirm gesture, so a plain call (or a role without the capability) 403s.

                  \n

                  Hand-written fetch (not the ApiClient) because the call needs a per-request header;\n.ExcludeFromDescription() on the endpoint keeps the generated client JSON-only, the\nsame seam as /brief/preview and uploads — which also means X-Role is set here.

                  \n", "rawdescription": "\n\nField-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked;\nthis unmasks it, gated server-side by the reveal capability AND a step-up. The\nstep-up is stubbed as the `X-Step-Up` header — the caller sends it only after the\nuser's confirm gesture, so a plain call (or a role without the capability) 403s.\n\nHand-written fetch (not the `ApiClient`) because the call needs a per-request header;\n`.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the\nsame seam as `/brief/preview` and uploads — which also means `X-Role` is set here.\n", - "sourceCode": "import { Injectable } from '@angular/core';\nimport { Result, ok, err } from '@shared/kernel/fp';\nimport { currentRole } from '@shared/infrastructure/role';\nimport { problemDetail } from '@shared/infrastructure/api-error';\nimport { environment } from '../../../environments/environment';\n\nconst REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;\n\n/**\n * Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked;\n * this unmasks it, gated server-side by the reveal capability AND a step-up. The\n * step-up is stubbed as the `X-Step-Up` header — the caller sends it only after the\n * user's confirm gesture, so a plain call (or a role without the capability) 403s.\n *\n * Hand-written fetch (not the `ApiClient`) because the call needs a per-request header;\n * `.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the\n * same seam as `/brief/preview` and uploads — which also means `X-Role` is set here.\n */\n@Injectable({ providedIn: 'root' })\nexport class RevealBigNummerAdapter {\n async reveal(): Promise> {\n let res: Response;\n try {\n res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, {\n method: 'POST',\n headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' },\n });\n } catch {\n return err(REVEAL_FAILED);\n }\n if (!res.ok) return err(await errorMessage(res));\n const body: unknown = await res.json().catch(() => null);\n // Trust boundary: validate the shape before handing back a plain string.\n if (typeof body === 'object' && body !== null && typeof (body as { bigNummer?: unknown }).bigNummer === 'string') {\n return ok((body as { bigNummer: string }).bigNummer);\n }\n return err(REVEAL_FAILED);\n }\n}\n\nasync function errorMessage(res: Response): Promise {\n try {\n return problemDetail(await res.json(), REVEAL_FAILED);\n } catch {\n return REVEAL_FAILED;\n }\n}\n", + "sourceCode": "import { Injectable } from '@angular/core';\nimport { Result, ok, err } from '@shared/kernel/fp';\nimport { currentRole } from '@shared/infrastructure/role';\nimport { problemDetail } from '@shared/infrastructure/api-error';\nimport { environment } from '../../../environments/environment';\n\nconst REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;\n\n/**\n * Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked;\n * this unmasks it, gated server-side by the reveal capability AND a step-up. The\n * step-up is stubbed as the `X-Step-Up` header — the caller sends it only after the\n * user's confirm gesture, so a plain call (or a role without the capability) 403s.\n *\n * Hand-written fetch (not the `ApiClient`) because the call needs a per-request header;\n * `.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the\n * same seam as `/brief/preview` and uploads — which also means `X-Role` is set here.\n */\n@Injectable({ providedIn: 'root' })\nexport class RevealBigNummerAdapter {\n async reveal(): Promise> {\n let res: Response;\n try {\n res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, {\n method: 'POST',\n headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' },\n });\n } catch {\n return err(REVEAL_FAILED);\n }\n if (!res.ok) return err(await errorMessage(res));\n const body: unknown = await res.json().catch(() => null);\n // Trust boundary: validate the shape before handing back a plain string.\n if (\n typeof body === 'object' &&\n body !== null &&\n typeof (body as { bigNummer?: unknown }).bigNummer === 'string'\n ) {\n return ok((body as { bigNummer: string }).bigNummer);\n }\n return err(REVEAL_FAILED);\n }\n}\n\nasync function errorMessage(res: Response): Promise {\n try {\n return problemDetail(await res.json(), REVEAL_FAILED);\n } catch {\n return REVEAL_FAILED;\n }\n}\n", "extends": [], "type": "injectable" }, @@ -11416,7 +11416,7 @@ }, { "name": "StamdataAdapter", - "id": "injectable-StamdataAdapter-58a283c888c4e66fccdd23a9a617e3f82d7a59c986b1470455efbb29bef1c4d6cc90a733a2d583736634675ef5a7cc3136a3ea2e9563fecd7a824992500fcd60", + "id": "injectable-StamdataAdapter-2aae978c9ed9eacc6ada93dbd44cc40112bffdaceefaaec46f3dd97d8ef848e7ff2d96e57d74ab99f27e943725106e2991582251cac567971bfa8ed9455df7d4", "file": "src/app/beheer/infrastructure/stamdata.adapter.ts", "properties": [ { @@ -11511,7 +11511,7 @@ "deprecationMessage": "", "description": "

                  The only place stamdata HTTP lives (ADR-0001 boundary). Both endpoints are reads; the\ngeneric parse* narrows the untrusted wire shape (schema + opaque rows) into the domain\nmodel. There is no write method — the edit is downloaded and lands as a PR.

                  \n", "rawdescription": "\n\nThe only place stamdata HTTP lives (ADR-0001 boundary). Both endpoints are reads; the\ngeneric `parse*` narrows the untrusted wire shape (schema + opaque rows) into the domain\nmodel. There is no write method — the edit is downloaded and lands as a PR.\n", - "sourceCode": "import { Injectable, inject } from '@angular/core';\nimport { Result, ok, err } from '@shared/kernel/fp';\nimport { runSubmit } from '@shared/application/submit';\nimport { ApiClient, StamdataColumnDto } from '@shared/infrastructure/api-client';\nimport { ColumnType, StamColumn, StamRow, StamTable } from '@beheer/domain/stamdata';\n\n/** A loaded table: its schema plus the rows for editing (or the peildatum-filtered view). */\nexport interface LoadedTable {\n table: StamTable;\n rows: StamRow[];\n}\n\nconst FAILED = $localize`:@@beheer.load.failed:De stamdata kon niet worden geladen.`;\nconst COLUMN_TYPES: readonly ColumnType[] = ['text', 'date', 'number', 'enum'];\n\n/**\n * The only place stamdata HTTP lives (ADR-0001 boundary). Both endpoints are reads; the\n * generic `parse*` narrows the untrusted wire shape (schema + opaque rows) into the domain\n * model. There is no write method — the edit is downloaded and lands as a PR.\n */\n@Injectable({ providedIn: 'root' })\nexport class StamdataAdapter {\n private client = inject(ApiClient);\n\n /** The tables in the catalog (schema only, no rows) — for the table switcher. */\n async list(): Promise> {\n const r = await runSubmit(() => this.client.stamdataTables(), FAILED);\n if (!r.ok) return r;\n const out: StamTable[] = [];\n for (const t of r.value ?? []) {\n const parsed = parseTable(t);\n if (!parsed.ok) return parsed;\n out.push(parsed.value);\n }\n return ok(out);\n }\n\n /** One table's schema + rows. `peildatum` (yyyy-MM-dd) asks the server for only the rows\n valid on that date; the editor uses it for a server-side cross-check, previewing\n locally for instant feedback (see `activeOn`). */\n async load(tableId: string, peildatum?: string): Promise> {\n const r = await runSubmit(() => this.client.stamdataTable(tableId, peildatum), FAILED);\n return r.ok ? parseStamdataTable(r.value) : r;\n }\n}\n\n// --- parse: wire → domain, validating at the boundary ---\n\n/** Trust-boundary parse for one table response: schema + rows → domain. Exported so its\n spec can exercise it without HTTP (the house `parse*` seam, ADR-0001). */\nexport function parseStamdataTable(dto: {\n id?: string;\n label?: string;\n columns?: StamdataColumnDto[];\n temporal?: boolean;\n rows?: readonly unknown[];\n}): Result {\n const table = parseTable(dto);\n if (!table.ok) return table;\n return ok({ table: table.value, rows: parseRows(dto.rows ?? [], table.value.columns) });\n}\n\nfunction parseColumn(dto: StamdataColumnDto): Result {\n if (typeof dto.name !== 'string' || dto.name === '') return err('stamdata column: bad name');\n const raw = dto.type ?? '';\n const type = (COLUMN_TYPES as string[]).includes(raw) ? (raw as ColumnType) : 'text';\n return ok({ name: dto.name, type, isKey: dto.isKey === true, options: dto.options ?? [] });\n}\n\nfunction parseTable(dto: { id?: string; label?: string; columns?: StamdataColumnDto[]; temporal?: boolean }): Result {\n if (typeof dto.id !== 'string' || !Array.isArray(dto.columns))\n return err('stamdata table: bad shape');\n const columns: StamColumn[] = [];\n for (const c of dto.columns) {\n const parsed = parseColumn(c);\n if (!parsed.ok) return parsed;\n columns.push(parsed.value);\n }\n if (columns.length === 0) return err('stamdata table: no columns');\n return ok({ id: dto.id, label: dto.label ?? dto.id, columns, temporal: dto.temporal === true });\n}\n\n/** Every cell becomes editable text: null → '' (open-ended), number/bool → its string form. */\nfunction parseRows(rows: readonly unknown[], columns: readonly StamColumn[]): StamRow[] {\n return rows.map((raw) => {\n const row: StamRow = {};\n const obj = (raw ?? {}) as Record;\n for (const c of columns) {\n const v = obj[c.name];\n row[c.name] = v === null || v === undefined ? '' : String(v);\n }\n return row;\n });\n}\n", + "sourceCode": "import { Injectable, inject } from '@angular/core';\nimport { Result, ok, err } from '@shared/kernel/fp';\nimport { runSubmit } from '@shared/application/submit';\nimport { ApiClient, StamdataColumnDto } from '@shared/infrastructure/api-client';\nimport { ColumnType, StamColumn, StamRow, StamTable } from '@beheer/domain/stamdata';\n\n/** A loaded table: its schema plus the rows for editing (or the peildatum-filtered view). */\nexport interface LoadedTable {\n table: StamTable;\n rows: StamRow[];\n}\n\nconst FAILED = $localize`:@@beheer.load.failed:De stamdata kon niet worden geladen.`;\nconst COLUMN_TYPES: readonly ColumnType[] = ['text', 'date', 'number', 'enum'];\n\n/**\n * The only place stamdata HTTP lives (ADR-0001 boundary). Both endpoints are reads; the\n * generic `parse*` narrows the untrusted wire shape (schema + opaque rows) into the domain\n * model. There is no write method — the edit is downloaded and lands as a PR.\n */\n@Injectable({ providedIn: 'root' })\nexport class StamdataAdapter {\n private client = inject(ApiClient);\n\n /** The tables in the catalog (schema only, no rows) — for the table switcher. */\n async list(): Promise> {\n const r = await runSubmit(() => this.client.stamdataTables(), FAILED);\n if (!r.ok) return r;\n const out: StamTable[] = [];\n for (const t of r.value ?? []) {\n const parsed = parseTable(t);\n if (!parsed.ok) return parsed;\n out.push(parsed.value);\n }\n return ok(out);\n }\n\n /** One table's schema + rows. `peildatum` (yyyy-MM-dd) asks the server for only the rows\n valid on that date; the editor uses it for a server-side cross-check, previewing\n locally for instant feedback (see `activeOn`). */\n async load(tableId: string, peildatum?: string): Promise> {\n const r = await runSubmit(() => this.client.stamdataTable(tableId, peildatum), FAILED);\n return r.ok ? parseStamdataTable(r.value) : r;\n }\n}\n\n// --- parse: wire → domain, validating at the boundary ---\n\n/** Trust-boundary parse for one table response: schema + rows → domain. Exported so its\n spec can exercise it without HTTP (the house `parse*` seam, ADR-0001). */\nexport function parseStamdataTable(dto: {\n id?: string;\n label?: string;\n columns?: StamdataColumnDto[];\n temporal?: boolean;\n rows?: readonly unknown[];\n}): Result {\n const table = parseTable(dto);\n if (!table.ok) return table;\n return ok({ table: table.value, rows: parseRows(dto.rows ?? [], table.value.columns) });\n}\n\nfunction parseColumn(dto: StamdataColumnDto): Result {\n if (typeof dto.name !== 'string' || dto.name === '') return err('stamdata column: bad name');\n const raw = dto.type ?? '';\n const type = (COLUMN_TYPES as string[]).includes(raw) ? (raw as ColumnType) : 'text';\n return ok({ name: dto.name, type, isKey: dto.isKey === true, options: dto.options ?? [] });\n}\n\nfunction parseTable(dto: {\n id?: string;\n label?: string;\n columns?: StamdataColumnDto[];\n temporal?: boolean;\n}): Result {\n if (typeof dto.id !== 'string' || !Array.isArray(dto.columns))\n return err('stamdata table: bad shape');\n const columns: StamColumn[] = [];\n for (const c of dto.columns) {\n const parsed = parseColumn(c);\n if (!parsed.ok) return parsed;\n columns.push(parsed.value);\n }\n if (columns.length === 0) return err('stamdata table: no columns');\n return ok({ id: dto.id, label: dto.label ?? dto.id, columns, temporal: dto.temporal === true });\n}\n\n/** Every cell becomes editable text: null → '' (open-ended), number/bool → its string form. */\nfunction parseRows(rows: readonly unknown[], columns: readonly StamColumn[]): StamRow[] {\n return rows.map((raw) => {\n const row: StamRow = {};\n const obj = (raw ?? {}) as Record;\n for (const c of columns) {\n const v = obj[c.name];\n row[c.name] = v === null || v === undefined ? '' : String(v);\n }\n return row;\n });\n}\n", "extends": [], "type": "injectable" }, @@ -16925,7 +16925,7 @@ }, { "name": "BehandelSchermComponent", - "id": "component-BehandelSchermComponent-626e17feadd3f21a89920d99bea5368632993bd18973d25352a612e611409d9e6538acd72ee89bcb244039a6484c0f37da2801f0e889cd32b31780744ad249bd", + "id": "component-BehandelSchermComponent-e23bd37bbb02df2d3e0e736cf6a51cd5d39500122b9959ea14377eb8a16fa88379d2ce84c9c59423e88757ca15e0cbe29cb38329d7732fd9159994205c7de199", "file": "src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts", "encapsulation": [], "entryComponents": [], @@ -16937,7 +16937,7 @@ "styles": [ "\n :host {\n display: block;\n }\n .case-head {\n margin-block-end: var(--rhc-space-max-lg);\n padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);\n border-inline-start: 4px solid var(--rhc-color-primary, var(--rhc-color-border-strong));\n background: var(--rhc-color-background-subtle, transparent);\n }\n .case-meta {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n color: var(--rhc-color-foreground-subtle);\n }\n .step-body {\n display: grid;\n gap: var(--rhc-space-max-xl);\n margin-block-start: var(--rhc-space-max-lg);\n }\n .bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n align-items: center;\n margin-block-start: var(--rhc-space-max-xl);\n }\n dialog {\n border: none;\n border-radius: var(--rhc-radius-md, 4px);\n padding: 0;\n max-width: min(900px, 95vw);\n width: 100%;\n }\n dialog::backdrop {\n background: rgb(0 0 0 / 45%); /* token-ok: modal scrim, not a palette colour */\n }\n .modal-body {\n padding: var(--rhc-space-max-lg);\n max-height: 80vh;\n overflow: auto;\n }\n .modal-bar {\n display: flex;\n justify-content: space-between;\n gap: var(--rhc-space-max-md);\n padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);\n border-block-start: 1px solid var(--rhc-color-border);\n }\n " ], - "template": "
                  \n {{ caseHeading() }}\n
                  \n {{ caseContext().aanvraagReferentie }}\n {{ caseContext().zorgverlenerNaam }}\n \n {{ bigLabel() }} {{ caseContext().bigNummer }}\n @if (canRevealBigNummer() && isMasked()) {\n {{ revealLabel() }}\n }\n \n {{ caseContext().beroep }}\n
                  \n
                  \n\n\n\n
                  \n @if (status() === 'rejected') {\n \n }\n\n \n\n \n\n \n\n
                  \n {{ previewLabel() }}\n {{ submitLabel() }}\n @if (!canSubmit()) {\n {{ submitHint() }}\n }\n
                  \n
                  \n\n\n
                  \n \n
                  \n
                  \n {{ openDocumentLabel() }}\n {{ closeLabel() }}\n
                  \n
                  \n", + "template": "
                  \n {{ caseHeading() }}\n
                  \n {{ caseContext().aanvraagReferentie }}\n {{ caseContext().zorgverlenerNaam }}\n \n {{ bigLabel() }} {{ caseContext().bigNummer }}\n @if (canRevealBigNummer() && isMasked()) {\n {{ revealLabel() }}\n }\n \n {{ caseContext().beroep }}\n
                  \n
                  \n\n\n\n
                  \n @if (status() === 'rejected') {\n \n }\n\n \n\n \n\n \n\n
                  \n {{ previewLabel() }}\n {{\n submitLabel()\n }}\n @if (!canSubmit()) {\n {{ submitHint() }}\n }\n
                  \n
                  \n\n\n
                  \n \n
                  \n
                  \n {{\n openDocumentLabel()\n }}\n {{ closeLabel() }}\n
                  \n
                  \n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -16951,7 +16951,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 162, + "line": 161, "required": false }, { @@ -16962,7 +16962,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 234, + "line": 233, "modifierKind": [ 124 ], @@ -16976,7 +16976,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 159, + "line": 158, "required": true }, { @@ -16987,7 +16987,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 166, + "line": 165, "required": false }, { @@ -16998,7 +16998,7 @@ "indexKey": "", "optional": false, "description": "

                  Server decision (PRD-0002 §5c): may this actor unmask the case BIG-nummer?

                  \n", - "line": 168, + "line": 167, "rawdescription": "\nServer decision (PRD-0002 §5c): may this actor unmask the case BIG-nummer?", "required": false }, @@ -17010,7 +17010,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 165, + "line": 164, "required": false }, { @@ -17021,7 +17021,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 164, + "line": 163, "required": true }, { @@ -17032,7 +17032,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 233, + "line": 232, "modifierKind": [ 124 ], @@ -17046,7 +17046,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 243, + "line": 242, "modifierKind": [ 124 ], @@ -17061,7 +17061,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 163, + "line": 162, "required": false }, { @@ -17073,7 +17073,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 161, + "line": 160, "required": false }, { @@ -17084,7 +17084,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 240, + "line": 239, "modifierKind": [ 124 ], @@ -17098,7 +17098,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 160, + "line": 159, "required": true }, { @@ -17109,7 +17109,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 239, + "line": 238, "modifierKind": [ 124 ], @@ -17123,7 +17123,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 231, + "line": 230, "modifierKind": [ 124 ], @@ -17137,7 +17137,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 235, + "line": 234, "modifierKind": [ 124 ], @@ -17152,7 +17152,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 226, + "line": 225, "modifierKind": [ 124 ], @@ -17166,7 +17166,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 232, + "line": 231, "modifierKind": [ 124 ], @@ -17180,7 +17180,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 236, + "line": 235, "modifierKind": [ 124 ], @@ -17194,7 +17194,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 244, + "line": 243, "modifierKind": [ 124 ], @@ -17210,7 +17210,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 170, + "line": 169, "required": false }, { @@ -17221,7 +17221,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 173, + "line": 172, "required": false }, { @@ -17232,7 +17232,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 172, + "line": 171, "required": false }, { @@ -17243,7 +17243,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 174, + "line": 173, "required": false }, { @@ -17254,7 +17254,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 171, + "line": 170, "required": false } ], @@ -17268,7 +17268,7 @@ "indexKey": "", "optional": false, "description": "

                  The BIG-nummer arrives masked (contains *); once revealed the swapped value has\nno *, so the reveal action hides itself — no separate "revealed" flag needed.

                  \n", - "line": 178, + "line": 177, "rawdescription": "\nThe BIG-nummer arrives masked (contains `*`); once revealed the swapped value has\nno `*`, so the reveal action hides itself — no separate \"revealed\" flag needed.", "modifierKind": [ 124 @@ -17283,7 +17283,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 202, + "line": 201, "modifierKind": [ 124 ] @@ -17297,7 +17297,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 186, + "line": 185, "modifierKind": [ 123 ] @@ -17311,7 +17311,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 189, + "line": 188, "modifierKind": [ 124 ] @@ -17325,7 +17325,7 @@ "indexKey": "", "optional": false, "description": "

                  The besluit + redenen the letter currently reflects, read back off the kern's\npassages — this seeds the panel so it survives reload/undo (no separate storage).

                  \n", - "line": 196, + "line": 195, "rawdescription": "\nThe besluit + redenen the letter currently reflects, read back off the kern's\npassages — this seeds the panel so it survives reload/undo (no separate storage).", "modifierKind": [ 124 @@ -17340,7 +17340,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 188, + "line": 187, "modifierKind": [ 124 ] @@ -17354,7 +17354,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 220, + "line": 219, "modifierKind": [ 124 ] @@ -17367,7 +17367,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 216, + "line": 215, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -17380,7 +17380,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 182, + "line": 181, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nStep-up (PRD-0002 §5d) stubbed as a native confirm — the extra verification gesture\nbefore an audited PII reveal. ponytail: real systems prompt MFA / recent re-auth.", @@ -17404,7 +17404,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 209, + "line": 208, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nBesluit/redenen changed → recompose the kern as one edit (= one undo step).", @@ -17432,7 +17432,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 213, + "line": 212, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -17482,7 +17482,7 @@ "description": "

                  Organism: the behandelaar's drafting step. Frames "Brief opstellen" as one step in\nthe case workflow — a case-context header + stepper (Beoordelen → Brief opstellen →\nIndienen, neighbours stubbed) — with the besluit-driven guidance, the lean letter\neditor, and an on-demand full-letter preview in a modal. Only ever renders for an\neditable brief (draft/rejected); the approver's read-only flow stays in\nletter-composer. Presentational: emits edit/submit/preview intents.

                  \n", "rawdescription": "\nOrganism: the behandelaar's drafting step. Frames \"Brief opstellen\" as one step in\nthe case workflow — a case-context header + stepper (Beoordelen → Brief opstellen →\nIndienen, neighbours stubbed) — with the besluit-driven guidance, the lean letter\neditor, and an on-demand full-letter preview in a modal. Only ever renders for an\neditable brief (draft/rejected); the approver's read-only flow stays in\nletter-composer. Presentational: emits edit/submit/preview intents.", "type": "component", - "sourceCode": "import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core';\nimport { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { HeadingComponent } from '@shared/ui/heading/heading.component';\nimport { StepperComponent } from '@shared/ui/stepper/stepper.component';\nimport { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';\nimport { inferSelection } from '@brief/domain/besluit';\nimport { Diagnostic } from '@brief/domain/placeholders';\nimport { OrgTemplate } from '@brief/domain/org-template';\nimport { BriefMsg } from '@brief/domain/brief.machine';\nimport { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';\nimport { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';\nimport { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';\nimport { LetterEditorComponent } from '@brief/ui/letter-editor/letter-editor.component';\nimport { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.component';\n\n/** Organism: the behandelaar's drafting step. Frames \"Brief opstellen\" as one step in\n the case workflow — a case-context header + stepper (Beoordelen → Brief opstellen →\n Indienen, neighbours stubbed) — with the besluit-driven guidance, the lean letter\n editor, and an on-demand full-letter preview in a modal. Only ever renders for an\n editable brief (draft/rejected); the approver's read-only flow stays in\n letter-composer. Presentational: emits edit/submit/preview intents. */\n@Component({\n selector: 'app-behandel-scherm',\n imports: [\n ButtonComponent,\n HeadingComponent,\n StepperComponent,\n LetterCanvasComponent,\n DiagnosticsPanelComponent,\n RejectionCommentsComponent,\n LetterEditorComponent,\n BesluitPanelComponent,\n ],\n styles: [\n `\n :host {\n display: block;\n }\n .case-head {\n margin-block-end: var(--rhc-space-max-lg);\n padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);\n border-inline-start: 4px solid var(--rhc-color-primary, var(--rhc-color-border-strong));\n background: var(--rhc-color-background-subtle, transparent);\n }\n .case-meta {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n color: var(--rhc-color-foreground-subtle);\n }\n .step-body {\n display: grid;\n gap: var(--rhc-space-max-xl);\n margin-block-start: var(--rhc-space-max-lg);\n }\n .bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n align-items: center;\n margin-block-start: var(--rhc-space-max-xl);\n }\n dialog {\n border: none;\n border-radius: var(--rhc-radius-md, 4px);\n padding: 0;\n max-width: min(900px, 95vw);\n width: 100%;\n }\n dialog::backdrop {\n background: rgb(0 0 0 / 45%); /* token-ok: modal scrim, not a palette colour */\n }\n .modal-body {\n padding: var(--rhc-space-max-lg);\n max-height: 80vh;\n overflow: auto;\n }\n .modal-bar {\n display: flex;\n justify-content: space-between;\n gap: var(--rhc-space-max-md);\n padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);\n border-block-start: 1px solid var(--rhc-color-border);\n }\n `,\n ],\n template: `\n
                  \n {{ caseHeading() }}\n
                  \n {{ caseContext().aanvraagReferentie }}\n {{ caseContext().zorgverlenerNaam }}\n \n {{ bigLabel() }} {{ caseContext().bigNummer }}\n @if (canRevealBigNummer() && isMasked()) {\n {{ revealLabel() }}\n }\n \n {{ caseContext().beroep }}\n
                  \n
                  \n\n \n\n
                  \n @if (status() === 'rejected') {\n \n }\n\n \n\n \n\n \n\n
                  \n {{ previewLabel() }}\n {{ submitLabel() }}\n @if (!canSubmit()) {\n {{ submitHint() }}\n }\n
                  \n
                  \n\n \n
                  \n \n
                  \n
                  \n {{ openDocumentLabel() }}\n {{ closeLabel() }}\n
                  \n
                  \n `,\n})\nexport class BehandelSchermComponent {\n brief = input.required();\n orgTemplate = input.required();\n logoUrl = input(null);\n availablePassages = input([]);\n diagnostics = input([]);\n caseContext = input.required();\n canSubmit = input(false);\n busy = input(false);\n /** Server decision (PRD-0002 §5c): may this actor unmask the case BIG-nummer? */\n canRevealBigNummer = input(false);\n\n edit = output();\n submit = output();\n preview = output();\n locate = output();\n revealBigNummer = output();\n\n /** The BIG-nummer arrives masked (contains `*`); once revealed the swapped value has\n no `*`, so the reveal action hides itself — no separate \"revealed\" flag needed. */\n protected isMasked = computed(() => this.caseContext().bigNummer.includes('*'));\n\n /** Step-up (PRD-0002 §5d) stubbed as a native confirm — the extra verification gesture\n before an audited PII reveal. ponytail: real systems prompt MFA / recent re-auth. */\n protected onReveal() {\n if (confirm(this.stepUpPrompt())) this.revealBigNummer.emit();\n }\n\n private previewDialog = viewChild>('previewDialog');\n\n protected status = computed(() => this.brief().status.tag);\n protected rejectComments = computed(() => {\n const s = this.brief().status;\n return s.tag === 'rejected' ? s.comments : '';\n });\n\n /** The besluit + redenen the letter currently reflects, read back off the kern's\n passages — this seeds the panel so it survives reload/undo (no separate storage). */\n protected selection = computed(() => {\n const kern = this.brief().sections.find((s) => s.sectionKey === 'kern');\n return inferSelection(kern?.blocks ?? [], this.availablePassages());\n });\n\n // Same insert menu as the composer: only valid, fillable, non-deprecated fields.\n protected menu = computed(() =>\n this.brief()\n .placeholders.filter((p) => p.fillable !== false && !p.deprecated)\n .map((p) => ({ key: p.key, label: p.label, autoResolvable: p.autoResolvable })),\n );\n\n /** Besluit/redenen changed → recompose the kern as one edit (= one undo step). */\n protected onSelection(sel: { besluit: Besluit | null; reasons: string[] }) {\n this.edit.emit({ tag: 'BesluitSelected', besluit: sel.besluit, reasons: sel.reasons });\n }\n\n protected openPreview() {\n this.previewDialog()?.nativeElement.showModal();\n }\n protected closePreview() {\n this.previewDialog()?.nativeElement.close();\n }\n\n protected submitLabel = computed(() =>\n this.status() === 'rejected'\n ? $localize`:@@brief.resubmit:Opnieuw indienen`\n : $localize`:@@brief.submit:Indienen ter beoordeling`,\n );\n\n protected steps = input([\n $localize`:@@brief.step.beoordelen:Beoordelen`,\n $localize`:@@brief.step.opstellen:Brief opstellen`,\n $localize`:@@brief.step.indienen:Indienen`,\n ]);\n protected processName = input($localize`:@@brief.process:Herregistratie behandelen`);\n protected stepTitle = input($localize`:@@brief.step.opstellen:Brief opstellen`);\n protected caseHeading = input($localize`:@@brief.case.heading:Aanvraag herregistratie`);\n protected bigLabel = input($localize`:@@brief.case.big:BIG-nummer`);\n protected revealLabel = input($localize`:@@brief.case.reveal:Toon BIG-nummer`);\n protected stepUpPrompt = input(\n $localize`:@@brief.case.revealConfirm:Extra verificatie vereist. Het tonen van het BIG-nummer wordt vastgelegd. Doorgaan?`,\n );\n protected previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);\n protected openDocumentLabel = input(\n $localize`:@@brief.preview.openDocument:Openen als document (PDF)`,\n );\n protected closeLabel = input($localize`:@@common.close:Sluiten`);\n protected submitHint = input(\n $localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`,\n );\n}\n", + "sourceCode": "import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core';\nimport { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { HeadingComponent } from '@shared/ui/heading/heading.component';\nimport { StepperComponent } from '@shared/ui/stepper/stepper.component';\nimport { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';\nimport { inferSelection } from '@brief/domain/besluit';\nimport { Diagnostic } from '@brief/domain/placeholders';\nimport { OrgTemplate } from '@brief/domain/org-template';\nimport { BriefMsg } from '@brief/domain/brief.machine';\nimport { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';\nimport { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';\nimport { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';\nimport { LetterEditorComponent } from '@brief/ui/letter-editor/letter-editor.component';\nimport { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.component';\n\n/** Organism: the behandelaar's drafting step. Frames \"Brief opstellen\" as one step in\n the case workflow — a case-context header + stepper (Beoordelen → Brief opstellen →\n Indienen, neighbours stubbed) — with the besluit-driven guidance, the lean letter\n editor, and an on-demand full-letter preview in a modal. Only ever renders for an\n editable brief (draft/rejected); the approver's read-only flow stays in\n letter-composer. Presentational: emits edit/submit/preview intents. */\n@Component({\n selector: 'app-behandel-scherm',\n imports: [\n ButtonComponent,\n HeadingComponent,\n StepperComponent,\n LetterCanvasComponent,\n DiagnosticsPanelComponent,\n RejectionCommentsComponent,\n LetterEditorComponent,\n BesluitPanelComponent,\n ],\n styles: [\n `\n :host {\n display: block;\n }\n .case-head {\n margin-block-end: var(--rhc-space-max-lg);\n padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);\n border-inline-start: 4px solid var(--rhc-color-primary, var(--rhc-color-border-strong));\n background: var(--rhc-color-background-subtle, transparent);\n }\n .case-meta {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n color: var(--rhc-color-foreground-subtle);\n }\n .step-body {\n display: grid;\n gap: var(--rhc-space-max-xl);\n margin-block-start: var(--rhc-space-max-lg);\n }\n .bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n align-items: center;\n margin-block-start: var(--rhc-space-max-xl);\n }\n dialog {\n border: none;\n border-radius: var(--rhc-radius-md, 4px);\n padding: 0;\n max-width: min(900px, 95vw);\n width: 100%;\n }\n dialog::backdrop {\n background: rgb(0 0 0 / 45%); /* token-ok: modal scrim, not a palette colour */\n }\n .modal-body {\n padding: var(--rhc-space-max-lg);\n max-height: 80vh;\n overflow: auto;\n }\n .modal-bar {\n display: flex;\n justify-content: space-between;\n gap: var(--rhc-space-max-md);\n padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);\n border-block-start: 1px solid var(--rhc-color-border);\n }\n `,\n ],\n template: `\n
                  \n {{ caseHeading() }}\n
                  \n {{ caseContext().aanvraagReferentie }}\n {{ caseContext().zorgverlenerNaam }}\n \n {{ bigLabel() }} {{ caseContext().bigNummer }}\n @if (canRevealBigNummer() && isMasked()) {\n {{ revealLabel() }}\n }\n \n {{ caseContext().beroep }}\n
                  \n
                  \n\n \n\n
                  \n @if (status() === 'rejected') {\n \n }\n\n \n\n \n\n \n\n
                  \n {{ previewLabel() }}\n {{\n submitLabel()\n }}\n @if (!canSubmit()) {\n {{ submitHint() }}\n }\n
                  \n
                  \n\n \n
                  \n \n
                  \n
                  \n {{\n openDocumentLabel()\n }}\n {{ closeLabel() }}\n
                  \n
                  \n `,\n})\nexport class BehandelSchermComponent {\n brief = input.required();\n orgTemplate = input.required();\n logoUrl = input(null);\n availablePassages = input([]);\n diagnostics = input([]);\n caseContext = input.required();\n canSubmit = input(false);\n busy = input(false);\n /** Server decision (PRD-0002 §5c): may this actor unmask the case BIG-nummer? */\n canRevealBigNummer = input(false);\n\n edit = output();\n submit = output();\n preview = output();\n locate = output();\n revealBigNummer = output();\n\n /** The BIG-nummer arrives masked (contains `*`); once revealed the swapped value has\n no `*`, so the reveal action hides itself — no separate \"revealed\" flag needed. */\n protected isMasked = computed(() => this.caseContext().bigNummer.includes('*'));\n\n /** Step-up (PRD-0002 §5d) stubbed as a native confirm — the extra verification gesture\n before an audited PII reveal. ponytail: real systems prompt MFA / recent re-auth. */\n protected onReveal() {\n if (confirm(this.stepUpPrompt())) this.revealBigNummer.emit();\n }\n\n private previewDialog = viewChild>('previewDialog');\n\n protected status = computed(() => this.brief().status.tag);\n protected rejectComments = computed(() => {\n const s = this.brief().status;\n return s.tag === 'rejected' ? s.comments : '';\n });\n\n /** The besluit + redenen the letter currently reflects, read back off the kern's\n passages — this seeds the panel so it survives reload/undo (no separate storage). */\n protected selection = computed(() => {\n const kern = this.brief().sections.find((s) => s.sectionKey === 'kern');\n return inferSelection(kern?.blocks ?? [], this.availablePassages());\n });\n\n // Same insert menu as the composer: only valid, fillable, non-deprecated fields.\n protected menu = computed(() =>\n this.brief()\n .placeholders.filter((p) => p.fillable !== false && !p.deprecated)\n .map((p) => ({ key: p.key, label: p.label, autoResolvable: p.autoResolvable })),\n );\n\n /** Besluit/redenen changed → recompose the kern as one edit (= one undo step). */\n protected onSelection(sel: { besluit: Besluit | null; reasons: string[] }) {\n this.edit.emit({ tag: 'BesluitSelected', besluit: sel.besluit, reasons: sel.reasons });\n }\n\n protected openPreview() {\n this.previewDialog()?.nativeElement.showModal();\n }\n protected closePreview() {\n this.previewDialog()?.nativeElement.close();\n }\n\n protected submitLabel = computed(() =>\n this.status() === 'rejected'\n ? $localize`:@@brief.resubmit:Opnieuw indienen`\n : $localize`:@@brief.submit:Indienen ter beoordeling`,\n );\n\n protected steps = input([\n $localize`:@@brief.step.beoordelen:Beoordelen`,\n $localize`:@@brief.step.opstellen:Brief opstellen`,\n $localize`:@@brief.step.indienen:Indienen`,\n ]);\n protected processName = input($localize`:@@brief.process:Herregistratie behandelen`);\n protected stepTitle = input($localize`:@@brief.step.opstellen:Brief opstellen`);\n protected caseHeading = input($localize`:@@brief.case.heading:Aanvraag herregistratie`);\n protected bigLabel = input($localize`:@@brief.case.big:BIG-nummer`);\n protected revealLabel = input($localize`:@@brief.case.reveal:Toon BIG-nummer`);\n protected stepUpPrompt = input(\n $localize`:@@brief.case.revealConfirm:Extra verificatie vereist. Het tonen van het BIG-nummer wordt vastgelegd. Doorgaan?`,\n );\n protected previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);\n protected openDocumentLabel = input(\n $localize`:@@brief.preview.openDocument:Openen als document (PDF)`,\n );\n protected closeLabel = input($localize`:@@common.close:Sluiten`);\n protected submitHint = input(\n $localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`,\n );\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "\n :host {\n display: block;\n }\n .case-head {\n margin-block-end: var(--rhc-space-max-lg);\n padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);\n border-inline-start: 4px solid var(--rhc-color-primary, var(--rhc-color-border-strong));\n background: var(--rhc-color-background-subtle, transparent);\n }\n .case-meta {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n color: var(--rhc-color-foreground-subtle);\n }\n .step-body {\n display: grid;\n gap: var(--rhc-space-max-xl);\n margin-block-start: var(--rhc-space-max-lg);\n }\n .bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n align-items: center;\n margin-block-start: var(--rhc-space-max-xl);\n }\n dialog {\n border: none;\n border-radius: var(--rhc-radius-md, 4px);\n padding: 0;\n max-width: min(900px, 95vw);\n width: 100%;\n }\n dialog::backdrop {\n background: rgb(0 0 0 / 45%); /* token-ok: modal scrim, not a palette colour */\n }\n .modal-body {\n padding: var(--rhc-space-max-lg);\n max-height: 80vh;\n overflow: auto;\n }\n .modal-bar {\n display: flex;\n justify-content: space-between;\n gap: var(--rhc-space-max-md);\n padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);\n border-block-start: 1px solid var(--rhc-color-border);\n }\n \n", @@ -22485,7 +22485,7 @@ }, { "name": "LetterCanvasComponent", - "id": "component-LetterCanvasComponent-57a8e8d85a7853c56c3057d769d6ed2043e022e122e65bd26c624f3b0d8f3af6560212d6ebdffc741e01feaab7582b31b56d4aa8761ae6283d51f0a59fb199cf", + "id": "component-LetterCanvasComponent-7d98a63450286b3b1374bbf8d6394fab96d14a5f4e9c25cd597d8e05938592e44168fb388d401505fd6a24a09a44ae12b5ee082c3e6e77ddc8fabc63a4f47636", "file": "src/app/brief/ui/letter-canvas/letter-canvas.component.ts", "encapsulation": [], "entryComponents": [], @@ -22497,7 +22497,7 @@ "styles": [ "\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: var(--rhc-space-max-sm);\n margin-block-end: var(--rhc-space-max-sm);\n }\n .zoom {\n display: flex;\n align-items: center;\n gap: var(--rhc-space-max-sm);\n }\n .zoom-pct {\n min-width: 3.5ch;\n text-align: center;\n color: var(--rhc-color-foreground-subtle);\n font-variant-numeric: tabular-nums;\n }\n /* Rejection-diff badge (WP-27): a small pill above a changed/added block. */\n .diff-block.diff-changed {\n border-inline-start: 3px solid var(--rhc-color-oranje-500);\n padding-inline-start: var(--rhc-space-max-sm);\n }\n .diff-badge {\n display: inline-block;\n margin-block-end: 1mm;\n padding: 0 1.5mm;\n border-radius: var(--rhc-border-radius-sm);\n font-size: 7.5pt;\n /* changed = dark text on oranje-500 (4.79:1); white on oranje-500 fails (3.23:1). */\n color: var(--rhc-color-foreground-default);\n background: var(--rhc-color-oranje-500);\n }\n .diff-badge.added {\n /* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (WP-29 axe). */\n color: var(--rhc-color-wit);\n background: var(--rhc-color-groen-700);\n }\n /* Portal-side chrome around the letter surface (not part of the contract file). */\n .surface {\n background: var(--rhc-color-grijs-100);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-lg);\n overflow-x: auto;\n }\n .surface .letter {\n box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */\n }\n /* Admin edit-in-place (editableRegions='template'): the org-identity fields\n become controls styled to sit in the letter, with a visible editable affordance. */\n .tmpl-input,\n .tmpl-textarea {\n font: inherit;\n color: inherit;\n width: 100%;\n box-sizing: border-box;\n background: var(--rhc-color-geel-100);\n border: var(--rhc-border-width-sm) dashed var(--rhc-color-border-strong);\n border-radius: var(--rhc-border-radius-sm);\n padding: 0.5mm 1mm;\n }\n .tmpl-textarea {\n resize: vertical;\n }\n .org-logo {\n max-height: 20mm;\n max-width: 60mm;\n margin-block-end: 3mm;\n }\n " ], - "template": "\n @for (node of nodes; track $index) {\n @switch (node.type) {\n @case ('text') {\n {{ node.text }}\n }\n @case ('lineBreak') {\n
                  \n }\n @case ('placeholder') {\n @if (showSample() && autoFor(node.key)) {\n {{ sampleFor(node.key) }}\n } @else {\n \n }\n }\n }\n }\n
                  \n\n@if (editableRegions() !== 'template') {\n
                  \n
                  \n \n {{ zoomPct() }}\n = 1.5\"\n [attr.aria-label]=\"zoomInLabel()\"\n (click)=\"zoomBy(0.1)\"\n >+\n {{ zoomResetLabel() }}\n
                  \n @if (editableRegions() === 'none') {\n \n {{ showSample() ? hideSampleLabel() : showSampleLabel() }}\n \n }\n
                  \n}\n\n
                  \n
                  \n \n
                  \n @if (logoUrl()) {\n \n }\n @if (editing()) {\n \n \n } @else {\n

                  {{ orgTemplate().orgName }}

                  \n
                  {{ orgTemplate().returnAddress }}
                  \n }\n
                  {{ recipientText() }}
                  \n
                  \n
                  \n
                  {{ referenceLabel() }}
                  \n
                  {{ brief().briefId }}
                  \n
                  \n
                  \n
                  {{ dateLabel() }}
                  \n
                  {{ letterDate }}
                  \n
                  \n
                  \n
                  \n\n
                  \n @for (section of brief().sections; track section.sectionKey) {\n
                  \n

                  {{ section.title }}

                  \n @for (block of section.blocks; track block.blockId) {\n @let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;\n
                  \n @if (diffKind) {\n {{\n diffLabel(diffKind)\n }}\n }\n @for (seg of segmentsOf(block); track $index) {\n @if (seg.list === 'bullet') {\n
                    \n @for (para of seg.items; track $index) {\n
                  • \n \n
                  • \n }\n
                  \n } @else if (seg.list === 'number') {\n
                    \n @for (para of seg.items; track $index) {\n
                  1. \n \n
                  2. \n }\n
                  \n } @else {\n

                  \n \n

                  \n }\n }\n
                  \n }\n
                  \n }\n
                  \n\n
                  \n @if (editing()) {\n \n \n \n } @else {\n

                  {{ orgTemplate().signatureClosing }}

                  \n

                  {{ orgTemplate().signatureName }}

                  \n

                  {{ orgTemplate().signatureRole }}

                  \n }\n
                  \n\n
                  \n @if (editing()) {\n \n \n } @else {\n
                  {{ orgTemplate().footerContact }}
                  \n
                  {{ orgTemplate().footerLegal }}
                  \n }\n
                  \n\n @for (top of pageBreaks(); track $index) {\n
                  \n {{ pageBreakCaption() }}\n
                  \n }\n
                  \n
                  \n", + "template": "\n @for (node of nodes; track $index) {\n @switch (node.type) {\n @case ('text') {\n {{ node.text }}\n }\n @case ('lineBreak') {\n
                  \n }\n @case ('placeholder') {\n @if (showSample() && autoFor(node.key)) {\n {{ sampleFor(node.key) }}\n } @else {\n \n }\n }\n }\n }\n
                  \n\n@if (editableRegions() !== 'template') {\n
                  \n
                  \n \n {{ zoomPct() }}\n = 1.5\"\n [attr.aria-label]=\"zoomInLabel()\"\n (click)=\"zoomBy(0.1)\"\n >+\n {{\n zoomResetLabel()\n }}\n
                  \n @if (editableRegions() === 'none') {\n \n {{ showSample() ? hideSampleLabel() : showSampleLabel() }}\n \n }\n
                  \n}\n\n
                  \n
                  \n \n
                  \n @if (logoUrl()) {\n \n }\n @if (editing()) {\n \n \n } @else {\n

                  {{ orgTemplate().orgName }}

                  \n
                  {{ orgTemplate().returnAddress }}
                  \n }\n
                  {{ recipientText() }}
                  \n
                  \n
                  \n
                  {{ referenceLabel() }}
                  \n
                  {{ brief().briefId }}
                  \n
                  \n
                  \n
                  {{ dateLabel() }}
                  \n
                  {{ letterDate }}
                  \n
                  \n
                  \n
                  \n\n
                  \n @for (section of brief().sections; track section.sectionKey) {\n
                  \n

                  {{ section.title }}

                  \n @for (block of section.blocks; track block.blockId) {\n @let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;\n
                  \n @if (diffKind) {\n {{\n diffLabel(diffKind)\n }}\n }\n @for (seg of segmentsOf(block); track $index) {\n @if (seg.list === 'bullet') {\n
                    \n @for (para of seg.items; track $index) {\n
                  • \n \n
                  • \n }\n
                  \n } @else if (seg.list === 'number') {\n
                    \n @for (para of seg.items; track $index) {\n
                  1. \n \n
                  2. \n }\n
                  \n } @else {\n

                  \n \n

                  \n }\n }\n
                  \n }\n
                  \n }\n
                  \n\n
                  \n @if (editing()) {\n \n \n \n } @else {\n

                  {{ orgTemplate().signatureClosing }}

                  \n

                  {{ orgTemplate().signatureName }}

                  \n

                  {{ orgTemplate().signatureRole }}

                  \n }\n
                  \n\n
                  \n @if (editing()) {\n \n \n } @else {\n
                  {{ orgTemplate().footerContact }}
                  \n
                  {{ orgTemplate().footerLegal }}
                  \n }\n
                  \n\n @for (top of pageBreaks(); track $index) {\n
                  \n {{ pageBreakCaption() }}\n
                  \n }\n
                  \n
                  \n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -22510,7 +22510,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 382, + "line": 384, "required": false }, { @@ -22522,7 +22522,7 @@ "indexKey": "", "optional": false, "description": "

                  Blocks changed/added/removed since the letter was rejected (WP-27); badged when\nshowDiff is on. Removed blocks aren't in the map's rendered set — they no longer\nexist in the letter — the composer surfaces them as a count.

                  \n", - "line": 353, + "line": 355, "rawdescription": "\nBlocks changed/added/removed since the letter was rejected (WP-27); badged when\n`showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer\nexist in the letter — the composer surfaces them as a count.", "required": false }, @@ -22534,7 +22534,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 342, + "line": 344, "required": true }, { @@ -22545,7 +22545,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 383, + "line": 385, "required": false }, { @@ -22556,7 +22556,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 369, + "line": 371, "required": false }, { @@ -22568,7 +22568,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 347, + "line": 349, "required": false }, { @@ -22580,7 +22580,7 @@ "indexKey": "", "optional": false, "description": "

                  Who edits what on the surface: read-only ('none', the drafter preview + approver\nview) or admin editor ('template', WP-26). Authoring moved to letter-editor.

                  \n", - "line": 346, + "line": 348, "rawdescription": "\nWho edits what on the surface: read-only ('none', the drafter preview + approver\nview) or admin editor ('template', WP-26). Authoring moved to letter-editor.", "required": false }, @@ -22592,7 +22592,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 376, + "line": 378, "required": false }, { @@ -22603,7 +22603,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 377, + "line": 379, "required": false }, { @@ -22614,7 +22614,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 361, + "line": 363, "required": false }, { @@ -22625,7 +22625,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 370, + "line": 372, "required": false }, { @@ -22637,7 +22637,7 @@ "indexKey": "", "optional": false, "description": "

                  The org logo's content URL (letterhead), or null when none is set.

                  \n", - "line": 356, + "line": 358, "rawdescription": "\nThe org logo's content URL (letterhead), or null when none is set.", "required": false }, @@ -22649,7 +22649,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 371, + "line": 373, "required": false }, { @@ -22660,7 +22660,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 343, + "line": 345, "required": true }, { @@ -22671,7 +22671,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 362, + "line": 364, "required": false }, { @@ -22682,7 +22682,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 365, + "line": 367, "required": false }, { @@ -22693,7 +22693,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 368, + "line": 370, "required": false }, { @@ -22704,7 +22704,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 372, + "line": 374, "required": false }, { @@ -22715,7 +22715,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 354, + "line": 356, "required": false }, { @@ -22726,7 +22726,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 360, + "line": 362, "required": false }, { @@ -22737,7 +22737,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 373, + "line": 375, "required": false }, { @@ -22748,7 +22748,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 374, + "line": 376, "required": false }, { @@ -22759,7 +22759,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 375, + "line": 377, "required": false }, { @@ -22770,7 +22770,7 @@ "indexKey": "", "optional": false, "description": "

                  Initial zoom; the in-canvas controls take over from here (WP-27).

                  \n", - "line": 349, + "line": 351, "rawdescription": "\nInitial zoom; the in-canvas controls take over from here (WP-27).", "required": false }, @@ -22782,7 +22782,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 378, + "line": 380, "required": false }, { @@ -22793,7 +22793,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 379, + "line": 381, "required": false }, { @@ -22804,7 +22804,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 380, + "line": 382, "required": false }, { @@ -22815,7 +22815,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 381, + "line": 383, "required": false } ], @@ -22828,7 +22828,7 @@ "indexKey": "", "optional": false, "description": "

                  An in-place edit to an org-identity field (only in editableRegions='template').

                  \n", - "line": 358, + "line": 360, "rawdescription": "\nAn in-place edit to an org-identity field (only in `editableRegions='template'`).", "required": false } @@ -22843,7 +22843,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 430, + "line": 435, "modifierKind": [ 124 ] @@ -22857,7 +22857,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 417, + "line": 422, "modifierKind": [ 123 ] @@ -22871,7 +22871,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 395, + "line": 397, "modifierKind": [ 124 ] @@ -22885,7 +22885,7 @@ "indexKey": "", "optional": false, "description": "

                  Admin edit-in-place: the org-identity regions render as controls.

                  \n", - "line": 399, + "line": 401, "rawdescription": "\nAdmin edit-in-place: the org-identity regions render as controls.", "modifierKind": [ 124 @@ -22900,7 +22900,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 429, + "line": 434, "modifierKind": [ 124 ] @@ -22914,7 +22914,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 386, + "line": 388, "modifierKind": [ 124 ] @@ -22928,7 +22928,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 405, + "line": 410, "modifierKind": [ 124 ] @@ -22942,7 +22942,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 437, + "line": 442, "modifierKind": [ 123 ] @@ -22956,7 +22956,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 438, + "line": 443, "modifierKind": [ 124 ] @@ -22970,7 +22970,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 432, + "line": 437, "modifierKind": [ 124 ] @@ -22984,7 +22984,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 428, + "line": 433, "modifierKind": [ 124 ] @@ -22998,7 +22998,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 385, + "line": 387, "modifierKind": [ 124 ] @@ -23012,7 +23012,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 431, + "line": 436, "modifierKind": [ 124 ] @@ -23026,7 +23026,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 418, + "line": 423, "modifierKind": [ 123 ] @@ -23040,7 +23040,7 @@ "indexKey": "", "optional": false, "description": "

                  Zoom seeded from the input; the +/−/reset controls drive it from there.

                  \n", - "line": 389, + "line": 391, "rawdescription": "\nZoom seeded from the input; the +/−/reset controls drive it from there.", "modifierKind": [ 124 @@ -23055,7 +23055,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 390, + "line": 392, "modifierKind": [ 124 ] @@ -23085,7 +23085,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 401, + "line": 403, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -23131,7 +23131,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 391, + "line": 393, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -23173,7 +23173,7 @@ "description": "

                  Organism: the letter as one surface — the org template's letterhead, signature and\nfooter around the case-type template's sections. editableRegions picks who edits\nwhat: 'content' hosts the editable letter-sections in place (drafter), 'none'\nrenders everything read-only (approver/locked, absorbs the old letter-preview),\n'template' reserves the org-identity regions for the admin editor (WP-26).\nLetter typography/geometry come from the shared public/letter.css contract —\nthe same file the backend preview renderer inlines (WP-25).

                  \n", "rawdescription": "\nOrganism: the letter as one surface — the org template's letterhead, signature and\nfooter around the case-type template's sections. `editableRegions` picks who edits\nwhat: `'content'` hosts the editable letter-sections in place (drafter), `'none'`\nrenders everything read-only (approver/locked, absorbs the old letter-preview),\n`'template'` reserves the org-identity regions for the admin editor (WP-26).\nLetter typography/geometry come from the shared `public/letter.css` contract —\nthe same file the backend preview renderer inlines (WP-25).", "type": "component", - "sourceCode": "import {\n Component,\n DestroyRef,\n ElementRef,\n computed,\n effect,\n inject,\n input,\n linkedSignal,\n output,\n signal,\n viewChild,\n} from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';\nimport { formatDatumNl } from '@shared/kernel/datum';\nimport { Paragraph } from '@shared/kernel/rich-text';\nimport { Brief, LetterBlock } from '@brief/domain/brief';\nimport { OrgTemplate } from '@brief/domain/org-template';\nimport { OrgTemplateTextField } from '@brief/domain/org-template.machine';\nimport { Diagnostic } from '@brief/domain/placeholders';\nimport { BlockDiffKind } from '@brief/domain/brief-diff';\n\n/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */\ntype PreviewSegment = {\n readonly list: 'bullet' | 'number' | null;\n readonly items: readonly Paragraph[];\n};\n\nfunction groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {\n const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];\n for (const para of paras) {\n const kind = para.list ?? null;\n const last = out[out.length - 1];\n if (kind && last && last.list === kind) last.items.push(para);\n else out.push({ list: kind, items: [para] });\n }\n return out;\n}\n\n// Illustrative values for the \"Voorbeeld\" toggle — what send resolves server-side.\nconst SAMPLE_VALUES: Record = {\n naam_zorgverlener: 'J. Jansen',\n big_nummer: '12345678901',\n};\n\n/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */\nconst A4_HEIGHT_PX = (297 * 96) / 25.4;\n\n/** Organism: the letter as one surface — the org template's letterhead, signature and\n footer around the case-type template's sections. `editableRegions` picks who edits\n what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`\n renders everything read-only (approver/locked, absorbs the old letter-preview),\n `'template'` reserves the org-identity regions for the admin editor (WP-26).\n Letter typography/geometry come from the shared `public/letter.css` contract —\n the same file the backend preview renderer inlines (WP-25). */\n@Component({\n selector: 'app-letter-canvas',\n imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent],\n styles: [\n `\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: var(--rhc-space-max-sm);\n margin-block-end: var(--rhc-space-max-sm);\n }\n .zoom {\n display: flex;\n align-items: center;\n gap: var(--rhc-space-max-sm);\n }\n .zoom-pct {\n min-width: 3.5ch;\n text-align: center;\n color: var(--rhc-color-foreground-subtle);\n font-variant-numeric: tabular-nums;\n }\n /* Rejection-diff badge (WP-27): a small pill above a changed/added block. */\n .diff-block.diff-changed {\n border-inline-start: 3px solid var(--rhc-color-oranje-500);\n padding-inline-start: var(--rhc-space-max-sm);\n }\n .diff-badge {\n display: inline-block;\n margin-block-end: 1mm;\n padding: 0 1.5mm;\n border-radius: var(--rhc-border-radius-sm);\n font-size: 7.5pt;\n /* changed = dark text on oranje-500 (4.79:1); white on oranje-500 fails (3.23:1). */\n color: var(--rhc-color-foreground-default);\n background: var(--rhc-color-oranje-500);\n }\n .diff-badge.added {\n /* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (WP-29 axe). */\n color: var(--rhc-color-wit);\n background: var(--rhc-color-groen-700);\n }\n /* Portal-side chrome around the letter surface (not part of the contract file). */\n .surface {\n background: var(--rhc-color-grijs-100);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-lg);\n overflow-x: auto;\n }\n .surface .letter {\n box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */\n }\n /* Admin edit-in-place (editableRegions='template'): the org-identity fields\n become controls styled to sit in the letter, with a visible editable affordance. */\n .tmpl-input,\n .tmpl-textarea {\n font: inherit;\n color: inherit;\n width: 100%;\n box-sizing: border-box;\n background: var(--rhc-color-geel-100);\n border: var(--rhc-border-width-sm) dashed var(--rhc-color-border-strong);\n border-radius: var(--rhc-border-radius-sm);\n padding: 0.5mm 1mm;\n }\n .tmpl-textarea {\n resize: vertical;\n }\n .org-logo {\n max-height: 20mm;\n max-width: 60mm;\n margin-block-end: 3mm;\n }\n `,\n ],\n template: `\n \n @for (node of nodes; track $index) {\n @switch (node.type) {\n @case ('text') {\n {{ node.text }}\n }\n @case ('lineBreak') {\n
                  \n }\n @case ('placeholder') {\n @if (showSample() && autoFor(node.key)) {\n {{ sampleFor(node.key) }}\n } @else {\n \n }\n }\n }\n }\n
                  \n\n @if (editableRegions() !== 'template') {\n
                  \n
                  \n \n {{ zoomPct() }}\n = 1.5\"\n [attr.aria-label]=\"zoomInLabel()\"\n (click)=\"zoomBy(0.1)\"\n >+\n {{ zoomResetLabel() }}\n
                  \n @if (editableRegions() === 'none') {\n \n {{ showSample() ? hideSampleLabel() : showSampleLabel() }}\n \n }\n
                  \n }\n\n
                  \n
                  \n \n
                  \n @if (logoUrl()) {\n \n }\n @if (editing()) {\n \n \n } @else {\n

                  {{ orgTemplate().orgName }}

                  \n
                  {{ orgTemplate().returnAddress }}
                  \n }\n
                  {{ recipientText() }}
                  \n
                  \n
                  \n
                  {{ referenceLabel() }}
                  \n
                  {{ brief().briefId }}
                  \n
                  \n
                  \n
                  {{ dateLabel() }}
                  \n
                  {{ letterDate }}
                  \n
                  \n
                  \n
                  \n\n
                  \n @for (section of brief().sections; track section.sectionKey) {\n
                  \n

                  {{ section.title }}

                  \n @for (block of section.blocks; track block.blockId) {\n @let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;\n
                  \n @if (diffKind) {\n {{\n diffLabel(diffKind)\n }}\n }\n @for (seg of segmentsOf(block); track $index) {\n @if (seg.list === 'bullet') {\n
                    \n @for (para of seg.items; track $index) {\n
                  • \n \n
                  • \n }\n
                  \n } @else if (seg.list === 'number') {\n
                    \n @for (para of seg.items; track $index) {\n
                  1. \n \n
                  2. \n }\n
                  \n } @else {\n

                  \n \n

                  \n }\n }\n
                  \n }\n
                  \n }\n
                  \n\n
                  \n @if (editing()) {\n \n \n \n } @else {\n

                  {{ orgTemplate().signatureClosing }}

                  \n

                  {{ orgTemplate().signatureName }}

                  \n

                  {{ orgTemplate().signatureRole }}

                  \n }\n
                  \n\n
                  \n @if (editing()) {\n \n \n } @else {\n
                  {{ orgTemplate().footerContact }}
                  \n
                  {{ orgTemplate().footerLegal }}
                  \n }\n
                  \n\n @for (top of pageBreaks(); track $index) {\n
                  \n {{ pageBreakCaption() }}\n
                  \n }\n
                  \n
                  \n `,\n})\nexport class LetterCanvasComponent {\n brief = input.required();\n orgTemplate = input.required();\n /** Who edits what on the surface: read-only ('none', the drafter preview + approver\n view) or admin editor ('template', WP-26). Authoring moved to letter-editor. */\n editableRegions = input<'template' | 'none'>('none');\n diagnostics = input([]);\n /** Initial zoom; the in-canvas controls take over from here (WP-27). */\n zoom = input(1);\n /** Blocks changed/added/removed since the letter was rejected (WP-27); badged when\n `showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer\n exist in the letter — the composer surfaces them as a count. */\n blockDiffs = input>(new Map());\n showDiff = input(false);\n /** The org logo's content URL (letterhead), or null when none is set. */\n logoUrl = input(null);\n /** An in-place edit to an org-identity field (only in `editableRegions='template'`). */\n templateEdit = output<{ field: OrgTemplateTextField; value: string }>();\n\n showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);\n hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);\n pageBreakCaption = input(\n $localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,\n );\n recipientText = input(\n $localize`:@@brief.canvas.recipient:Adres van de geadresseerde\\n(wordt ingevuld bij verzending)`,\n );\n referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);\n dateLabel = input($localize`:@@brief.canvas.date:Datum`);\n logoAlt = input($localize`:@@brief.canvas.logoAlt:Logo van de organisatie`);\n orgNameLabel = input($localize`:@@brief.canvas.orgName:Organisatienaam`);\n returnAddressLabel = input($localize`:@@brief.canvas.returnAddress:Retouradres`);\n signatureClosingLabel = input($localize`:@@brief.canvas.signatureClosing:Afsluiting`);\n signatureNameLabel = input($localize`:@@brief.canvas.signatureName:Naam ondertekenaar`);\n signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);\n footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);\n footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);\n zoomGroupLabel = input($localize`:@@brief.canvas.zoom:Zoomniveau`);\n zoomInLabel = input($localize`:@@brief.canvas.zoomIn:Inzoomen`);\n zoomOutLabel = input($localize`:@@brief.canvas.zoomOut:Uitzoomen`);\n zoomResetLabel = input($localize`:@@brief.canvas.zoomReset:100%`);\n addedLabel = input($localize`:@@brief.diff.added:nieuw`);\n changedLabel = input($localize`:@@brief.diff.changed:gewijzigd sinds afwijzing`);\n\n protected showSample = signal(false);\n protected letterDate = formatDatumNl(new Date());\n\n /** Zoom seeded from the input; the +/−/reset controls drive it from there. */\n protected zoomLevel = linkedSignal(() => this.zoom());\n protected zoomPct = computed(() => `${Math.round(this.zoomLevel() * 100)}%`);\n protected zoomBy(delta: number) {\n // clamp 0.5–1.5; round to avoid float drift accumulating on repeated clicks.\n this.zoomLevel.update((z) => Math.round(Math.min(1.5, Math.max(0.5, z + delta)) * 10) / 10);\n }\n protected diffLabel = (kind: BlockDiffKind) =>\n kind === 'added' ? this.addedLabel() : this.changedLabel();\n\n /** Admin edit-in-place: the org-identity regions render as controls. */\n protected editing = computed(() => this.editableRegions() === 'template');\n\n protected emitEdit(field: OrgTemplateTextField, event: Event) {\n this.templateEdit.emit({ field, value: (event.target as HTMLInputElement | HTMLTextAreaElement).value });\n }\n\n protected marginStyle = computed(() => {\n const m = this.orgTemplate().margins;\n return {\n '--letter-margin-top': `${m.topMm}mm`,\n '--letter-margin-right': `${m.rightMm}mm`,\n '--letter-margin-bottom': `${m.bottomMm}mm`,\n '--letter-margin-left': `${m.leftMm}mm`,\n };\n });\n\n // --- read-only rendering helpers (migrated from the superseded letter-preview) ---\n\n private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));\n private worst = computed(() => {\n const m = new Map();\n for (const d of this.diagnostics()) {\n if (!d.placeholderKey) continue;\n if (d.severity === 'error') m.set(d.placeholderKey, 'error');\n else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');\n }\n return m;\n });\n\n protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);\n protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;\n protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;\n protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';\n protected sampleFor = (key: string) =>\n SAMPLE_VALUES[key] ?? (key === 'datum' ? this.letterDate : this.labelFor(key));\n\n // --- approximate page-break marks (PRD §2b: honest \"±\", print preview is leading) ---\n\n private page = viewChild>('page');\n protected pageBreaks = signal([]);\n\n constructor() {\n // ponytail: whole-surface height / A4-interval — ignores that a break never truly\n // falls mid-line; the caption says \"±\" and WP-25's server preview is authoritative.\n const observer = new ResizeObserver(([entry]) => {\n // ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.\n const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);\n this.pageBreaks.set(\n Array.from({ length: Math.max(0, pages - 1) }, (_, i) => (i + 1) * A4_HEIGHT_PX),\n );\n });\n effect((onCleanup) => {\n const el = this.page()?.nativeElement;\n if (!el) return;\n observer.observe(el);\n onCleanup(() => observer.unobserve(el));\n });\n inject(DestroyRef).onDestroy(() => observer.disconnect());\n }\n}\n", + "sourceCode": "import {\n Component,\n DestroyRef,\n ElementRef,\n computed,\n effect,\n inject,\n input,\n linkedSignal,\n output,\n signal,\n viewChild,\n} from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';\nimport { formatDatumNl } from '@shared/kernel/datum';\nimport { Paragraph } from '@shared/kernel/rich-text';\nimport { Brief, LetterBlock } from '@brief/domain/brief';\nimport { OrgTemplate } from '@brief/domain/org-template';\nimport { OrgTemplateTextField } from '@brief/domain/org-template.machine';\nimport { Diagnostic } from '@brief/domain/placeholders';\nimport { BlockDiffKind } from '@brief/domain/brief-diff';\n\n/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */\ntype PreviewSegment = {\n readonly list: 'bullet' | 'number' | null;\n readonly items: readonly Paragraph[];\n};\n\nfunction groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {\n const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];\n for (const para of paras) {\n const kind = para.list ?? null;\n const last = out[out.length - 1];\n if (kind && last && last.list === kind) last.items.push(para);\n else out.push({ list: kind, items: [para] });\n }\n return out;\n}\n\n// Illustrative values for the \"Voorbeeld\" toggle — what send resolves server-side.\nconst SAMPLE_VALUES: Record = {\n naam_zorgverlener: 'J. Jansen',\n big_nummer: '12345678901',\n};\n\n/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */\nconst A4_HEIGHT_PX = (297 * 96) / 25.4;\n\n/** Organism: the letter as one surface — the org template's letterhead, signature and\n footer around the case-type template's sections. `editableRegions` picks who edits\n what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`\n renders everything read-only (approver/locked, absorbs the old letter-preview),\n `'template'` reserves the org-identity regions for the admin editor (WP-26).\n Letter typography/geometry come from the shared `public/letter.css` contract —\n the same file the backend preview renderer inlines (WP-25). */\n@Component({\n selector: 'app-letter-canvas',\n imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent],\n styles: [\n `\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: var(--rhc-space-max-sm);\n margin-block-end: var(--rhc-space-max-sm);\n }\n .zoom {\n display: flex;\n align-items: center;\n gap: var(--rhc-space-max-sm);\n }\n .zoom-pct {\n min-width: 3.5ch;\n text-align: center;\n color: var(--rhc-color-foreground-subtle);\n font-variant-numeric: tabular-nums;\n }\n /* Rejection-diff badge (WP-27): a small pill above a changed/added block. */\n .diff-block.diff-changed {\n border-inline-start: 3px solid var(--rhc-color-oranje-500);\n padding-inline-start: var(--rhc-space-max-sm);\n }\n .diff-badge {\n display: inline-block;\n margin-block-end: 1mm;\n padding: 0 1.5mm;\n border-radius: var(--rhc-border-radius-sm);\n font-size: 7.5pt;\n /* changed = dark text on oranje-500 (4.79:1); white on oranje-500 fails (3.23:1). */\n color: var(--rhc-color-foreground-default);\n background: var(--rhc-color-oranje-500);\n }\n .diff-badge.added {\n /* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (WP-29 axe). */\n color: var(--rhc-color-wit);\n background: var(--rhc-color-groen-700);\n }\n /* Portal-side chrome around the letter surface (not part of the contract file). */\n .surface {\n background: var(--rhc-color-grijs-100);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-lg);\n overflow-x: auto;\n }\n .surface .letter {\n box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */\n }\n /* Admin edit-in-place (editableRegions='template'): the org-identity fields\n become controls styled to sit in the letter, with a visible editable affordance. */\n .tmpl-input,\n .tmpl-textarea {\n font: inherit;\n color: inherit;\n width: 100%;\n box-sizing: border-box;\n background: var(--rhc-color-geel-100);\n border: var(--rhc-border-width-sm) dashed var(--rhc-color-border-strong);\n border-radius: var(--rhc-border-radius-sm);\n padding: 0.5mm 1mm;\n }\n .tmpl-textarea {\n resize: vertical;\n }\n .org-logo {\n max-height: 20mm;\n max-width: 60mm;\n margin-block-end: 3mm;\n }\n `,\n ],\n template: `\n \n @for (node of nodes; track $index) {\n @switch (node.type) {\n @case ('text') {\n {{ node.text }}\n }\n @case ('lineBreak') {\n
                  \n }\n @case ('placeholder') {\n @if (showSample() && autoFor(node.key)) {\n {{ sampleFor(node.key) }}\n } @else {\n \n }\n }\n }\n }\n
                  \n\n @if (editableRegions() !== 'template') {\n
                  \n
                  \n \n {{ zoomPct() }}\n = 1.5\"\n [attr.aria-label]=\"zoomInLabel()\"\n (click)=\"zoomBy(0.1)\"\n >+\n {{\n zoomResetLabel()\n }}\n
                  \n @if (editableRegions() === 'none') {\n \n {{ showSample() ? hideSampleLabel() : showSampleLabel() }}\n \n }\n
                  \n }\n\n
                  \n
                  \n \n
                  \n @if (logoUrl()) {\n \n }\n @if (editing()) {\n \n \n } @else {\n

                  {{ orgTemplate().orgName }}

                  \n
                  {{ orgTemplate().returnAddress }}
                  \n }\n
                  {{ recipientText() }}
                  \n
                  \n
                  \n
                  {{ referenceLabel() }}
                  \n
                  {{ brief().briefId }}
                  \n
                  \n
                  \n
                  {{ dateLabel() }}
                  \n
                  {{ letterDate }}
                  \n
                  \n
                  \n
                  \n\n
                  \n @for (section of brief().sections; track section.sectionKey) {\n
                  \n

                  {{ section.title }}

                  \n @for (block of section.blocks; track block.blockId) {\n @let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;\n
                  \n @if (diffKind) {\n {{\n diffLabel(diffKind)\n }}\n }\n @for (seg of segmentsOf(block); track $index) {\n @if (seg.list === 'bullet') {\n
                    \n @for (para of seg.items; track $index) {\n
                  • \n \n
                  • \n }\n
                  \n } @else if (seg.list === 'number') {\n
                    \n @for (para of seg.items; track $index) {\n
                  1. \n \n
                  2. \n }\n
                  \n } @else {\n

                  \n \n

                  \n }\n }\n
                  \n }\n
                  \n }\n
                  \n\n
                  \n @if (editing()) {\n \n \n \n } @else {\n

                  {{ orgTemplate().signatureClosing }}

                  \n

                  {{ orgTemplate().signatureName }}

                  \n

                  {{ orgTemplate().signatureRole }}

                  \n }\n
                  \n\n
                  \n @if (editing()) {\n \n \n } @else {\n
                  {{ orgTemplate().footerContact }}
                  \n
                  {{ orgTemplate().footerLegal }}
                  \n }\n
                  \n\n @for (top of pageBreaks(); track $index) {\n
                  \n {{ pageBreakCaption() }}\n
                  \n }\n
                  \n
                  \n `,\n})\nexport class LetterCanvasComponent {\n brief = input.required();\n orgTemplate = input.required();\n /** Who edits what on the surface: read-only ('none', the drafter preview + approver\n view) or admin editor ('template', WP-26). Authoring moved to letter-editor. */\n editableRegions = input<'template' | 'none'>('none');\n diagnostics = input([]);\n /** Initial zoom; the in-canvas controls take over from here (WP-27). */\n zoom = input(1);\n /** Blocks changed/added/removed since the letter was rejected (WP-27); badged when\n `showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer\n exist in the letter — the composer surfaces them as a count. */\n blockDiffs = input>(new Map());\n showDiff = input(false);\n /** The org logo's content URL (letterhead), or null when none is set. */\n logoUrl = input(null);\n /** An in-place edit to an org-identity field (only in `editableRegions='template'`). */\n templateEdit = output<{ field: OrgTemplateTextField; value: string }>();\n\n showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);\n hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);\n pageBreakCaption = input(\n $localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,\n );\n recipientText = input(\n $localize`:@@brief.canvas.recipient:Adres van de geadresseerde\\n(wordt ingevuld bij verzending)`,\n );\n referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);\n dateLabel = input($localize`:@@brief.canvas.date:Datum`);\n logoAlt = input($localize`:@@brief.canvas.logoAlt:Logo van de organisatie`);\n orgNameLabel = input($localize`:@@brief.canvas.orgName:Organisatienaam`);\n returnAddressLabel = input($localize`:@@brief.canvas.returnAddress:Retouradres`);\n signatureClosingLabel = input($localize`:@@brief.canvas.signatureClosing:Afsluiting`);\n signatureNameLabel = input($localize`:@@brief.canvas.signatureName:Naam ondertekenaar`);\n signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);\n footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);\n footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);\n zoomGroupLabel = input($localize`:@@brief.canvas.zoom:Zoomniveau`);\n zoomInLabel = input($localize`:@@brief.canvas.zoomIn:Inzoomen`);\n zoomOutLabel = input($localize`:@@brief.canvas.zoomOut:Uitzoomen`);\n zoomResetLabel = input($localize`:@@brief.canvas.zoomReset:100%`);\n addedLabel = input($localize`:@@brief.diff.added:nieuw`);\n changedLabel = input($localize`:@@brief.diff.changed:gewijzigd sinds afwijzing`);\n\n protected showSample = signal(false);\n protected letterDate = formatDatumNl(new Date());\n\n /** Zoom seeded from the input; the +/−/reset controls drive it from there. */\n protected zoomLevel = linkedSignal(() => this.zoom());\n protected zoomPct = computed(() => `${Math.round(this.zoomLevel() * 100)}%`);\n protected zoomBy(delta: number) {\n // clamp 0.5–1.5; round to avoid float drift accumulating on repeated clicks.\n this.zoomLevel.update((z) => Math.round(Math.min(1.5, Math.max(0.5, z + delta)) * 10) / 10);\n }\n protected diffLabel = (kind: BlockDiffKind) =>\n kind === 'added' ? this.addedLabel() : this.changedLabel();\n\n /** Admin edit-in-place: the org-identity regions render as controls. */\n protected editing = computed(() => this.editableRegions() === 'template');\n\n protected emitEdit(field: OrgTemplateTextField, event: Event) {\n this.templateEdit.emit({\n field,\n value: (event.target as HTMLInputElement | HTMLTextAreaElement).value,\n });\n }\n\n protected marginStyle = computed(() => {\n const m = this.orgTemplate().margins;\n return {\n '--letter-margin-top': `${m.topMm}mm`,\n '--letter-margin-right': `${m.rightMm}mm`,\n '--letter-margin-bottom': `${m.bottomMm}mm`,\n '--letter-margin-left': `${m.leftMm}mm`,\n };\n });\n\n // --- read-only rendering helpers (migrated from the superseded letter-preview) ---\n\n private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));\n private worst = computed(() => {\n const m = new Map();\n for (const d of this.diagnostics()) {\n if (!d.placeholderKey) continue;\n if (d.severity === 'error') m.set(d.placeholderKey, 'error');\n else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');\n }\n return m;\n });\n\n protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);\n protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;\n protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;\n protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';\n protected sampleFor = (key: string) =>\n SAMPLE_VALUES[key] ?? (key === 'datum' ? this.letterDate : this.labelFor(key));\n\n // --- approximate page-break marks (PRD §2b: honest \"±\", print preview is leading) ---\n\n private page = viewChild>('page');\n protected pageBreaks = signal([]);\n\n constructor() {\n // ponytail: whole-surface height / A4-interval — ignores that a break never truly\n // falls mid-line; the caption says \"±\" and WP-25's server preview is authoritative.\n const observer = new ResizeObserver(([entry]) => {\n // ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.\n const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);\n this.pageBreaks.set(\n Array.from({ length: Math.max(0, pages - 1) }, (_, i) => (i + 1) * A4_HEIGHT_PX),\n );\n });\n effect((onCleanup) => {\n const el = this.page()?.nativeElement;\n if (!el) return;\n observer.observe(el);\n onCleanup(() => observer.unobserve(el));\n });\n inject(DestroyRef).onDestroy(() => observer.disconnect());\n }\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: var(--rhc-space-max-sm);\n margin-block-end: var(--rhc-space-max-sm);\n }\n .zoom {\n display: flex;\n align-items: center;\n gap: var(--rhc-space-max-sm);\n }\n .zoom-pct {\n min-width: 3.5ch;\n text-align: center;\n color: var(--rhc-color-foreground-subtle);\n font-variant-numeric: tabular-nums;\n }\n /* Rejection-diff badge (WP-27): a small pill above a changed/added block. */\n .diff-block.diff-changed {\n border-inline-start: 3px solid var(--rhc-color-oranje-500);\n padding-inline-start: var(--rhc-space-max-sm);\n }\n .diff-badge {\n display: inline-block;\n margin-block-end: 1mm;\n padding: 0 1.5mm;\n border-radius: var(--rhc-border-radius-sm);\n font-size: 7.5pt;\n /* changed = dark text on oranje-500 (4.79:1); white on oranje-500 fails (3.23:1). */\n color: var(--rhc-color-foreground-default);\n background: var(--rhc-color-oranje-500);\n }\n .diff-badge.added {\n /* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (WP-29 axe). */\n color: var(--rhc-color-wit);\n background: var(--rhc-color-groen-700);\n }\n /* Portal-side chrome around the letter surface (not part of the contract file). */\n .surface {\n background: var(--rhc-color-grijs-100);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-lg);\n overflow-x: auto;\n }\n .surface .letter {\n box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */\n }\n /* Admin edit-in-place (editableRegions='template'): the org-identity fields\n become controls styled to sit in the letter, with a visible editable affordance. */\n .tmpl-input,\n .tmpl-textarea {\n font: inherit;\n color: inherit;\n width: 100%;\n box-sizing: border-box;\n background: var(--rhc-color-geel-100);\n border: var(--rhc-border-width-sm) dashed var(--rhc-color-border-strong);\n border-radius: var(--rhc-border-radius-sm);\n padding: 0.5mm 1mm;\n }\n .tmpl-textarea {\n resize: vertical;\n }\n .org-logo {\n max-height: 20mm;\n max-width: 60mm;\n margin-block-end: 3mm;\n }\n \n", @@ -23183,7 +23183,7 @@ "deprecated": false, "deprecationMessage": "", "args": [], - "line": 438 + "line": 443 }, "extends": [] }, @@ -24205,7 +24205,7 @@ }, { "name": "OrgTemplateEditorComponent", - "id": "component-OrgTemplateEditorComponent-c423dbb352723fa0a2f181cd90c63d42d6924426a8775720579afbc6c5a07cb65e64085d62001592b053e7b2b5592de2736a2a3396724a384eee1454e2c66498", + "id": "component-OrgTemplateEditorComponent-aedec6f4fe1fa7981749096d0fff63eb6581f29615095768d6b1a235c4b3fc6b67404b0431de4899fa95b1fe4e30dfd512ef3233e3ba85397cffc060e7887910", "file": "src/app/brief/ui/org-template-editor/org-template-editor.component.ts", "encapsulation": [], "entryComponents": [], @@ -24217,7 +24217,7 @@ "styles": [ "\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n justify-content: space-between;\n align-items: end;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n margin-block-end: var(--rhc-space-max-lg);\n }\n .field {\n display: flex;\n flex-direction: column;\n gap: var(--rhc-space-max-2xs);\n }\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n .section {\n margin-block-start: var(--rhc-space-max-xl);\n }\n .margins {\n display: flex;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n .margins input {\n width: 6rem;\n }\n .history-list {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: column;\n gap: var(--rhc-space-max-sm);\n }\n .history-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--rhc-space-max-md);\n border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n padding-block-end: var(--rhc-space-max-sm);\n }\n .bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n align-items: center;\n margin-block-start: var(--rhc-space-max-xl);\n }\n .published {\n color: var(--rhc-color-foreground-subtle);\n }\n " ], - "template": "
                  \n \n {{ saveText() }}\n
                  \n\n\n\n
                  \n {{ marginsLegend() }}\n @for (edge of edges; track edge) {\n \n }\n
                  \n\n
                  \n {{ logoHeading() }}\n @if (logoCategory()) {\n \n }\n @if (logoRejection()) {\n {{ logoRejection() }}\n }\n @if (logoUploads().length) {\n
                    \n @for (u of logoUploads(); track u.localId) {\n \n }\n
                  \n }\n
                  \n\n
                  \n {{ historyHeading() }}\n @if (history().length === 0) {\n

                  {{ noHistory() }}

                  \n } @else {\n
                    \n @for (v of history(); track v.version) {\n
                  • \n {{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}\n \n {{ rollbackLabel() }}\n \n
                  • \n }\n
                  \n }\n
                  \n\n
                  \n {{ publishedLabel() }} {{ publishedVersion() }}\n @if (pendingPublish()) {\n {{ impactText() }}\n \n {{ confirmLabel() }}\n \n \n {{ cancelLabel() }}\n \n } @else {\n \n {{ publishLabel() }}\n \n @if (!draftValid()) {\n {{ invalidHint() }}\n }\n }\n \n {{ proefbriefLabel() }}\n \n
                  \n", + "template": "
                  \n \n {{ saveText() }}\n
                  \n\n\n\n
                  \n {{ marginsLegend() }}\n @for (edge of edges; track edge) {\n \n }\n
                  \n\n
                  \n {{ logoHeading() }}\n @if (logoCategory()) {\n \n }\n @if (logoRejection()) {\n {{ logoRejection() }}\n }\n @if (logoUploads().length) {\n
                    \n @for (u of logoUploads(); track u.localId) {\n \n }\n
                  \n }\n
                  \n\n
                  \n {{ historyHeading() }}\n @if (history().length === 0) {\n

                  {{ noHistory() }}

                  \n } @else {\n
                    \n @for (v of history(); track v.version) {\n
                  • \n {{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}\n \n {{ rollbackLabel() }}\n \n
                  • \n }\n
                  \n }\n
                  \n\n
                  \n {{ publishedLabel() }} {{ publishedVersion() }}\n @if (pendingPublish()) {\n {{ impactText() }}\n \n {{ confirmLabel() }}\n \n \n {{ cancelLabel() }}\n \n } @else {\n \n {{ publishLabel() }}\n \n @if (!draftValid()) {\n {{ invalidHint() }}\n }\n }\n \n {{ proefbriefLabel() }}\n \n
                  \n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -24230,7 +24230,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 278, + "line": 280, "required": false }, { @@ -24241,7 +24241,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 343, + "line": 348, "modifierKind": [ 124 ], @@ -24255,7 +24255,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 342, + "line": 347, "modifierKind": [ 124 ], @@ -24269,7 +24269,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 269, + "line": 271, "required": true }, { @@ -24280,7 +24280,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 277, + "line": 279, "required": false }, { @@ -24292,7 +24292,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 274, + "line": 276, "required": false }, { @@ -24303,7 +24303,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 336, + "line": 341, "modifierKind": [ 124 ], @@ -24317,7 +24317,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 345, + "line": 350, "modifierKind": [ 124 ], @@ -24331,7 +24331,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 335, + "line": 340, "modifierKind": [ 124 ], @@ -24346,18 +24346,18 @@ "indexKey": "", "optional": false, "description": "", - "line": 270, + "line": 272, "required": false }, { "name": "marginsLegend", - "defaultValue": "$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`", + "defaultValue": " $localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`, ", "deprecated": false, "deprecationMessage": "", "indexKey": "", "optional": false, "description": "", - "line": 334, + "line": 337, "modifierKind": [ 124 ], @@ -24371,7 +24371,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 337, + "line": 342, "modifierKind": [ 124 ], @@ -24385,7 +24385,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 279, + "line": 281, "required": false }, { @@ -24396,7 +24396,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 282, + "line": 284, "required": false }, { @@ -24407,7 +24407,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 344, + "line": 349, "modifierKind": [ 124 ], @@ -24421,7 +24421,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 340, + "line": 345, "modifierKind": [ 124 ], @@ -24435,7 +24435,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 275, + "line": 277, "required": false }, { @@ -24446,7 +24446,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 341, + "line": 346, "modifierKind": [ 124 ], @@ -24460,7 +24460,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 339, + "line": 344, "modifierKind": [ 124 ], @@ -24475,7 +24475,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 281, + "line": 283, "required": false }, { @@ -24486,7 +24486,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 280, + "line": 282, "required": false }, { @@ -24498,7 +24498,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 273, + "line": 275, "required": false }, { @@ -24509,7 +24509,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 333, + "line": 336, "modifierKind": [ 124 ], @@ -24524,7 +24524,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 272, + "line": 274, "required": false }, { @@ -24535,7 +24535,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 276, + "line": 278, "required": false }, { @@ -24546,7 +24546,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 271, + "line": 273, "required": true }, { @@ -24557,7 +24557,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 338, + "line": 343, "modifierKind": [ 124 ], @@ -24573,7 +24573,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 292, + "line": 294, "required": false }, { @@ -24584,7 +24584,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 291, + "line": 293, "required": false }, { @@ -24595,7 +24595,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 288, + "line": 290, "required": false }, { @@ -24606,7 +24606,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 289, + "line": 291, "required": false }, { @@ -24617,7 +24617,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 287, + "line": 289, "required": false }, { @@ -24628,7 +24628,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 286, + "line": 288, "required": false }, { @@ -24639,7 +24639,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 294, + "line": 296, "required": false }, { @@ -24650,7 +24650,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 290, + "line": 292, "required": false }, { @@ -24661,7 +24661,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 293, + "line": 295, "required": false }, { @@ -24672,7 +24672,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 284, + "line": 286, "required": false }, { @@ -24683,7 +24683,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 285, + "line": 287, "required": false } ], @@ -24697,7 +24697,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 296, + "line": 298, "modifierKind": [ 124, 148 @@ -24705,14 +24705,14 @@ }, { "name": "impactText", - "defaultValue": "computed(() =>\n $localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,\n )", + "defaultValue": "computed(\n () =>\n $localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,\n )", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "", - "line": 329, + "line": 331, "modifierKind": [ 124 ] @@ -24726,7 +24726,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 300, + "line": 302, "modifierKind": [ 124 ] @@ -24740,7 +24740,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 306, + "line": 308, "modifierKind": [ 124 ] @@ -24754,7 +24754,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 303, + "line": 305, "modifierKind": [ 124 ] @@ -24768,7 +24768,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 298, + "line": 300, "modifierKind": [ 124, 148 @@ -24783,7 +24783,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 297, + "line": 299, "modifierKind": [ 124, 148 @@ -24806,7 +24806,7 @@ "optional": false, "returnType": "string", "typeParameters": [], - "line": 316, + "line": 318, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -24849,7 +24849,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 311, + "line": 313, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -24895,7 +24895,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 308, + "line": 310, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -24954,7 +24954,7 @@ "description": "

                  Organism (WP-26): the admin org-template editor. The mirror of the drafter's\ncomposer — the letter canvas runs in editableRegions='template' so the\nletterhead/signature/footer are edited in place, while the content is a read-only\nsample. Margins, logo upload, version history and the publish bar sit around it.\nPresentational: every mutation is an output the store turns into a command.

                  \n", "rawdescription": "\n\nOrganism (WP-26): the admin org-template editor. The mirror of the drafter's\ncomposer — the letter canvas runs in `editableRegions='template'` so the\nletterhead/signature/footer are edited in place, while the content is a read-only\nsample. Margins, logo upload, version history and the publish bar sit around it.\nPresentational: every mutation is an output the store turns into a command.\n", "type": "component", - "sourceCode": "import { Component, computed, input, output } from '@angular/core';\nimport { DatePipe } from '@angular/common';\nimport { HeadingComponent } from '@shared/ui/heading/heading.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';\nimport { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';\nimport { UploadState } from '@shared/upload/upload.machine';\nimport { Brief } from '@brief/domain/brief';\nimport {\n MARGIN_MAX_MM,\n MARGIN_MIN_MM,\n Margins,\n OrgTemplate,\n OrgTemplateVersion,\n SubOrgSummary,\n} from '@brief/domain/org-template';\nimport { OrgTemplateTextField } from '@brief/domain/org-template.machine';\nimport { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';\n\nconst LOGO_CATEGORY = 'org-logo';\nconst EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm'];\n\n/** A minimal read-only sample letter, so the admin sees the org identity in context\n while editing (content itself is not the admin's to change). */\nexport const SAMPLE_LETTER_BRIEF: Brief = {\n briefId: 'VOORBEELD-0001',\n beroep: 'arts',\n templateId: 'sample',\n drafterId: 'sample',\n status: { tag: 'draft' },\n placeholders: [\n { key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },\n { key: 'datum', label: 'Datum', autoResolvable: true },\n ],\n sections: [\n {\n sectionKey: 'body',\n title: 'Voorbeeldinhoud',\n required: true,\n locked: true,\n blocks: [\n {\n type: 'freeText',\n blockId: 'sample-1',\n content: {\n paragraphs: [\n {\n nodes: [\n { type: 'text', text: 'Geachte ' },\n { type: 'placeholder', key: 'naam_zorgverlener' },\n { type: 'text', text: ',' },\n ],\n },\n {\n nodes: [\n {\n type: 'text',\n text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',\n },\n ],\n },\n ],\n },\n },\n ],\n },\n ],\n};\n\n/**\n * Organism (WP-26): the admin org-template editor. The mirror of the drafter's\n * composer — the letter canvas runs in `editableRegions='template'` so the\n * letterhead/signature/footer are edited in place, while the content is a read-only\n * sample. Margins, logo upload, version history and the publish bar sit around it.\n * Presentational: every mutation is an output the store turns into a command.\n */\n@Component({\n selector: 'app-org-template-editor',\n imports: [\n DatePipe,\n HeadingComponent,\n ButtonComponent,\n AlertComponent,\n FileInputComponent,\n SingleUploadComponent,\n LetterCanvasComponent,\n ],\n styles: [\n `\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n justify-content: space-between;\n align-items: end;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n margin-block-end: var(--rhc-space-max-lg);\n }\n .field {\n display: flex;\n flex-direction: column;\n gap: var(--rhc-space-max-2xs);\n }\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n .section {\n margin-block-start: var(--rhc-space-max-xl);\n }\n .margins {\n display: flex;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n .margins input {\n width: 6rem;\n }\n .history-list {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: column;\n gap: var(--rhc-space-max-sm);\n }\n .history-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--rhc-space-max-md);\n border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n padding-block-end: var(--rhc-space-max-sm);\n }\n .bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n align-items: center;\n margin-block-start: var(--rhc-space-max-xl);\n }\n .published {\n color: var(--rhc-color-foreground-subtle);\n }\n `,\n ],\n template: `\n
                  \n \n {{ saveText() }}\n
                  \n\n \n\n
                  \n {{ marginsLegend() }}\n @for (edge of edges; track edge) {\n \n }\n
                  \n\n
                  \n {{ logoHeading() }}\n @if (logoCategory()) {\n \n }\n @if (logoRejection()) {\n {{ logoRejection() }}\n }\n @if (logoUploads().length) {\n
                    \n @for (u of logoUploads(); track u.localId) {\n \n }\n
                  \n }\n
                  \n\n
                  \n {{ historyHeading() }}\n @if (history().length === 0) {\n

                  {{ noHistory() }}

                  \n } @else {\n
                    \n @for (v of history(); track v.version) {\n
                  • \n {{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}\n \n {{ rollbackLabel() }}\n \n
                  • \n }\n
                  \n }\n
                  \n\n
                  \n {{ publishedLabel() }} {{ publishedVersion() }}\n @if (pendingPublish()) {\n {{ impactText() }}\n \n {{ confirmLabel() }}\n \n \n {{ cancelLabel() }}\n \n } @else {\n \n {{ publishLabel() }}\n \n @if (!draftValid()) {\n {{ invalidHint() }}\n }\n }\n \n {{ proefbriefLabel() }}\n \n
                  \n `,\n})\nexport class OrgTemplateEditorComponent {\n draft = input.required();\n logoUrl = input(null);\n uploadState = input.required();\n subOrgs = input([]);\n selectedSubOrgId = input(null);\n history = input([]);\n publishedVersion = input(0);\n unsentBriefs = input(0);\n draftValid = input(false);\n busy = input(false);\n pendingPublish = input(false);\n saveText = input('');\n sampleBrief = input(SAMPLE_LETTER_BRIEF);\n previewUrlFor = input<(documentId: string) => string | undefined>();\n\n selectSubOrg = output();\n templateEdit = output<{ field: OrgTemplateTextField; value: string }>();\n marginEdit = output<{ edge: keyof Margins; value: number }>();\n logoSelected = output();\n logoRemoved = output();\n logoRetry = output();\n requestPublish = output();\n confirmPublish = output();\n cancelPublish = output();\n rollback = output();\n proefbrief = output();\n\n protected readonly edges = EDGES;\n protected readonly MIN = MARGIN_MIN_MM;\n protected readonly MAX = MARGIN_MAX_MM;\n\n protected logoCategory = computed(() =>\n this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),\n );\n protected logoUploads = computed(() =>\n this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),\n );\n protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);\n\n protected onSelectSubOrg(event: Event) {\n this.selectSubOrg.emit((event.target as HTMLSelectElement).value);\n }\n protected onMargin(edge: keyof Margins, event: Event) {\n const value = (event.target as HTMLInputElement).valueAsNumber;\n if (Number.isFinite(value)) this.marginEdit.emit({ edge, value });\n }\n\n protected edgeLabel(edge: keyof Margins): string {\n switch (edge) {\n case 'topMm':\n return $localize`:@@orgTemplate.margin.top:Boven (mm)`;\n case 'rightMm':\n return $localize`:@@orgTemplate.margin.right:Rechts (mm)`;\n case 'bottomMm':\n return $localize`:@@orgTemplate.margin.bottom:Onder (mm)`;\n case 'leftMm':\n return $localize`:@@orgTemplate.margin.left:Links (mm)`;\n }\n }\n\n protected impactText = computed(() =>\n $localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,\n );\n\n protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);\n protected marginsLegend = input($localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`);\n protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`);\n protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);\n protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`);\n protected versionLabel = input($localize`:@@orgTemplate.version:Versie`);\n protected rollbackLabel = input($localize`:@@orgTemplate.rollback:Terugzetten in concept`);\n protected publishedLabel = input($localize`:@@orgTemplate.published:Gepubliceerde versie:`);\n protected publishLabel = input($localize`:@@orgTemplate.publish:Publiceren`);\n protected confirmLabel = input($localize`:@@orgTemplate.publish.confirm:Bevestigen`);\n protected cancelLabel = input($localize`:@@orgTemplate.publish.cancel:Annuleren`);\n protected proefbriefLabel = input($localize`:@@orgTemplate.proefbrief:Proefbrief`);\n protected invalidHint = input(\n $localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,\n );\n}\n", + "sourceCode": "import { Component, computed, input, output } from '@angular/core';\nimport { DatePipe } from '@angular/common';\nimport { HeadingComponent } from '@shared/ui/heading/heading.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';\nimport { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';\nimport { UploadState } from '@shared/upload/upload.machine';\nimport { Brief } from '@brief/domain/brief';\nimport {\n MARGIN_MAX_MM,\n MARGIN_MIN_MM,\n Margins,\n OrgTemplate,\n OrgTemplateVersion,\n SubOrgSummary,\n} from '@brief/domain/org-template';\nimport { OrgTemplateTextField } from '@brief/domain/org-template.machine';\nimport { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';\n\nconst LOGO_CATEGORY = 'org-logo';\nconst EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm'];\n\n/** A minimal read-only sample letter, so the admin sees the org identity in context\n while editing (content itself is not the admin's to change). */\nexport const SAMPLE_LETTER_BRIEF: Brief = {\n briefId: 'VOORBEELD-0001',\n beroep: 'arts',\n templateId: 'sample',\n drafterId: 'sample',\n status: { tag: 'draft' },\n placeholders: [\n { key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },\n { key: 'datum', label: 'Datum', autoResolvable: true },\n ],\n sections: [\n {\n sectionKey: 'body',\n title: 'Voorbeeldinhoud',\n required: true,\n locked: true,\n blocks: [\n {\n type: 'freeText',\n blockId: 'sample-1',\n content: {\n paragraphs: [\n {\n nodes: [\n { type: 'text', text: 'Geachte ' },\n { type: 'placeholder', key: 'naam_zorgverlener' },\n { type: 'text', text: ',' },\n ],\n },\n {\n nodes: [\n {\n type: 'text',\n text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',\n },\n ],\n },\n ],\n },\n },\n ],\n },\n ],\n};\n\n/**\n * Organism (WP-26): the admin org-template editor. The mirror of the drafter's\n * composer — the letter canvas runs in `editableRegions='template'` so the\n * letterhead/signature/footer are edited in place, while the content is a read-only\n * sample. Margins, logo upload, version history and the publish bar sit around it.\n * Presentational: every mutation is an output the store turns into a command.\n */\n@Component({\n selector: 'app-org-template-editor',\n imports: [\n DatePipe,\n HeadingComponent,\n ButtonComponent,\n AlertComponent,\n FileInputComponent,\n SingleUploadComponent,\n LetterCanvasComponent,\n ],\n styles: [\n `\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n justify-content: space-between;\n align-items: end;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n margin-block-end: var(--rhc-space-max-lg);\n }\n .field {\n display: flex;\n flex-direction: column;\n gap: var(--rhc-space-max-2xs);\n }\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n .section {\n margin-block-start: var(--rhc-space-max-xl);\n }\n .margins {\n display: flex;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n .margins input {\n width: 6rem;\n }\n .history-list {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: column;\n gap: var(--rhc-space-max-sm);\n }\n .history-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--rhc-space-max-md);\n border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n padding-block-end: var(--rhc-space-max-sm);\n }\n .bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n align-items: center;\n margin-block-start: var(--rhc-space-max-xl);\n }\n .published {\n color: var(--rhc-color-foreground-subtle);\n }\n `,\n ],\n template: `\n
                  \n \n {{ saveText() }}\n
                  \n\n \n\n
                  \n {{ marginsLegend() }}\n @for (edge of edges; track edge) {\n \n }\n
                  \n\n
                  \n {{ logoHeading() }}\n @if (logoCategory()) {\n \n }\n @if (logoRejection()) {\n {{ logoRejection() }}\n }\n @if (logoUploads().length) {\n
                    \n @for (u of logoUploads(); track u.localId) {\n \n }\n
                  \n }\n
                  \n\n
                  \n {{ historyHeading() }}\n @if (history().length === 0) {\n

                  {{ noHistory() }}

                  \n } @else {\n
                    \n @for (v of history(); track v.version) {\n
                  • \n {{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}\n \n {{ rollbackLabel() }}\n \n
                  • \n }\n
                  \n }\n
                  \n\n
                  \n {{ publishedLabel() }} {{ publishedVersion() }}\n @if (pendingPublish()) {\n {{ impactText() }}\n \n {{ confirmLabel() }}\n \n \n {{ cancelLabel() }}\n \n } @else {\n \n {{ publishLabel() }}\n \n @if (!draftValid()) {\n {{ invalidHint() }}\n }\n }\n \n {{ proefbriefLabel() }}\n \n
                  \n `,\n})\nexport class OrgTemplateEditorComponent {\n draft = input.required();\n logoUrl = input(null);\n uploadState = input.required();\n subOrgs = input([]);\n selectedSubOrgId = input(null);\n history = input([]);\n publishedVersion = input(0);\n unsentBriefs = input(0);\n draftValid = input(false);\n busy = input(false);\n pendingPublish = input(false);\n saveText = input('');\n sampleBrief = input(SAMPLE_LETTER_BRIEF);\n previewUrlFor = input<(documentId: string) => string | undefined>();\n\n selectSubOrg = output();\n templateEdit = output<{ field: OrgTemplateTextField; value: string }>();\n marginEdit = output<{ edge: keyof Margins; value: number }>();\n logoSelected = output();\n logoRemoved = output();\n logoRetry = output();\n requestPublish = output();\n confirmPublish = output();\n cancelPublish = output();\n rollback = output();\n proefbrief = output();\n\n protected readonly edges = EDGES;\n protected readonly MIN = MARGIN_MIN_MM;\n protected readonly MAX = MARGIN_MAX_MM;\n\n protected logoCategory = computed(() =>\n this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),\n );\n protected logoUploads = computed(() =>\n this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),\n );\n protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);\n\n protected onSelectSubOrg(event: Event) {\n this.selectSubOrg.emit((event.target as HTMLSelectElement).value);\n }\n protected onMargin(edge: keyof Margins, event: Event) {\n const value = (event.target as HTMLInputElement).valueAsNumber;\n if (Number.isFinite(value)) this.marginEdit.emit({ edge, value });\n }\n\n protected edgeLabel(edge: keyof Margins): string {\n switch (edge) {\n case 'topMm':\n return $localize`:@@orgTemplate.margin.top:Boven (mm)`;\n case 'rightMm':\n return $localize`:@@orgTemplate.margin.right:Rechts (mm)`;\n case 'bottomMm':\n return $localize`:@@orgTemplate.margin.bottom:Onder (mm)`;\n case 'leftMm':\n return $localize`:@@orgTemplate.margin.left:Links (mm)`;\n }\n }\n\n protected impactText = computed(\n () =>\n $localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,\n );\n\n protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);\n protected marginsLegend = input(\n $localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`,\n );\n protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`);\n protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);\n protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`);\n protected versionLabel = input($localize`:@@orgTemplate.version:Versie`);\n protected rollbackLabel = input($localize`:@@orgTemplate.rollback:Terugzetten in concept`);\n protected publishedLabel = input($localize`:@@orgTemplate.published:Gepubliceerde versie:`);\n protected publishLabel = input($localize`:@@orgTemplate.publish:Publiceren`);\n protected confirmLabel = input($localize`:@@orgTemplate.publish.confirm:Bevestigen`);\n protected cancelLabel = input($localize`:@@orgTemplate.publish.cancel:Annuleren`);\n protected proefbriefLabel = input($localize`:@@orgTemplate.proefbrief:Proefbrief`);\n protected invalidHint = input(\n $localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,\n );\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "\n :host {\n display: block;\n }\n .toolbar {\n display: flex;\n justify-content: space-between;\n align-items: end;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n margin-block-end: var(--rhc-space-max-lg);\n }\n .field {\n display: flex;\n flex-direction: column;\n gap: var(--rhc-space-max-2xs);\n }\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n .section {\n margin-block-start: var(--rhc-space-max-xl);\n }\n .margins {\n display: flex;\n gap: var(--rhc-space-max-md);\n flex-wrap: wrap;\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n .margins input {\n width: 6rem;\n }\n .history-list {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: column;\n gap: var(--rhc-space-max-sm);\n }\n .history-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--rhc-space-max-md);\n border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n padding-block-end: var(--rhc-space-max-sm);\n }\n .bar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-md);\n align-items: center;\n margin-block-start: var(--rhc-space-max-xl);\n }\n .published {\n color: var(--rhc-color-foreground-subtle);\n }\n \n", @@ -24962,7 +24962,7 @@ }, { "name": "OrgTemplatePage", - "id": "component-OrgTemplatePage-94502f6ae3982864348068f3fa8ccae308060925b55b7c64e6a9a20eb52d5b511214a6fc40ced7e46011d543b375282c4d4529a6759051757dd3121aba197684", + "id": "component-OrgTemplatePage-fae5e4a1227d986f335688b1171786e8df9dd79d87e27f6323a01566ac9514cec1eecb7ae45cef865dfa30582b2b56bdd725a2704e246ce10db969e4138ebc17", "file": "src/app/brief/ui/org-template.page.ts", "encapsulation": [], "entryComponents": [], @@ -24974,7 +24974,7 @@ "styles": [ "\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n " ], - "template": "\n @if (store.lastError(); as err) {\n {{ err }}\n }\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.draft(); as draft) {\n \n }\n \n \n }\n\n", + "template": "\n @if (store.lastError(); as err) {\n {{ err }}\n }\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.draft(); as draft) {\n \n }\n \n \n }\n\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -24990,7 +24990,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 84, + "line": 88, "modifierKind": [ 124 ] @@ -25004,7 +25004,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 87, + "line": 91, "modifierKind": [ 124 ] @@ -25018,7 +25018,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 92, + "line": 96, "modifierKind": [ 124 ] @@ -25032,7 +25032,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 93, + "line": 97, "modifierKind": [ 124 ] @@ -25046,7 +25046,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 90, + "line": 94, "modifierKind": [ 124 ] @@ -25060,7 +25060,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 91, + "line": 95, "modifierKind": [ 124 ] @@ -25074,7 +25074,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 112, + "line": 116, "modifierKind": [ 123 ] @@ -25088,7 +25088,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 88, + "line": 92, "modifierKind": [ 124 ] @@ -25102,7 +25102,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 94, + "line": 98, "modifierKind": [ 124 ] @@ -25116,7 +25116,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 97, + "line": 101, "modifierKind": [ 123 ] @@ -25130,7 +25130,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 98, + "line": 102, "modifierKind": [ 123 ] @@ -25144,7 +25144,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 99, + "line": 103, "modifierKind": [ 124 ] @@ -25158,7 +25158,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 96, + "line": 100, "modifierKind": [ 123 ] @@ -25172,7 +25172,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 83, + "line": 87, "modifierKind": [ 124 ] @@ -25186,7 +25186,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 85, + "line": 89, "modifierKind": [ 123 ] @@ -25199,7 +25199,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 125, + "line": 129, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -25236,7 +25236,7 @@ "description": "

                  Page: thin container for the admin org-template editor (WP-26). Deny-by-default\ncapability gate (orgtemplate:edit) — a denial alert for non-admins, the editor\nfor admins. Loads once the capability resolves; wires store commands to the organism.

                  \n", "rawdescription": "\nPage: thin container for the admin org-template editor (WP-26). Deny-by-default\ncapability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor\nfor admins. Loads once the capability resolves; wires store commands to the organism.", "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 { UploadAdapter } from '@shared/upload/upload.adapter';\nimport { OrgTemplateStore } from '@brief/application/org-template.store';\nimport { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';\n\n/** Page: thin container for the admin org-template editor (WP-26). Deny-by-default\n capability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor\n for admins. Loads once the capability resolves; wires store commands to the organism. */\n@Component({\n selector: 'app-org-template-page',\n imports: [\n PageShellComponent,\n AlertComponent,\n ButtonComponent,\n ...ASYNC,\n OrgTemplateEditorComponent,\n ],\n styles: [\n `\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n `,\n ],\n template: `\n \n @if (store.lastError(); as err) {\n {{ err }}\n }\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.draft(); as draft) {\n \n }\n \n \n }\n \n `,\n})\nexport class OrgTemplatePage {\n protected store = inject(OrgTemplateStore);\n protected access = inject(AccessStore);\n private uploadAdapter = inject(UploadAdapter);\n\n protected canEdit = computed(() => this.access.can('orgtemplate:edit'));\n protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId);\n\n protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`;\n protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`;\n protected deniedText = $localize`:@@orgTemplate.page.denied:U hebt geen rechten om organisatiesjablonen te beheren.`;\n protected failedText = $localize`:@@orgTemplate.page.failed:Het sjabloon kon niet worden geladen.`;\n protected retryText = $localize`:@@orgTemplate.page.retry:Opnieuw proberen`;\n\n private savingText = $localize`:@@orgTemplate.page.saving:Concept opslaan…`;\n private savedText = $localize`:@@orgTemplate.page.saved:Concept opgeslagen`;\n private saveErrorText = $localize`:@@orgTemplate.page.saveError:Opslaan mislukt`;\n protected saveText = computed(() => {\n switch (this.store.saveState().tag) {\n case 'Saving':\n return this.savingText;\n case 'Saved':\n return this.savedText;\n case 'Error':\n return this.saveErrorText;\n default:\n return '';\n }\n });\n\n private loadRequested = false;\n constructor() {\n // Load once the capability resolves to `allowed` (a 403 GET would be wasted\n // otherwise). Depends only on `canEdit()` + a plain flag — never on the store\n // model, so dispatching `Loading` inside `load()` can't retrigger this effect.\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 { UploadAdapter } from '@shared/upload/upload.adapter';\nimport { OrgTemplateStore } from '@brief/application/org-template.store';\nimport { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';\n\n/** Page: thin container for the admin org-template editor (WP-26). Deny-by-default\n capability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor\n for admins. Loads once the capability resolves; wires store commands to the organism. */\n@Component({\n selector: 'app-org-template-page',\n imports: [\n PageShellComponent,\n AlertComponent,\n ButtonComponent,\n ...ASYNC,\n OrgTemplateEditorComponent,\n ],\n styles: [\n `\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n `,\n ],\n template: `\n \n @if (store.lastError(); as err) {\n {{ err }}\n }\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.draft(); as draft) {\n \n }\n \n \n }\n \n `,\n})\nexport class OrgTemplatePage {\n protected store = inject(OrgTemplateStore);\n protected access = inject(AccessStore);\n private uploadAdapter = inject(UploadAdapter);\n\n protected canEdit = computed(() => this.access.can('orgtemplate:edit'));\n protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId);\n\n protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`;\n protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`;\n protected deniedText = $localize`:@@orgTemplate.page.denied:U hebt geen rechten om organisatiesjablonen te beheren.`;\n protected failedText = $localize`:@@orgTemplate.page.failed:Het sjabloon kon niet worden geladen.`;\n protected retryText = $localize`:@@orgTemplate.page.retry:Opnieuw proberen`;\n\n private savingText = $localize`:@@orgTemplate.page.saving:Concept opslaan…`;\n private savedText = $localize`:@@orgTemplate.page.saved:Concept opgeslagen`;\n private saveErrorText = $localize`:@@orgTemplate.page.saveError:Opslaan mislukt`;\n protected saveText = computed(() => {\n switch (this.store.saveState().tag) {\n case 'Saving':\n return this.savingText;\n case 'Saved':\n return this.savedText;\n case 'Error':\n return this.saveErrorText;\n default:\n return '';\n }\n });\n\n private loadRequested = false;\n constructor() {\n // Load once the capability resolves to `allowed` (a 403 GET would be wasted\n // otherwise). Depends only on `canEdit()` + a plain flag — never on the store\n // model, so dispatching `Loading` inside `load()` can't retrigger this effect.\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", "assetsDirs": [], "styleUrlsData": "", "stylesData": "\n .save {\n color: var(--rhc-color-foreground-subtle);\n font-size: 0.9em;\n }\n \n", @@ -25246,7 +25246,7 @@ "deprecated": false, "deprecationMessage": "", "args": [], - "line": 112 + "line": 116 }, "extends": [] }, @@ -28547,7 +28547,7 @@ }, { "name": "StamdataPage", - "id": "component-StamdataPage-681c7077b9f5c96e535c71069925c67f17eb096d40ae0d63a6b86a58cf04e9ae7da39557bdd15ed2f31fff42a354fdbb98680ecaa5e9e8f57fe6aa4eac9084a0", + "id": "component-StamdataPage-ef18c1f6869c9b49396c1bbae1c9f3f625883185b71d8f9841b24a1c9b1e207f2ef527ba8478b9df36fc122ae87dc2b18fdbc6e065b455ea23b12e072f8a170c", "file": "src/app/beheer/ui/stamdata.page.ts", "encapsulation": [], "entryComponents": [], @@ -28573,7 +28573,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 57, + "line": 63, "modifierKind": [ 124 ] @@ -28587,7 +28587,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 59, + "line": 65, "modifierKind": [ 124 ] @@ -28601,7 +28601,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 63, + "line": 69, "modifierKind": [ 124 ] @@ -28615,7 +28615,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 64, + "line": 70, "modifierKind": [ 124 ] @@ -28629,7 +28629,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 61, + "line": 67, "modifierKind": [ 124 ] @@ -28643,7 +28643,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 62, + "line": 68, "modifierKind": [ 124 ] @@ -28657,7 +28657,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 67, + "line": 73, "modifierKind": [ 123 ] @@ -28671,7 +28671,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 65, + "line": 71, "modifierKind": [ 124 ] @@ -28685,7 +28685,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 56, + "line": 62, "modifierKind": [ 124 ] @@ -28698,7 +28698,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 80, + "line": 86, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -28735,7 +28735,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: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, StamdataTableEditorComponent],\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 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", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -28745,13 +28745,13 @@ "deprecated": false, "deprecationMessage": "", "args": [], - "line": 67 + "line": 73 }, "extends": [] }, { "name": "StamdataTableEditorComponent", - "id": "component-StamdataTableEditorComponent-46d5738b5376faf6de39e68f0b4c5d89d4e8f7c4d2521f52947161185f2657f08f20defd80232fcffd0d66963b8b8566c0b0f91a18774baa3ff612d6d542ce5f", + "id": "component-StamdataTableEditorComponent-6a5aeaa8ec97f6f8c54ca5436c496ec1b6c8d7b400fe73d1e25136c6d9fc54b04d78f7cd27c163fa17c2c3b256207fae7d90ac95cff23a5748a89b563fcbeaba", "file": "src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts", "encapsulation": [], "entryComponents": [], @@ -28763,7 +28763,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 {{ showAll }}\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 {{ addRowLabel }}\n {{ countsLabel() }}\n {{\n downloadLabel\n }}\n
                  \n

                  {{ applyHint }}

                  \n}\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -29279,7 +29279,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 {\n ChangeCounts,\n StamColumn,\n StamRow,\n StamTable,\n activeOn,\n} 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 {{ showAll }}\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 {{ 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", "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", @@ -30737,6 +30737,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", @@ -30749,16 +30759,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", @@ -30843,6 +30843,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", @@ -30863,16 +30873,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", @@ -31256,7 +31256,7 @@ "deprecated": false, "deprecationMessage": "", "type": "Routes", - "defaultValue": "[\n {\n path: '',\n component: ShellComponent, // persistent header/footer; only children swap\n children: [\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n {\n path: 'login',\n loadComponent: () => \"import('@auth/ui/login.page').then((m) => m.LoginPage)\",\n },\n {\n path: 'dashboard',\n canActivate: [authGuard],\n loadComponent: () => \"import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage)\",\n },\n {\n path: 'registratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registration-detail.page').then((m) => m.RegistrationDetailPage)\",\n },\n {\n path: 'aanvraag/:id',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/aanvraag-detail.page').then((m) => m.AanvraagDetailPage)\",\n },\n {\n path: 'registreren',\n canActivate: [authGuard],\n // Autosave wizard: flush the pending debounced draft before leaving (pending-saves.ts).\n canDeactivate: [flushPendingGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage)\",\n },\n {\n path: 'herregistratie',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () =>\n \"import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage)\",\n },\n {\n path: 'intake',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () => \"import('@herregistratie/ui/intake.page').then((m) => m.IntakePage)\",\n },\n {\n path: 'brief',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () => \"import('@brief/ui/brief.page').then((m) => m.BriefPage)\",\n },\n {\n path: 'brief/huisstijl',\n // Admin-only org-template editor (WP-26): capabilityGuard denies-by-default\n // unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces\n // via the OrgAdmin gate — the guard just avoids loading a page that would 403.\n canActivate: [\"capabilityGuard('orgtemplate:edit')\"],\n canDeactivate: [flushPendingGuard],\n loadComponent: () =>\n \"import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage)\",\n },\n {\n path: 'beheer/stamdata',\n // Admin-only stamdata maintenance editor (ADR-0004): capabilityGuard denies-by-default\n // unless GET /me resolved `stamdata:edit` (Admin role). Backend re-enforces via the\n // StamdataAdmin gate — the guard just avoids loading a page that would 403.\n canActivate: [\"capabilityGuard('stamdata:edit')\"],\n loadComponent: () => \"import('@beheer/ui/stamdata.page').then((m) => m.StamdataPage)\",\n },\n {\n path: 'concepts',\n loadComponent: () => \"import('./showcase/concepts.page').then((m) => m.ConceptsPage)\",\n },\n { path: '**', redirectTo: 'login' },\n ],\n },\n]" + "defaultValue": "[\n {\n path: '',\n component: ShellComponent, // persistent header/footer; only children swap\n children: [\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n {\n path: 'login',\n loadComponent: () => \"import('@auth/ui/login.page').then((m) => m.LoginPage)\",\n },\n {\n path: 'dashboard',\n canActivate: [authGuard],\n loadComponent: () => \"import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage)\",\n },\n {\n path: 'registratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registration-detail.page').then((m) => m.RegistrationDetailPage)\",\n },\n {\n path: 'aanvraag/:id',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/aanvraag-detail.page').then((m) => m.AanvraagDetailPage)\",\n },\n {\n path: 'registreren',\n canActivate: [authGuard],\n // Autosave wizard: flush the pending debounced draft before leaving (pending-saves.ts).\n canDeactivate: [flushPendingGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage)\",\n },\n {\n path: 'herregistratie',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () =>\n \"import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage)\",\n },\n {\n path: 'intake',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () => \"import('@herregistratie/ui/intake.page').then((m) => m.IntakePage)\",\n },\n {\n path: 'brief',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () => \"import('@brief/ui/brief.page').then((m) => m.BriefPage)\",\n },\n {\n path: 'brief/huisstijl',\n // Admin-only org-template editor (WP-26): capabilityGuard denies-by-default\n // unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces\n // via the OrgAdmin gate — the guard just avoids loading a page that would 403.\n canActivate: [\"capabilityGuard('orgtemplate:edit')\"],\n canDeactivate: [flushPendingGuard],\n loadComponent: () => \"import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage)\",\n },\n {\n path: 'beheer/stamdata',\n // Admin-only stamdata maintenance editor (ADR-0004): capabilityGuard denies-by-default\n // unless GET /me resolved `stamdata:edit` (Admin role). Backend re-enforces via the\n // StamdataAdmin gate — the guard just avoids loading a page that would 403.\n canActivate: [\"capabilityGuard('stamdata:edit')\"],\n loadComponent: () => \"import('@beheer/ui/stamdata.page').then((m) => m.StamdataPage)\",\n },\n {\n path: 'concepts',\n loadComponent: () => \"import('./showcase/concepts.page').then((m) => m.ConceptsPage)\",\n },\n { path: '**', redirectTo: 'login' },\n ],\n },\n]" }, { "name": "ROUTES", @@ -36305,6 +36305,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", @@ -36393,50 +36437,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", @@ -39274,8 +39274,8 @@ "name": "LoadedState", "ctype": "miscellaneous", "subtype": "typealias", - "rawtype": "Extract", - "file": "src/app/brief/application/org-template.store.ts", + "rawtype": "Extract", + "file": "src/app/beheer/application/stamdata.store.ts", "deprecated": false, "deprecationMessage": "", "description": "", @@ -39285,8 +39285,8 @@ "name": "LoadedState", "ctype": "miscellaneous", "subtype": "typealias", - "rawtype": "Extract", - "file": "src/app/beheer/application/stamdata.store.ts", + "rawtype": "Extract", + "file": "src/app/brief/application/org-template.store.ts", "deprecated": false, "deprecationMessage": "", "description": "", @@ -40559,7 +40559,7 @@ "deprecated": false, "deprecationMessage": "", "type": "Routes", - "defaultValue": "[\n {\n path: '',\n component: ShellComponent, // persistent header/footer; only children swap\n children: [\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n {\n path: 'login',\n loadComponent: () => \"import('@auth/ui/login.page').then((m) => m.LoginPage)\",\n },\n {\n path: 'dashboard',\n canActivate: [authGuard],\n loadComponent: () => \"import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage)\",\n },\n {\n path: 'registratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registration-detail.page').then((m) => m.RegistrationDetailPage)\",\n },\n {\n path: 'aanvraag/:id',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/aanvraag-detail.page').then((m) => m.AanvraagDetailPage)\",\n },\n {\n path: 'registreren',\n canActivate: [authGuard],\n // Autosave wizard: flush the pending debounced draft before leaving (pending-saves.ts).\n canDeactivate: [flushPendingGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage)\",\n },\n {\n path: 'herregistratie',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () =>\n \"import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage)\",\n },\n {\n path: 'intake',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () => \"import('@herregistratie/ui/intake.page').then((m) => m.IntakePage)\",\n },\n {\n path: 'brief',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () => \"import('@brief/ui/brief.page').then((m) => m.BriefPage)\",\n },\n {\n path: 'brief/huisstijl',\n // Admin-only org-template editor (WP-26): capabilityGuard denies-by-default\n // unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces\n // via the OrgAdmin gate — the guard just avoids loading a page that would 403.\n canActivate: [\"capabilityGuard('orgtemplate:edit')\"],\n canDeactivate: [flushPendingGuard],\n loadComponent: () =>\n \"import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage)\",\n },\n {\n path: 'beheer/stamdata',\n // Admin-only stamdata maintenance editor (ADR-0004): capabilityGuard denies-by-default\n // unless GET /me resolved `stamdata:edit` (Admin role). Backend re-enforces via the\n // StamdataAdmin gate — the guard just avoids loading a page that would 403.\n canActivate: [\"capabilityGuard('stamdata:edit')\"],\n loadComponent: () => \"import('@beheer/ui/stamdata.page').then((m) => m.StamdataPage)\",\n },\n {\n path: 'concepts',\n loadComponent: () => \"import('./showcase/concepts.page').then((m) => m.ConceptsPage)\",\n },\n { path: '**', redirectTo: 'login' },\n ],\n },\n]" + "defaultValue": "[\n {\n path: '',\n component: ShellComponent, // persistent header/footer; only children swap\n children: [\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n {\n path: 'login',\n loadComponent: () => \"import('@auth/ui/login.page').then((m) => m.LoginPage)\",\n },\n {\n path: 'dashboard',\n canActivate: [authGuard],\n loadComponent: () => \"import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage)\",\n },\n {\n path: 'registratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registration-detail.page').then((m) => m.RegistrationDetailPage)\",\n },\n {\n path: 'aanvraag/:id',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/aanvraag-detail.page').then((m) => m.AanvraagDetailPage)\",\n },\n {\n path: 'registreren',\n canActivate: [authGuard],\n // Autosave wizard: flush the pending debounced draft before leaving (pending-saves.ts).\n canDeactivate: [flushPendingGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage)\",\n },\n {\n path: 'herregistratie',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () =>\n \"import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage)\",\n },\n {\n path: 'intake',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () => \"import('@herregistratie/ui/intake.page').then((m) => m.IntakePage)\",\n },\n {\n path: 'brief',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () => \"import('@brief/ui/brief.page').then((m) => m.BriefPage)\",\n },\n {\n path: 'brief/huisstijl',\n // Admin-only org-template editor (WP-26): capabilityGuard denies-by-default\n // unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces\n // via the OrgAdmin gate — the guard just avoids loading a page that would 403.\n canActivate: [\"capabilityGuard('orgtemplate:edit')\"],\n canDeactivate: [flushPendingGuard],\n loadComponent: () => \"import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage)\",\n },\n {\n path: 'beheer/stamdata',\n // Admin-only stamdata maintenance editor (ADR-0004): capabilityGuard denies-by-default\n // unless GET /me resolved `stamdata:edit` (Admin role). Backend re-enforces via the\n // StamdataAdmin gate — the guard just avoids loading a page that would 403.\n canActivate: [\"capabilityGuard('stamdata:edit')\"],\n loadComponent: () => \"import('@beheer/ui/stamdata.page').then((m) => m.StamdataPage)\",\n },\n {\n path: 'concepts',\n loadComponent: () => \"import('./showcase/concepts.page').then((m) => m.ConceptsPage)\",\n },\n { path: '**', redirectTo: 'login' },\n ],\n },\n]" } ], "src/app/shared/layout/breadcrumb/breadcrumb-trail.ts": [ diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index da42f9f..0d55190 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -64,8 +64,7 @@ export const routes: Routes = [ // via the OrgAdmin gate — the guard just avoids loading a page that would 403. canActivate: [capabilityGuard('orgtemplate:edit')], canDeactivate: [flushPendingGuard], - loadComponent: () => - import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage), + loadComponent: () => import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage), }, { path: 'beheer/stamdata', diff --git a/src/app/beheer/domain/stamdata-editor.machine.spec.ts b/src/app/beheer/domain/stamdata-editor.machine.spec.ts index 4f2b6ef..f6cf4af 100644 --- a/src/app/beheer/domain/stamdata-editor.machine.spec.ts +++ b/src/app/beheer/domain/stamdata-editor.machine.spec.ts @@ -47,11 +47,17 @@ describe('stamdata-editor reduce', () => { it('edit messages are ignored unless loaded', () => { expect(reduce(initial, { tag: 'RowAdded' })).toBe(initial); - expect(reduce({ tag: 'failed', reason: 'x' }, { tag: 'CellEdited', row: 0, column: 'a', value: 'b' }).tag).toBe('failed'); + expect( + reduce({ tag: 'failed', reason: 'x' }, { tag: 'CellEdited', row: 0, column: 'a', value: 'b' }) + .tag, + ).toBe('failed'); }); it('LoadFailed and Loading transition regardless of prior state', () => { - expect(reduce(seedLoaded(), { tag: 'LoadFailed', reason: 'boom' })).toEqual({ tag: 'failed', reason: 'boom' }); + expect(reduce(seedLoaded(), { tag: 'LoadFailed', reason: 'boom' })).toEqual({ + tag: 'failed', + reason: 'boom', + }); expect(reduce(seedLoaded(), { tag: 'Loading' })).toEqual({ tag: 'loading' }); }); }); diff --git a/src/app/beheer/domain/stamdata.spec.ts b/src/app/beheer/domain/stamdata.spec.ts index 57a06ee..3ca87f6 100644 --- a/src/app/beheer/domain/stamdata.spec.ts +++ b/src/app/beheer/domain/stamdata.spec.ts @@ -28,8 +28,12 @@ describe('activeOn (valid-time, half-open [van, tot))', () => { expect(activeOn(professions, row('a', 'A', '2000-01-01'), '1999-01-01')).toBe(false); }); it('excludes on the geldigTot boundary (half-open)', () => { - expect(activeOn(professions, row('a', 'A', '2000-01-01', '2020-01-01'), '2020-01-01')).toBe(false); - expect(activeOn(professions, row('a', 'A', '2000-01-01', '2020-01-01'), '2019-12-31')).toBe(true); + expect(activeOn(professions, row('a', 'A', '2000-01-01', '2020-01-01'), '2020-01-01')).toBe( + false, + ); + expect(activeOn(professions, row('a', 'A', '2000-01-01', '2020-01-01'), '2019-12-31')).toBe( + true, + ); }); }); @@ -55,7 +59,11 @@ describe('changeCounts (diff against the loaded snapshot, by key)', () => { expect(changeCounts(professions, original, draft)).toEqual({ added: 1, removed: 0, edited: 0 }); }); it('counts a removed key', () => { - expect(changeCounts(professions, original, [original[0]])).toEqual({ added: 0, removed: 1, edited: 0 }); + expect(changeCounts(professions, original, [original[0]])).toEqual({ + added: 0, + removed: 1, + edited: 0, + }); }); it('counts an edited cell', () => { const draft = [row('a', 'CHANGED', '2000-01-01'), original[1]]; @@ -81,6 +89,8 @@ describe('toJson (draft → file shape)', () => { { name: 'jaar', type: 'number', isKey: false, options: [] }, ], }; - expect(JSON.parse(toJson(table, [{ code: 'x', jaar: '2020' }]))).toEqual([{ code: 'x', jaar: 2020 }]); + expect(JSON.parse(toJson(table, [{ code: 'x', jaar: '2020' }]))).toEqual([ + { code: 'x', jaar: 2020 }, + ]); }); }); diff --git a/src/app/beheer/domain/stamdata.ts b/src/app/beheer/domain/stamdata.ts index 4b674e3..f38e418 100644 --- a/src/app/beheer/domain/stamdata.ts +++ b/src/app/beheer/domain/stamdata.ts @@ -116,9 +116,13 @@ export function toJson(table: StamTable, rows: readonly StamRow[]): string { for (const c of table.columns) { const cell = (row[c.name] ?? '').trim(); out[c.name] = - cell === '' ? (c.type === 'text' || c.type === 'enum' ? '' : null) - : c.type === 'number' ? Number(cell) - : cell; + cell === '' + ? c.type === 'text' || c.type === 'enum' + ? '' + : null + : c.type === 'number' + ? Number(cell) + : cell; } return out; }); diff --git a/src/app/beheer/infrastructure/stamdata.adapter.spec.ts b/src/app/beheer/infrastructure/stamdata.adapter.spec.ts index 5d2c4fe..3cbcfd5 100644 --- a/src/app/beheer/infrastructure/stamdata.adapter.spec.ts +++ b/src/app/beheer/infrastructure/stamdata.adapter.spec.ts @@ -30,7 +30,11 @@ describe('parseStamdataTable', () => { }); it('falls back to text for an unknown column type', () => { - const r = parseStamdataTable({ ...wire, columns: [{ name: 'x', type: 'weird', isKey: true }], rows: [] }); + const r = parseStamdataTable({ + ...wire, + columns: [{ name: 'x', type: 'weird', isKey: true }], + rows: [], + }); if (!r.ok) return; expect(r.value.table.columns[0].type).toBe('text'); }); diff --git a/src/app/beheer/infrastructure/stamdata.adapter.ts b/src/app/beheer/infrastructure/stamdata.adapter.ts index 81c278d..a4070ab 100644 --- a/src/app/beheer/infrastructure/stamdata.adapter.ts +++ b/src/app/beheer/infrastructure/stamdata.adapter.ts @@ -67,7 +67,12 @@ function parseColumn(dto: StamdataColumnDto): Result { return ok({ name: dto.name, type, isKey: dto.isKey === true, options: dto.options ?? [] }); } -function parseTable(dto: { id?: string; label?: string; columns?: StamdataColumnDto[]; temporal?: boolean }): Result { +function parseTable(dto: { + id?: string; + label?: string; + columns?: StamdataColumnDto[]; + temporal?: boolean; +}): Result { if (typeof dto.id !== 'string' || !Array.isArray(dto.columns)) return err('stamdata table: bad shape'); const columns: StamColumn[] = []; 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 b51aead..172b497 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 @@ -1,12 +1,6 @@ import { Component, computed, input, output } from '@angular/core'; import { ButtonComponent } from '@shared/ui/button/button.component'; -import { - ChangeCounts, - StamColumn, - StamRow, - StamTable, - activeOn, -} from '@beheer/domain/stamdata'; +import { ChangeCounts, StamColumn, StamRow, StamTable, activeOn } from '@beheer/domain/stamdata'; interface DisplayRow { row: StamRow; @@ -94,7 +88,9 @@ interface DisplayRow { /> @if (previewing()) { - {{ showAll }} + {{ + showAll + }} } } @@ -123,7 +119,9 @@ interface DisplayRow { [value]="item.row[col.name]" [disabled]="previewing()" [attr.aria-label]="cellLabel(col, item.index)" - (change)="cellEdited.emit({ row: item.index, column: col.name, value: asValue($event) })" + (change)=" + cellEdited.emit({ row: item.index, column: col.name, value: asValue($event) }) + " > @for (opt of col.options; track opt) { @@ -138,7 +136,9 @@ interface DisplayRow { [class.is-invalid]="!!errors()[item.index]" [disabled]="previewing()" [attr.aria-label]="cellLabel(col, item.index)" - (input)="cellEdited.emit({ row: item.index, column: col.name, value: asValue($event) })" + (input)=" + cellEdited.emit({ row: item.index, column: col.name, value: asValue($event) }) + " /> } diff --git a/src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.stories.ts b/src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.stories.ts index 82360c7..cb2d403 100644 --- a/src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.stories.ts +++ b/src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.stories.ts @@ -17,7 +17,12 @@ const table: StamTable = { const rows: StamRow[] = [ { program: 'geneeskunde', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: '' }, { program: 'verpleegkunde', beroep: 'Verpleegkundige', geldigVan: '2000-01-01', geldigTot: '' }, - { program: 'fysiotherapie', beroep: 'Fysiotherapeut', geldigVan: '2000-01-01', geldigTot: '2020-01-01' }, + { + program: 'fysiotherapie', + beroep: 'Fysiotherapeut', + geldigVan: '2000-01-01', + geldigTot: '2020-01-01', + }, ]; const meta: Meta = { @@ -48,11 +53,14 @@ export const Dirty: Story = { export const Invalid: Story = { args: { - rows: [{ program: '', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: '' }, ...rows.slice(1)], - errors: rowErrors( - table, - [{ program: '', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: '' }, ...rows.slice(1)], - ), + rows: [ + { program: '', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: '' }, + ...rows.slice(1), + ], + errors: rowErrors(table, [ + { program: '', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: '' }, + ...rows.slice(1), + ]), }, }; diff --git a/src/app/beheer/ui/stamdata.page.ts b/src/app/beheer/ui/stamdata.page.ts index e9b5cb8..b9af4c2 100644 --- a/src/app/beheer/ui/stamdata.page.ts +++ b/src/app/beheer/ui/stamdata.page.ts @@ -14,7 +14,13 @@ import { StamdataTableEditorComponent } from '@beheer/ui/stamdata-table-editor/s */ @Component({ selector: 'app-stamdata-page', - imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, StamdataTableEditorComponent], + imports: [ + PageShellComponent, + AlertComponent, + ButtonComponent, + ...ASYNC, + StamdataTableEditorComponent, + ], template: ` @if (!access.ready()) { diff --git a/src/app/brief/application/brief.store.spec.ts b/src/app/brief/application/brief.store.spec.ts index b7d774a..e009c18 100644 --- a/src/app/brief/application/brief.store.spec.ts +++ b/src/app/brief/application/brief.store.spec.ts @@ -133,7 +133,11 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => { // --- WP-27: undo/redo history + rejection diff --- function block(id: string, text: string): LetterBlock { - return { type: 'freeText', blockId: id, content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] } }; + return { + type: 'freeText', + blockId: id, + content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] }, + }; } const kern = (blocks: LetterBlock[]) => ({ sectionKey: 'kern', @@ -152,7 +156,8 @@ function loadedBrief(store: BriefStore): Brief { } async function loadedStore(over: Partial = {}): Promise { - const ok = (v: BriefView): Promise> => Promise.resolve({ ok: true, value: v }); + const ok = (v: BriefView): Promise> => + Promise.resolve({ ok: true, value: v }); const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over }); await store.load(); return store; @@ -203,12 +208,16 @@ describe('BriefStore undo/redo history', () => { describe('BriefStore rejection diff', () => { it('captures the rejected letter and diffs a subsequent edit against it', async () => { - const submitted: Brief = { ...filledBrief, status: { tag: 'submitted', submittedBy: 'u', submittedAt: 't' } }; + const submitted: Brief = { + ...filledBrief, + status: { tag: 'submitted', submittedBy: 'u', submittedAt: 't' }, + }; const rejected: Brief = { ...filledBrief, status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' }, }; - const ok = (v: BriefView): Promise> => Promise.resolve({ ok: true, value: v }); + const ok = (v: BriefView): Promise> => + Promise.resolve({ ok: true, value: v }); const store = setup({ load: () => ok({ ...filledView, brief: submitted }), save: () => ok(filledView), @@ -272,7 +281,10 @@ describe('BriefStore.revealBigNummer (PRD-0002 §5c)', () => { afterEach(() => vi.restoreAllMocks()); // Loaded with a MASKED BIG-nummer, as the server ships it by default. - const maskedView: BriefView = { ...view, caseContext: { ...caseContext, bigNummer: '********601' } }; + const maskedView: BriefView = { + ...view, + caseContext: { ...caseContext, bigNummer: '********601' }, + }; it('swaps the masked value for the revealed one on success', async () => { const store = setup({ load: () => Promise.resolve({ ok: true, value: maskedView }) }); diff --git a/src/app/brief/application/org-template.store.ts b/src/app/brief/application/org-template.store.ts index e83b0e1..6417159 100644 --- a/src/app/brief/application/org-template.store.ts +++ b/src/app/brief/application/org-template.store.ts @@ -88,9 +88,12 @@ export class OrgTemplateStore implements PendingSave { readonly draftValid = computed(() => { const d = this.draft(); if (!d) return false; - const marginsOk = [d.margins.topMm, d.margins.rightMm, d.margins.bottomMm, d.margins.leftMm].every( - (v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM, - ); + const marginsOk = [ + d.margins.topMm, + d.margins.rightMm, + d.margins.bottomMm, + d.margins.leftMm, + ].every((v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM); return d.orgName.trim().length > 0 && d.signatureName.trim().length > 0 && marginsOk; }); @@ -107,7 +110,10 @@ export class OrgTemplateStore implements PendingSave { if (s.tag !== 'loaded' || s.upload.categories.length > 0) return; const status = this.categoriesRes.status(); if (status === 'resolved' || status === 'local') - this.dispatchUpload({ type: 'CategoriesLoaded', categories: this.categoriesRes.value() ?? [] }); + this.dispatchUpload({ + type: 'CategoriesLoaded', + categories: this.categoriesRes.value() ?? [], + }); }); // Flush a pending debounced edit before navigation/unload (see pending-saves.ts). registerPendingSave(this); @@ -259,8 +265,9 @@ export class OrgTemplateStore implements PendingSave { fileName: file.name, fileSizeMb: file.size / 1e6, }); - this.shell.upload({ localId, categoryId: cat.categoryId, wizardId: 'org-template', file }, (m) => - this.onUploadMsg(m), + this.shell.upload( + { localId, categoryId: cat.categoryId, wizardId: 'org-template', file }, + (m) => this.onUploadMsg(m), ); } diff --git a/src/app/brief/domain/besluit.spec.ts b/src/app/brief/domain/besluit.spec.ts index a71f852..e26d797 100644 --- a/src/app/brief/domain/besluit.spec.ts +++ b/src/app/brief/domain/besluit.spec.ts @@ -2,7 +2,9 @@ import { describe, it, expect } from 'vitest'; import { Besluit, LetterBlock, LibraryPassage } from './brief'; import { inferSelection, passagesForBesluit, redenenFor } from './besluit'; -const block = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] }); +const block = (t: string): LibraryPassage['content'] => ({ + paragraphs: [{ nodes: [{ type: 'text', text: t }] }], +}); const p = (over: Partial): LibraryPassage => ({ passageId: over.passageId ?? 'x', @@ -18,8 +20,18 @@ const lib: LibraryPassage[] = [ p({ passageId: 'intro', besluit: undefined }), // shared, any besluit p({ passageId: 'pos', besluit: 'positief' }), p({ passageId: 'neg', besluit: 'negatief' }), - p({ passageId: 'neg-scholing', besluit: 'negatief', reason: 'onvoldoende_scholing', label: 'Onvoldoende scholing' }), - p({ passageId: 'neg-gegevens', besluit: 'negatief', reason: 'onjuiste_gegevens', label: 'Onjuiste gegevens' }), + p({ + passageId: 'neg-scholing', + besluit: 'negatief', + reason: 'onvoldoende_scholing', + label: 'Onvoldoende scholing', + }), + p({ + passageId: 'neg-gegevens', + besluit: 'negatief', + reason: 'onjuiste_gegevens', + label: 'Onjuiste gegevens', + }), p({ passageId: 'slot-x', sectionKey: 'slot', besluit: undefined }), // not kern → never offered ]; @@ -35,17 +47,24 @@ describe('passagesForBesluit', () => { }); it('negatief with a reden ticked includes that reason-specific passage only', () => { - const ids = passagesForBesluit(lib, 'negatief', ['onvoldoende_scholing']).map((x) => x.passageId); + const ids = passagesForBesluit(lib, 'negatief', ['onvoldoende_scholing']).map( + (x) => x.passageId, + ); expect(ids).toEqual(['intro', 'neg', 'neg-scholing']); }); it('preserves library order (= reading order)', () => { - const ids = passagesForBesluit(lib, 'negatief', ['onjuiste_gegevens', 'onvoldoende_scholing']).map((x) => x.passageId); + const ids = passagesForBesluit(lib, 'negatief', [ + 'onjuiste_gegevens', + 'onvoldoende_scholing', + ]).map((x) => x.passageId); expect(ids).toEqual(['intro', 'neg', 'neg-scholing', 'neg-gegevens']); }); it('never offers non-kern passages', () => { - expect(passagesForBesluit(lib, 'positief', []).some((x) => x.sectionKey !== 'kern')).toBe(false); + expect(passagesForBesluit(lib, 'positief', []).some((x) => x.sectionKey !== 'kern')).toBe( + false, + ); }); }); diff --git a/src/app/brief/domain/besluit.ts b/src/app/brief/domain/besluit.ts index 7042d65..b596d97 100644 --- a/src/app/brief/domain/besluit.ts +++ b/src/app/brief/domain/besluit.ts @@ -66,7 +66,8 @@ export function inferSelection( const source = byId.get(b.sourcePassageId); if (!source) continue; if (source.besluit !== undefined) besluit = source.besluit; - if (source.reason !== undefined && !reasons.includes(source.reason)) reasons.push(source.reason); + if (source.reason !== undefined && !reasons.includes(source.reason)) + reasons.push(source.reason); } return { besluit, reasons }; } diff --git a/src/app/brief/domain/brief-diff.spec.ts b/src/app/brief/domain/brief-diff.spec.ts index 0f8ec77..56b50b4 100644 --- a/src/app/brief/domain/brief-diff.spec.ts +++ b/src/app/brief/domain/brief-diff.spec.ts @@ -3,7 +3,11 @@ import { Brief, LetterBlock } from './brief'; import { diffBlocks, changedBlocks } from './brief-diff'; function block(id: string, text: string): LetterBlock { - return { type: 'freeText', blockId: id, content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] } }; + return { + type: 'freeText', + blockId: id, + content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] }, + }; } function brief(blocks: LetterBlock[]): Brief { diff --git a/src/app/brief/domain/org-template.machine.spec.ts b/src/app/brief/domain/org-template.machine.spec.ts index 2baacf9..24b2604 100644 --- a/src/app/brief/domain/org-template.machine.spec.ts +++ b/src/app/brief/domain/org-template.machine.spec.ts @@ -24,7 +24,8 @@ const view = (over: Partial = {}): OrgTemplateAdminView => ...over, }); -const loaded = (): OrgTemplateState => reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() }); +const loaded = (): OrgTemplateState => + reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() }); const logoCategory: DocumentCategory = { categoryId: 'org-logo', @@ -84,7 +85,9 @@ describe('org-template.machine', () => { }); it('edits are no-ops in non-loaded states', () => { - expect(reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' })).toEqual({ + expect( + reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }), + ).toEqual({ tag: 'loading', }); }); @@ -96,7 +99,13 @@ describe('org-template.machine', () => { }); const selected = reduce(withCat, { tag: 'Upload', - msg: { type: 'FileSelected', categoryId: 'org-logo', localId: 'a', fileName: 'l.png', fileSizeMb: 0.1 }, + msg: { + type: 'FileSelected', + categoryId: 'org-logo', + localId: 'a', + fileName: 'l.png', + fileSizeMb: 0.1, + }, }); const done = reduce(selected, { tag: 'Upload', diff --git a/src/app/brief/domain/org-template.machine.ts b/src/app/brief/domain/org-template.machine.ts index f9e5dd0..de90a36 100644 --- a/src/app/brief/domain/org-template.machine.ts +++ b/src/app/brief/domain/org-template.machine.ts @@ -50,10 +50,7 @@ export type OrgTemplateMsg = | { tag: 'Upload'; msg: UploadMsg }; /** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */ -function editDraft( - s: OrgTemplateState, - f: (draft: OrgTemplate) => OrgTemplate, -): OrgTemplateState { +function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState { return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s; } @@ -87,7 +84,12 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState const upload = reduceUpload(s.upload, m.msg); // A completed/removed logo upload also updates the draft's logoDocumentId. if (m.msg.type === 'UploadComplete') - return { ...s, upload, draft: { ...s.draft, logoDocumentId: m.msg.documentId }, dirty: true }; + return { + ...s, + upload, + draft: { ...s.draft, logoDocumentId: m.msg.documentId }, + dirty: true, + }; if (m.msg.type === 'UploadRemoved') { const { logoDocumentId: _dropped, ...rest } = s.draft; return { ...s, upload, draft: rest, dirty: true }; diff --git a/src/app/brief/infrastructure/brief.adapter.spec.ts b/src/app/brief/infrastructure/brief.adapter.spec.ts index 6085678..55e861b 100644 --- a/src/app/brief/infrastructure/brief.adapter.spec.ts +++ b/src/app/brief/infrastructure/brief.adapter.spec.ts @@ -62,7 +62,13 @@ const view: BriefViewDto = { reason: 'onvoldoende_scholing', }, ], - decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false, canRevealBigNummer: false }, + decisions: { + canEdit: false, + canApprove: true, + canReject: true, + canSend: false, + canRevealBigNummer: false, + }, orgTemplate: { subOrgId: 'cibg-registers', orgName: 'CIBG — Registers', @@ -126,8 +132,10 @@ describe('brief.adapter parse boundary', () => { it('rejects a view whose case context is missing or malformed', () => { expect(parseBriefView({ ...view, caseContext: undefined }).ok).toBe(false); expect( - parseBriefView({ ...view, caseContext: { ...view.caseContext!, bigNummer: undefined as never } }) - .ok, + parseBriefView({ + ...view, + caseContext: { ...view.caseContext!, bigNummer: undefined as never }, + }).ok, ).toBe(false); }); diff --git a/src/app/brief/infrastructure/org-template.adapter.spec.ts b/src/app/brief/infrastructure/org-template.adapter.spec.ts index b15234d..ac7d221 100644 --- a/src/app/brief/infrastructure/org-template.adapter.spec.ts +++ b/src/app/brief/infrastructure/org-template.adapter.spec.ts @@ -47,7 +47,9 @@ describe('parseOrgTemplateAdminView', () => { it('rejects a malformed history entry', () => { const r = parseOrgTemplateAdminView({ ...view, - history: [{ version: 2, publishedAt: '2026-06-01', template: { ...draft, orgName: undefined } }], + history: [ + { version: 2, publishedAt: '2026-06-01', template: { ...draft, orgName: undefined } }, + ], }); expect(r.ok).toBe(false); }); diff --git a/src/app/brief/infrastructure/org-template.adapter.ts b/src/app/brief/infrastructure/org-template.adapter.ts index 5e82379..436313c 100644 --- a/src/app/brief/infrastructure/org-template.adapter.ts +++ b/src/app/brief/infrastructure/org-template.adapter.ts @@ -65,10 +65,7 @@ export class OrgTemplateAdapter { return r.ok ? parsePublish(r.value) : r; } - async rollback( - subOrgId: string, - version: number, - ): Promise> { + async rollback(subOrgId: string, version: number): Promise> { const r = await runSubmit(() => this.client.orgTemplateRollback(subOrgId, version), FAILED); return r.ok ? parseAdminView(r.value) : r; } diff --git a/src/app/brief/infrastructure/reveal-bignummer.adapter.ts b/src/app/brief/infrastructure/reveal-bignummer.adapter.ts index 3ad33ed..f2cbae1 100644 --- a/src/app/brief/infrastructure/reveal-bignummer.adapter.ts +++ b/src/app/brief/infrastructure/reveal-bignummer.adapter.ts @@ -31,7 +31,11 @@ export class RevealBigNummerAdapter { if (!res.ok) return err(await errorMessage(res)); const body: unknown = await res.json().catch(() => null); // Trust boundary: validate the shape before handing back a plain string. - if (typeof body === 'object' && body !== null && typeof (body as { bigNummer?: unknown }).bigNummer === 'string') { + if ( + typeof body === 'object' && + body !== null && + typeof (body as { bigNummer?: unknown }).bigNummer === 'string' + ) { return ok((body as { bigNummer: string }).bigNummer); } return err(REVEAL_FAILED); diff --git a/src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts b/src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts index a3a6c40..104a30d 100644 --- a/src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts +++ b/src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts @@ -126,12 +126,9 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com
                  {{ previewLabel() }} - {{ submitLabel() }} + {{ + submitLabel() + }} @if (!canSubmit()) { {{ submitHint() }} } @@ -149,7 +146,9 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com />
                  diff --git a/src/app/brief/ui/behandel-scherm/behandel-scherm.stories.ts b/src/app/brief/ui/behandel-scherm/behandel-scherm.stories.ts index e9722e5..a11d9bd 100644 --- a/src/app/brief/ui/behandel-scherm/behandel-scherm.stories.ts +++ b/src/app/brief/ui/behandel-scherm/behandel-scherm.stories.ts @@ -1,9 +1,17 @@ import type { Meta, StoryObj } from '@storybook/angular'; -import { Brief, BriefStatus, CaseContext, LibraryPassage, allDiagnostics } from '@brief/domain/brief'; +import { + Brief, + BriefStatus, + CaseContext, + LibraryPassage, + allDiagnostics, +} from '@brief/domain/brief'; import { OrgTemplate } from '@brief/domain/org-template'; import { BehandelSchermComponent } from './behandel-scherm.component'; -const text = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] }); +const text = (t: string): LibraryPassage['content'] => ({ + paragraphs: [{ nodes: [{ type: 'text', text: t }] }], +}); const orgTemplate: OrgTemplate = { subOrgId: 'cibg-registers', @@ -26,9 +34,34 @@ const caseContext: CaseContext = { }; const passages: LibraryPassage[] = [ - { passageId: 'p-kern-positief', scope: 'global', sectionKey: 'kern', label: 'Toewijzing', version: 1, besluit: 'positief', content: text('Uw aanvraag is toegewezen.') }, - { passageId: 'p-kern-negatief', scope: 'global', sectionKey: 'kern', label: 'Afwijzing', version: 1, besluit: 'negatief', content: text('Uw aanvraag is afgewezen.') }, - { passageId: 'p-kern-scholing', scope: 'global', sectionKey: 'kern', label: 'Onvoldoende scholing', version: 1, besluit: 'negatief', reason: 'onvoldoende_scholing', content: text('Onvoldoende scholing.') }, + { + passageId: 'p-kern-positief', + scope: 'global', + sectionKey: 'kern', + label: 'Toewijzing', + version: 1, + besluit: 'positief', + content: text('Uw aanvraag is toegewezen.'), + }, + { + passageId: 'p-kern-negatief', + scope: 'global', + sectionKey: 'kern', + label: 'Afwijzing', + version: 1, + besluit: 'negatief', + content: text('Uw aanvraag is afgewezen.'), + }, + { + passageId: 'p-kern-scholing', + scope: 'global', + sectionKey: 'kern', + label: 'Onvoldoende scholing', + version: 1, + besluit: 'negatief', + reason: 'onvoldoende_scholing', + content: text('Onvoldoende scholing.'), + }, ]; function brief(status: BriefStatus, kernBlocks: Brief['sections'][number]['blocks'] = []): Brief { @@ -43,9 +76,27 @@ function brief(status: BriefStatus, kernBlocks: Brief['sections'][number]['block { key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false }, ], sections: [ - { sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: true, blocks: [{ type: 'freeText', blockId: 'aanhef-1', content: text('Geachte heer/mevrouw,') }] }, - { sectionKey: 'kern', title: 'Kern van het besluit', required: true, locked: false, blocks: kernBlocks }, - { sectionKey: 'slot', title: 'Slot', required: false, locked: true, blocks: [{ type: 'freeText', blockId: 'slot-1', content: text('Met vriendelijke groet,') }] }, + { + sectionKey: 'aanhef', + title: 'Aanhef', + required: true, + locked: true, + blocks: [{ type: 'freeText', blockId: 'aanhef-1', content: text('Geachte heer/mevrouw,') }], + }, + { + sectionKey: 'kern', + title: 'Kern van het besluit', + required: true, + locked: false, + blocks: kernBlocks, + }, + { + sectionKey: 'slot', + title: 'Slot', + required: false, + locked: true, + blocks: [{ type: 'freeText', blockId: 'slot-1', content: text('Met vriendelijke groet,') }], + }, ], }; } @@ -66,8 +117,22 @@ export const EmptyKern: Story = { // A besluit-sourced kern block (carries provenance), so the panel re-seeds itself from it. const negatiefScholingKern: Brief['sections'][number]['blocks'] = [ - { type: 'passage', blockId: 'local-1', sourcePassageId: 'p-kern-negatief', sourceVersion: 1, edited: false, content: text('Uw aanvraag is afgewezen.') }, - { type: 'passage', blockId: 'local-2', sourcePassageId: 'p-kern-scholing', sourceVersion: 1, edited: false, content: text('Onvoldoende scholing.') }, + { + type: 'passage', + blockId: 'local-1', + sourcePassageId: 'p-kern-negatief', + sourceVersion: 1, + edited: false, + content: text('Uw aanvraag is afgewezen.'), + }, + { + type: 'passage', + blockId: 'local-2', + sourcePassageId: 'p-kern-scholing', + sourceVersion: 1, + edited: false, + content: text('Onvoldoende scholing.'), + }, ]; /** Draft with a negatief besluit: the kern is filled from the selection and the besluit @@ -96,7 +161,12 @@ export const MaskedBigNummer: Story = { export const Rejected: Story = { render: (args) => { const b = brief( - { tag: 'rejected', rejectedBy: 'demo-approver', rejectedAt: '2026-07-01', comments: 'Graag de reden concreter.' }, + { + tag: 'rejected', + rejectedBy: 'demo-approver', + rejectedAt: '2026-07-01', + comments: 'Graag de reden concreter.', + }, negatiefScholingKern, ); return { props: { ...args, brief: b, diagnostics: allDiagnostics(b), canSubmit: true } }; diff --git a/src/app/brief/ui/besluit-panel/besluit-panel.stories.ts b/src/app/brief/ui/besluit-panel/besluit-panel.stories.ts index 94a75ce..80b8a34 100644 --- a/src/app/brief/ui/besluit-panel/besluit-panel.stories.ts +++ b/src/app/brief/ui/besluit-panel/besluit-panel.stories.ts @@ -2,13 +2,49 @@ import type { Meta, StoryObj } from '@storybook/angular'; import { LibraryPassage } from '@brief/domain/brief'; import { BesluitPanelComponent } from './besluit-panel.component'; -const text = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] }); +const text = (t: string): LibraryPassage['content'] => ({ + paragraphs: [{ nodes: [{ type: 'text', text: t }] }], +}); const passages: LibraryPassage[] = [ - { passageId: 'p-kern-positief', scope: 'global', sectionKey: 'kern', label: 'Toewijzing', version: 1, besluit: 'positief', content: text('Toegewezen.') }, - { passageId: 'p-kern-negatief', scope: 'global', sectionKey: 'kern', label: 'Afwijzing', version: 1, besluit: 'negatief', content: text('Afgewezen.') }, - { passageId: 'p-kern-scholing', scope: 'global', sectionKey: 'kern', label: 'Onvoldoende scholing', version: 1, besluit: 'negatief', reason: 'onvoldoende_scholing', content: text('Onvoldoende scholing.') }, - { passageId: 'p-kern-gegevens', scope: 'global', sectionKey: 'kern', label: 'Onjuiste gegevens', version: 1, besluit: 'negatief', reason: 'onjuiste_gegevens', content: text('Onjuiste gegevens.') }, + { + passageId: 'p-kern-positief', + scope: 'global', + sectionKey: 'kern', + label: 'Toewijzing', + version: 1, + besluit: 'positief', + content: text('Toegewezen.'), + }, + { + passageId: 'p-kern-negatief', + scope: 'global', + sectionKey: 'kern', + label: 'Afwijzing', + version: 1, + besluit: 'negatief', + content: text('Afgewezen.'), + }, + { + passageId: 'p-kern-scholing', + scope: 'global', + sectionKey: 'kern', + label: 'Onvoldoende scholing', + version: 1, + besluit: 'negatief', + reason: 'onvoldoende_scholing', + content: text('Onvoldoende scholing.'), + }, + { + passageId: 'p-kern-gegevens', + scope: 'global', + sectionKey: 'kern', + label: 'Onjuiste gegevens', + version: 1, + besluit: 'negatief', + reason: 'onjuiste_gegevens', + content: text('Onjuiste gegevens.'), + }, ]; const meta: Meta = { diff --git a/src/app/brief/ui/letter-canvas/letter-canvas.component.ts b/src/app/brief/ui/letter-canvas/letter-canvas.component.ts index 9cb831b..07ac186 100644 --- a/src/app/brief/ui/letter-canvas/letter-canvas.component.ts +++ b/src/app/brief/ui/letter-canvas/letter-canvas.component.ts @@ -178,7 +178,9 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4; (click)="zoomBy(0.1)" >+ - {{ zoomResetLabel() }} + {{ + zoomResetLabel() + }} @if (editableRegions() === 'none') { this.editableRegions() === 'template'); protected emitEdit(field: OrgTemplateTextField, event: Event) { - this.templateEdit.emit({ field, value: (event.target as HTMLInputElement | HTMLTextAreaElement).value }); + this.templateEdit.emit({ + field, + value: (event.target as HTMLInputElement | HTMLTextAreaElement).value, + }); } protected marginStyle = computed(() => { diff --git a/src/app/brief/ui/org-template-editor/org-template-editor.component.ts b/src/app/brief/ui/org-template-editor/org-template-editor.component.ts index 405f0cc..b9b8c98 100644 --- a/src/app/brief/ui/org-template-editor/org-template-editor.component.ts +++ b/src/app/brief/ui/org-template-editor/org-template-editor.component.ts @@ -227,7 +227,9 @@ export const SAMPLE_LETTER_BRIEF: Brief = {