Compare commits

...
Author SHA1 Message Date
ehoandClaude Opus 5 fc2a3c348b refactor: one member order for the 3 wizard containers (RD-38)
RD-22 and RD-23 brought the wizard containers under the 250-line budget, so
`max-lines` reports nothing. The files still read badly. Line count was never
the problem.

Fix three things in all three containers:

1. The member order was scrambled, and it differed per file. `registratie`
   declared `draftSync` in the middle of a run of `computed`s; `herregistratie`
   read `this.stepLabels.length` seven lines before `stepLabels` existed; the
   three files put the copy arrays in three different places. All three now use
   one nine-section order, so they compare side by side.
2. Pure logic sat in the container. Extract `digitalDocumentIds` into
   `upload.machine.ts` — the "digital and finished uploading" filter was
   written out four times, and it removes a `documentId!` assertion from both
   containers. Extract `diplomaMsg` into a sibling of the step files.
3. Comments carried archaeology. Drop the three RD-05 references and keep the
   rule. Drop "replaces sessionStorage" and the note about focus management that
   moved to the shell. Fix `intake`'s class comment, which claimed answers
   persist to sessionStorage and was contradicted 30 lines below.

`phase` deliberately stays in all three: it cannot live in `domain/`, and three
siblings plus three specs is a worse trade than 17 readable lines. The store ⇄
`draftSync` cycle also stays — both callbacks are deferred, so it is safe, and
one comment now names it.

No behaviour change. Member lists and every `private`/`protected`/`readonly`
modifier are unchanged, which the showcase depends on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 20:16:23 +02:00
ehoandClaude Opus 5 551cabce5e docs: add RD-37 — five a11y suppressions name a ticket that closed
Found while measuring RD-30, which archives the directory these references
point into.

Five stories disable the axe check with the reason "WP-11 reworks this
markup". WP-11 is done, and so is WP-13, the gap register it handed the
remainder to. No open ticket owns the defect, so the rule that a disabled
check must name the ticket that removes it holds only in letter.

The defect ships: `app-choice-link` and `app-aanvraag-block` put a component
host between the keuzelijst `<ul>` and its `<li>`, which breaks axe's
list/listitem rule. WP-11 solved the same problem for `application-link` by
making the host be the `<li>`, but `atomic-design.mdx` documents the current
split as deliberate — so whether that move fights the vendored CSS is the
question RD-37 must answer first.

Sequenced before RD-30.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:37:26 +02:00
ehoandClaude Sonnet 5 b6bd7eea5a refactor: enforce the atomic ladder in dependency-cruiser (RD-29)
Add three dependency-cruiser rules that forbid upward edges inside
libs/shared/src/ui: atoms-compose-nothing-above, molecules-below-organisms,
and design-system-not-layout. RD-27 made the atomic ladder expressible by
moving components into atoms/molecules/organisms folders; these rules make
it enforced, so an upward import now fails dep:check instead of shipping.

No pathNot exemption for specs and stories: measured, zero upward edges
exist anywhere in libs/shared/src/ui today, in production code, specs, and
stories alike, so the exemption PLAN proposed has nothing to protect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:32:52 +02:00
ehoandClaude Sonnet 5 d4c5a9450a fix: layer-tag mismatches + libs/beheer title rule (RD-28)
Two components disagreed with their own story title. async.component.ts
had no layer tag; add `Molecule:` to its class header, not to the
Convenience: comment on the ASYNC array (that comment describes an
export, not a layer, and stays as it is). breadcrumb.component.ts had
`Chrome:`; rename it to `Molecule:` to match its story title.

CLAUDE.md and layers.mdx both claimed `libs/beheer/ui` is Design System.
The code disagrees: its story title is `Domein/Beheer/...`, because
libs/beheer is a bounded context that lives under libs/ only because two
apps share it. Fix the two doc lines to match the code; the story title
does not change.

The ticket's own tag-count check asserted 68 after the edits, but adding
a new tag to async.component.ts (which had none) is a net +1 over the
Chrome-to-Molecule rename (a wash) — the true post-edit count is 69.
Corrected the number in the ticket text rather than deleting the
mandated tag to force the wrong count. Also fixed pre-existing invalid
nested-backtick markdown in the ticket's decision 4 bullets: prettier
mis-rewrapped it and dropped spaces, so the phrasing was rewritten in
valid CommonMark with no change in meaning.

npm run ci --full passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:26:47 +02:00
ehoandClaude Opus 5 57fc72f254 docs: correct RD-27's occurrence count, the fourth time in one pattern
Decision 3 said the `@shared/ui/` occurrence count must not change at 200,
while decision 4, nine lines below, mandated converting five relative imports
into `@shared/ui/` aliases — five new occurrences by construction. The answer
is 205, and the agent verified by hand that the 21 occurrences now inside
`libs/shared/src/ui/` are the 16 from before plus exactly those five.

This is the fourth instance of the pattern named in the previous docs commit,
and the first ticket written after naming it. Recorded as such: when two
decisions both touch the thing you are counting, add them up first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:15:20 +02:00
ehoandClaude Sonnet 5 43dc3210cd refactor: move libs/shared/src/ui/ into atoms/molecules/organisms (RD-27)
The folder now equals the layer, as CLAUDE.md decision 2 requires. 33
directories move by git mv (25 flat, plus upload/'s 8 subfolders split
across all three layers). 28 distinct @shared/ui/* specifiers rewrite
across 73 files, longest-first. Five relative imports inside upload/
become @shared/ui aliases because their sibling now lives in a
different layer; two stay relative because both ends stay in the same
layer. Four .mdx docs get their seven broken story imports fixed;
atomic-design.mdx's page-shell import is untouched, because layout/
does not move.

No component, template, story title, or layer-tag comment changes.
That is RD-28's job.

Verified against the ticket's acceptance commands: the 26 flat
directories become exactly 3 layer folders with the counts the ticket
names, only three @shared/ui/* prefixes remain (atoms, molecules,
organisms), the .mdx import count holds at 7, and the relative-import
count inside ui/ drops from 7 to 2 as decision 4 requires. The
@shared/ui/ occurrence count moves from 200 to 205: decision 4
mandates turning 5 of those 7 relative imports into @shared/ui/*
aliases, which decision 3's "200 before, 200 after" check does not
account for. The 5-occurrence gap is exactly the 5 conversions decision
4 names, not a lost or duplicated specifier.

npm run ci --full passes: lint, typecheck, dep:check, format, tokens,
seam, both apps' + both libraries' tests, both apps' localized build,
audit, backend tests, all three generated-artifact drift checks, and
both Storybook instances' build + axe-core a11y suite (67+45 suites,
198+112 tests, all green).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:14:10 +02:00
ehoandClaude Sonnet 5 7a8eab917b refactor: inline letter-canvas's 19 pure labels, extract letter-line (RD-26)
Twenty of letter-canvas's twenty-eight input()s are $localize labels that no
caller ever binds. Inline nineteen of them as template i18n, using the same
ids and source text so messages.en.xlf does not change. recipientText stays
an input() because its message embeds a literal \n, which as template text
becomes a different source string to Angular's extractor.

Extract letter-line.component.ts for the #line template plus the
label/auto/state/sample helpers it needs, replacing letter-canvas's three
ngTemplateOutlet incantations with one tag each. Its helpers are exported
pure functions with a spec, no TestBed.

The file stays over the 250-line budget (77 lines of CSS plus one letter's
markup), so the eslint-disable max-lines directive stays too, with its
reason rewritten to say so plainly — the only such disable left in the repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:25:35 +02:00
ehoandClaude Opus 5 bcac3789d3 docs: fix RD-25's decision 4, and name the pattern behind three misses
Decision 4 said the parent keeps seven outputs while decision 5, four lines
below, said all eleven remain and are re-emitted from children. Seven is how
many the parent raises; eleven is how many it declares. The acceptance line
copied the wrong one, and satisfying it would have broken
`org-template.page.ts`, which binds all eleven.

RD-08, RD-23 and RD-25 are the same mistake: a decision describes the design
in one vocabulary, and the acceptance line counts something else that shares a
word. Name what the command counts before writing the number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:03:30 +02:00
ehoandClaude Sonnet 5 cf1f641534 refactor: split org-template-editor by output cluster (RD-25)
org-template-editor.component.ts carried an eslint-disable for max-lines,
padded by a dead sample-letter constant, 13 label inputs that were never
bindable, and two self-contained mutation clusters. Split all three out:

- SAMPLE_LETTER_BRIEF moves to brief/domain/sample-letter.ts. It is
  production content (the letter the admin previews), not a test fixture,
  so it stays out of brief.testing.ts (no-testing-in-production forbids
  production code from reaching a *.testing.ts file).
- 11 of the 13 label inputs become inline i18n template text. The two
  that interpolate MARGIN_MIN_MM/MARGIN_MAX_MM (marginsLegend,
  invalidHint) stay in TS, because moving an interpolated $localize call
  into a template renames the xlf placeholder and breaks the translation
  merge. Every id is preserved; messages.en.xlf is unchanged.
- logo-upload.component.ts and version-history.component.ts each take
  one output cluster. The parent still declares and re-emits all 11
  outputs — org-template.page.ts binds them directly on
  <app-org-template-editor> and is out of this ticket's file scope, so
  the parent's public surface cannot shrink.

Correction to the ticket while executing it: its acceptance check for
"= output" on the parent read "MUST be 7", copying decision 4's cluster
count instead of decision 5's (and the ticket's own Risks section's)
explicit requirement that the parent keep all 11 declarations. Fixed the
ticket's acceptance section to the correct number.

npm run ci --full is green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:02:30 +02:00
ehoandClaude Opus 5 5aed15bb98 docs: record the supervisor-as-second-writer mistake from RD-24
A task notification fires every time an agent stops with no live children,
including a pause mid-task. RD-24's agent paused and notified; the supervisor
read that as abandonment, took over the tree, ran the gate and edited the
ticket Status while the agent was still working. The agent resumed, correctly
detected a second writer, and refused to commit.

This is the mirror image of the RD-17 race the section above records: there a
fresh supervisor met a running agent, here a running agent met its own
supervisor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:44:17 +02:00
ehoandClaude Sonnet 5 630d68045f refactor: split concepts.page into 6 sections, fix dead highlighting (RD-24)
The page held six teaching sections and a 142-line `styles:` block, at 471
effective lines against a limit of 250. It is now 36 lines of composition.

Angular scopes a component's CSS to markup that component rendered, so the
split had to move each rule to its owner. `concept-card` owns the card
vocabulary and renders it. `.app-code`, `.app-lead`, `.app-cols` and
`.app-note` become globals, because their targets are projected or arrive
through `[innerHTML]`.

That constraint exposed a live bug. The syntax-highlighting rules compiled to
`pre[_ngcontent-%COMP%] .k[_ngcontent-%COMP%]`, but `highlight-ts` injects the
`.k`/`.s`/`.c` spans through `[innerHTML]`, so they carry no scope attribute
and the rule never matched. Keywords, strings and comments have always
rendered in the plain foreground colour. The rules are global now, on five new
`--app-code-*` tokens.

Widen the colour guard while here: it scanned only `*.component.ts`, so every
`*.page.ts`, `*.section.ts` and `*.step.ts` was invisible to it. That is how
this page collected 21 hardcoded colours. One other file needed a fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:44:01 +02:00
ehoandClaude Opus 5 d5a7a25a78 docs: record RD-23's two acceptance misses as recurrences
Both repeat a habit this README already documents, so they belong beside it
rather than as new entries.

`git grep -c` counts lines: a symbol that a file imports and then calls is two
lines, never one. And a check must not grep for a word that the ticket's own
mandated snippet contains — RD-23 required the upload controller's
`dispatch:` property and then asserted zero occurrences of `dispatch`.

The agent hit both, refused to distort the code, and corrected the ticket text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:18:26 +02:00
ehoandClaude Sonnet 5 11e3191099 refactor: split registratie-wizard into three steps (RD-23)
Move the adres, beroep and controle cases out of registratie-wizard.component.ts
into adres.step.ts, beroep.step.ts and controle.step.ts, matching RD-22's
*.step.ts convention. The parent drops from ~568 to 274 lines and loses its
`eslint-disable max-lines`.

The upload controller moves into beroep.step.ts and emits `uploadMsg` instead of
dispatching directly; the parent maps that back onto the machine's `Upload`
message. `onDiplomaKeuze` stays in the parent (message construction from the DUO
payload belongs in the container) and now takes only the chosen id, reading its
own `duoData` computed instead of receiving the DUO payload as an argument. Each
step injects `RegistratieLookupStore` directly for its own async presentation
(adresStatus, the DUO lookup, samenvattingVragen) — the sanctioned exception,
since it is a root singleton. Markup moved verbatim; the `@@` id count across
the directory stays 43.

Two of the ticket's acceptance numbers do not hold against correct code and are
corrected in the ticket file: `createUploadController` is 2 lines (import +
call), not 1 — `git grep -c` counts lines, and the same shape gives 2 for
`createStore` and 3 for `createDraftSync` elsewhere in this codebase. `dispatch`
is 1, not 0 — decision 4's mandated `UploadControllerDeps.dispatch` property
name is that string even though it is not the machine's dispatch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:17:25 +02:00
ehoandClaude Sonnet 5 8e1de38c68 refactor: split intake-wizard into three step components (RD-22)
The parent held one @switch with three @case blocks — three screens'
markup in one file. Each case is independent and needs only the
answers, the errors, and (for two of them) the scholing threshold.

Extract buitenland.step.ts, werk.step.ts, and review.step.ts as pure,
presentational steps: inputs down, one narrow output up, dispatch
never passed down. The parent keeps the store, the shell, and
draftSync, and maps each step's output back to a machine message.

This is the first *.step.ts in the repo, so it sets the naming
convention that RD-23 does the same job with.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:58:52 +02:00
ehoandClaude Sonnet 5 4b3e6a6cfd refactor: move selection surgery into rich-text-dom.ts (RD-21)
deleteAdjacentChip and insert did getSelection()/Range work inside the
component, which pushed it over the max-lines budget under a disable
comment. rich-text-dom.ts already owns the DOM boundary, so the surgery
moves there as two new exports, chipAtCaret and insertChipAtCaret, and
the component keeps only its event-handling and output concerns.

adjacentChip stays exported with its own spec case. The component
disable comment is gone, since the file is now under the line budget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:42:21 +02:00
ehoandClaude Opus 5 8a3e42015b docs: add RD-36 — rename the stale ui/dashboard/ folder
RD-03 moved the dashboard page into the `overzicht` context and RD-04 left the
folder name alone, recording it as stale. It stays stale: the folder is named
after a page that lives somewhere else, so a reader looking for the dashboard
finds four sections that are not it.

Measured cost: 8 `git mv`s and four import lines in one file. Fold in two doc
paths that still name the moved page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:31:55 +02:00
ehoandClaude Opus 5 dff5f96bb3 docs: correct RD-20's file count, and record the ninth miss
RD-20 asserted `git grep -l "toWizardErrors"` would find 5 files, but its own
Steps list regenerates `behaviour-spec.mdx`, and the generator publishes every
`describe` title. Naming a spec after the function it tests puts the name in
the generated document too, so the honest count is 6.

The agent refused to rename the describe block to satisfy the number, which is
the correct response and matches the precedent from RD-14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:01:01 +02:00
ehoandClaude Sonnet 5 831940f1b9 refactor: extract toWizardErrors, adopted by all 3 wizards (RD-20)
Each wizard flattened its per-field error record into the shell's
WizardError[] summary with its own copy of the same loop. Extract one
pure helper, wizard-errors.ts, next to naarStapLabel. Add a spec that
covers a flat record, an empty record, skipped undefined/empty-string
values, the idPrefix, and a skipped nested object.

registratie-wizard.machine.ts changes Errors from an interface to a
type alias, because only a type alias gets an implicit index
signature and is assignable to the helper's Record<string, unknown>
parameter. The other two machines already declare their error maps as
type aliases, so this also makes the three consistent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:00:04 +02:00
ehoandClaude Opus 5 a196a380ce docs: restore the two backlog paths RD-19's target of 0 removed
RD-18 established that a ticket reference inside a path to a document that
still exists is a pointer, not provenance, and exempted one. RD-19 did not
re-run that check against `backend/`, and asserted a target of 0. Two such
paths existed, so the agent described the two documents in prose instead. The
path no longer resolves and the reader must search.

Restore both paths, correct the ticket's decision 1 and acceptance target to
2, and record the miss as the eighth in the README's list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:50:06 +02:00
ehoandClaude Sonnet 5 8560746d15 refactor: strip WP-/RB- ticket refs from backend (RD-19)
The backend half of the sweep RD-18 did for the front end. git blame
holds the provenance and stays correct when the code moves; the
comment names a closed ticket and tells the reader nothing the
sentence around it does not.

public/letter.css and LetterHtml.golden.html change together, because
the renderer inlines the CSS and the golden file snapshots the
result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:48:08 +02:00
ehoandClaude Sonnet 5 dd11eafe50 refactor: strip WP-/RB- ticket refs from apps and libs (RD-18)
204 WP-NN/RB-NN comments named a closed ticket instead of the code they
sit next to. git blame already records history and stays correct when
code moves; the comment does not. This sweep removes the reference and
keeps the sentence, across 95 files in apps/ and libs/ plus the
behaviour-spec generator's header text.

Eleven references stay: five story files justify an a11y disable per
the README's rule, and one line in a11y.mdx documents that convention.
Two sentences needed a rewrite, not a deletion, so the reference's
meaning survives its removal. behaviour-spec.mdx is regenerated, not
hand-edited.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:23:07 +02:00
ehoandClaude Sonnet 5 3895588b9a refactor: story titles to Domein/<Context>/<Name>; add 2 missing stories (RD-04)
Three dashboard-section stories used a fourth title segment
(`Domein/Registratie/Dashboard/<Name>`) that the sidebar rule does not have.
Drop the `Dashboard/` segment so all story titles follow the one rule from
CLAUDE.md decision 5.

Add the two missing stories for sections that have more than one visual
state: `wat-moet-ik-regelen` (4 states) and `wat-wilt-u-doen` (2 states, the
first story in the new `Domein/Overzicht/` bucket). `beheer-links` gets no
story — it has one visual state and its other branch renders nothing.

The `MetTaken` story disables the a11y addon with the same reason and WP-11
reference already used on `task-list.stories.ts` and `choice-list.stories.ts`:
`app-choice-link`'s host sits between the keuzelijst `<ul>` and its `<li>`,
a pre-existing structural gap this ticket does not fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 20:56:17 +02:00
ehoandClaude Opus 5 fb2e58ab87 docs: record the concurrent-agent trap that RD-17 hit
`/clear` ends the supervisor's context. It does not stop the developer agent
that session spawned. A fresh supervisor read the half-finished tree as an
interrupted session and dispatched a second agent onto the same ticket. Both
agents then ran `dotnet test` in one checkout, which truncated the shared
SQLite file and produced the stale-database failure the Troubleshooting
section already records under a different cause.

Also record that a bare `dotnet test` runs the OpenZaak integration test that
both CI scripts filter out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 20:41:32 +02:00
ehoandClaude Sonnet 5 e221834f6e refactor: add successOr, sweep remaining inline unwraps (RD-17)
Eight sites hand-rolled `rd.tag === 'Success' ? rd.value : fallback`. Six
take the new `successOr(rd, fallback)`, one takes the existing `successOf`,
and one (`big-profile.store.ts`) uses the existing `map`, since it returns a
RemoteData rather than an unwrapped value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 20:33:56 +02:00
ehoandClaude Opus 5 c36d9e3ff0 docs: drop RD-16 — the instruction would have discarded server decisions
RD-16 was to make parseDashboardView return BigProfile directly, on the
plan's claim that DashboardViewDto, DashboardView and BigProfile were
three names for one payload. Reading the type disproves it.

DashboardView is a pair of BigProfile and HerregistratieDecisions.
BigProfile is { registration, person } and has nowhere to put decisions,
so returning it directly would silently drop the server-computed
herregistratie eligibility — the value ADR-0001 says the front end must
render rather than recompute.

The store's two map calls are not a redundant hop either. They project
one aggregate into two independently consumed signals, and six files read
them separately.

Also withdraw the earlier correction that "Step 2 did not fully land".
That claim came from reading the parse signature without reading the type
it returns. Commit 42e7a1e did the right parts, including moving
HerregistratieDecisions into domain, and correctly left alone the part
that would have been wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:50:38 +02:00
ehoandClaude Sonnet 5 fb7b531fdf chore: remove 22 abandoned agent worktrees (RD-15)
The repository carried 22 abandoned agent worktrees under
.claude/worktrees/, left behind by past agent runs. They are
gitignored, so they never reached a commit, but they stayed on disk
and every unqualified repository-wide grep or find walked all 22
copies of the source tree.

Measured before: 48,005 files under .claude/worktrees/, against 856
tracked in the repository. An unqualified search walked 56 times more
files than the repository contains.

The verification gate confirmed both safety conditions before removal:
all 22 worktree-agent-* branch tips were already ancestors of main, and
all 22 worktrees were clean (unmerged: 0, dirty: 0).

Removal steps:
- git worktree remove for each of the 22 worktrees (no rm -rf, so the
  registrations in .git/worktrees/ stay consistent)
- git branch -d for each worktree-agent-* branch (lowercase -d, so an
  unmerged branch would block deletion instead of being force-deleted)
- git worktree prune to clear administrative entries

Measured after: 0 files under .claude/worktrees/, .claude/ shrank from
4.7 GB to 72 KB. The 856 tracked files are unchanged. HEAD is unchanged
from before the removals. npm run ci exits 0.

This ticket changes no tracked source file. The diff is this ticket
file and the README row, because the work is entirely in gitignored
paths and local branch refs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:48:52 +02:00
ehoandClaude Opus 5 c45d1bc0ff docs: git grep -c counts lines, not occurrences (the seventh miss)
RD-14 asserted that a grep for the four SaveState tags would count at
least 4. All four live on one line of a single-line type declaration, so
the honest answer is 1. The executing agent refused to reformat the type
across four lines to satisfy the number, which would have fought prettier
and violated the ticket's own "move verbatim" decision, and flagged the
check instead.

Use `grep -o | wc -l` when you mean occurrences. Say "lines" when you
mean lines.

This is the seventh miscalibrated acceptance command in this backlog, and
the third an executing agent caught using these rules rather than the
supervisor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:46:00 +02:00
ehoandClaude Sonnet 5 a8c7a573fc refactor: move SaveState into debounced-save.ts, delete action-state.ts (RD-14)
RD-12 and RD-13 moved both ActionState consumers into their machines. This
left ActionState with zero users, so this ticket deletes it outright.

SaveState is different. It has two four-way consumers (brief.page.ts and
org-template.page.ts) that still switch on all four cases, so it survives.
It moves into debounced-save.ts, next to createDebouncedSave, the only
function that sets it. Both store imports merge into the existing
debounced-save import line. The doc comments that named ActionState are
reworded, since the type no longer exists. No store's public saveState
signature changes, so brief/ui/ needs no edit.

This completes the phase. Two encodings survive: RemoteData for fetched
data, and each machine's own state union. SaveState stays as an explicitly
justified third encoding, for a separate concern (debounced autosave status)
that the other two do not cover.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:45:04 +02:00
ehoandClaude Sonnet 5 c599fee8e2 refactor: fold org-template's action lifecycle + pendingPublish into one union (RD-13)
Before this change, org-template.store.ts held the action lifecycle in an
actionState signal and the publish impact-confirm gate in an independent
pendingPublish signal. The two were representable in combination, so
pendingPublish === true and busy === true could both hold at once. That
state was meaningless: the UI would show the publish-impact confirmation
while a publish was already in flight.

OrgTemplateState.Loaded now carries one action field, a four-variant union
(Idle | ConfirmingPublish | Busy | Failed). ActionStarted overwrites the
field straight to Busy from any prior tag, so ConfirmingPublish and Busy
can never coexist — not by convention, but because one field can only
hold one tag. requestPublish and cancelPublish become dispatches
(PublishRequested/PublishCancelled); as the reducer already no-ops
outside Loaded, this changes no behaviour. The other four commands
(confirmPublish, rollback, proefbrief, flushSave) keep their existing
loaded() guards. busy, lastError and pendingPublish stay on the store as
computed values reading the new union, with byte-identical public
signatures — no file under brief/ui/ changes.

Ran gen:behaviour-spec for the six new reducer cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:38:23 +02:00
ehoandClaude Opus 5 8e5f48c5d2 docs: scope acceptance checks to the ticket's files, not a directory
The sixth acceptance-check miss, and the first the executing agent
caught using these rules rather than the supervisor. RD-12 asserted
`git grep "ActionState" -- apps/ssp/src/app/brief` returns nothing, but
org-template.store.ts lives in that directory and is deliberately out of
scope until RD-13. The check could never pass.

Sharpen the existing habit into a mechanical step: name the files, never
a parent directory.

The agent also caught the RD-11 substring trap in its own work, where the
new type BriefActionState contains ActionState, and word-anchored the
check instead of renaming away from the natural name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:28:13 +02:00
ehoandClaude Sonnet 5 02d41536df refactor(brief): move the action lifecycle into the machine (RD-12)
The action lifecycle (Idle | Busy | Failed) lived in an imperative
store-level signal, set from ten call sites outside the reducer. The
reducer could not enforce which action transitions are legal.

Add `action` to `BriefState.Loaded`, driven by three new messages
(ActionStarted, ActionFinished, ActionFailed) and handled in `reduce`.
Replace every `actionState.set(...)` call in `brief.store.ts` with the
matching `dispatch`. `BriefLoaded` resets `action` to Idle, so a fresh
load clears a stale action error instead of letting it outlive the
reload.

`busy` and `lastError` stay as `computed`s on the store with a
byte-identical public signature — they are the render seam for four
components and two page templates, and the union belongs in the
machine, not the components. `revealBigNummer` still sets only
`Failed`, never `Busy` — an existing asymmetry, not changed here.
`SaveState`, `org-template.store.ts`, and `pendingPublish` are out of
scope (RD-13, RD-14).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:27:09 +02:00
ehoandClaude Opus 5 43f62ddfee docs: record the fifth acceptance-check miss, and the stale SQLite trap
RD-11 asserted that `git grep "machineRemoteData\|LoadLifecycle"` returns
nothing, but the replacement it mandates is named `fromLoadLifecycle`,
which contains the old name as a substring. The check could never pass.
When a new name contains the old one, anchor on word boundaries.

Add a fourth habit: prefer a number over a prohibition for anything that
must not change. "Do not rename BriefStatus" invites reasoning around it.
"This count must still be 54, and if it moves, revert rather than adjust
the number" does not. RD-11 renamed tags across 19 files with a wire
contract in the same file, and the count held.

Also record a real trap found during RD-11: stale gitignored
bigregister.db artifacts fail backend tests with "no such table" on an
otherwise clean tree. A 0-byte file months old failed 6 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:08:21 +02:00
ehoandClaude Sonnet 5 827c655c1b refactor: fold machine-remote-data into remote-data.ts, PascalCase load lifecycle (RD-11)
`machine-remote-data.ts` defined a third encoding of an in-flight fetch:
`LoadLifecycle`. It had three call sites, all one identical line, and the type
was never imported by name. Move the mapping into `remote-data.ts` as
`fromLoadLifecycle`, beside its neighbour `fromResource` — a `RemoteData`
constructor, not a sixth encoding.

The lowercase `loading`/`failed`/`loaded` tags on `BriefState`,
`OrgTemplateState` and `StamdataEditorState` existed only because
`LoadLifecycle` required them. Now that the constraint is inline and
PascalCase, the three machines' load-lifecycle tags become `Loading`,
`Failed` and `Loaded` — matching their own PascalCase message tags in the
same file. `stamdata-editor.machine.spec.ts` no longer asserts a PascalCase
message producing a lowercase state.

`BriefStatus` (the letter's draft/submitted/approved/rejected/sent status,
parsed off the wire from `BriefViewDto`) is a separate tag family and is
untouched — its tag count stays 54 before and after this change.

Delete `machine-remote-data.ts` and merge its spec into `remote-data.spec.ts`.
Regenerate `behaviour-spec.mdx` (the `machineRemoteData` section heading
becomes `fromLoadLifecycle`) and confirm `gen:snippets` reports no drift, since
`remote-data.ts` carries a showcase region.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:07:00 +02:00
ehoandClaude Sonnet 5 11664d2efa refactor: WizardStatus to a payload-carrying WizardPhase (RD-10)
The wizard shell took two inputs to say one thing: a flat WizardStatus
string and a separate errorMessage input. Each wizard needed three
computeds (failedError, errorMessage, shellStatus) to take the state
apart and put it back together for the shell.

WizardPhase replaces both inputs with one discriminated union. Its
Failed variant carries the message directly, so no data travels through
a second channel. Each wizard now maps its own tags onto WizardPhase in
one computed, composing the localized failure prefix at the same spot
errorMessage did before. The three machines and their own vocabulary
(Editing/Answering/Invullen, Indienen/Ingediend/Mislukt) are unchanged;
only the shell's input contract changes.

The shell reads the Failed message via the existing whenTag helper,
because @switch cannot narrow a union in an Angular template.

Both $localize ids (wizard.indienenMislukt, regWizard.indienenMislukt)
keep byte-identical source text, so no locale file changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 17:45:05 +02:00
ehoandClaude Opus 5 84cbf3f7d8 docs: use git grep for acceptance checks, and fix RD-09's ledger row
Four acceptance commands in this backlog could not pass as written. The
fourth, in RD-09, reached this backlog's own ticket files and 22
gitignored worktrees, so satisfying it literally would have rewritten the
history of completed tickets.

The general fix is `git grep` instead of `grep -r`: it searches tracked
files only, so untracked and gitignored paths cannot pollute the result.
Measured here, `grep -r` finds 132 hits under .claude/ where `git grep`
finds none. RD-17, RD-18 and RD-19 are repo-wide sweeps and depend on
this.

Also correct RD-09's Order row. It claimed the ticket covered a generator
and a skill file; neither teaches the deleted idiom, as recorded in
PLAN.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:28:37 +02:00
ehoandClaude Sonnet 5 d15943bb36 docs: teach the effect map, not the deleted submit method (RD-09)
RD-05 through RD-08 replaced the hand-called submit method with
createStore's effect map. Two teaching documents still showed the old
method in a code block, as the answer to "how does a submit happen?".
Both blocks also called a function that no longer exists.

Rewrite the code block in ARCHITECTURE.md section 2d and its
fp-tea-atomic-design.md counterpart. Both now show the effect map, keyed
on the Submitting tag, using the same herregistratie worked example with
its optimistic begin/confirm/rollback calls. Both use draftSync.submit,
the call the two herregistratie wizards make today.

State the two properties the old idiom lacked, since they are the reason
for the change: entering a state runs its effect, so a dispatch cannot
skip it; and double-submit protection is structural, because the effect
fires only on a tag transition. Add one sentence on the Seed exemption: a
mount or restore message must not trigger a submit.

Fix the one runIfSubmitting() hop in the write walkthrough at
ARCHITECTURE.md's line 574. The rest of section 6a stays stale on
purpose — RD-31 owns it, including its line citations and dead paths.
fp-tea-atomic-design.md's broken pre-monorepo paths stay stale too —
RD-32 owns those.

Set RD-09's Status to done and its README row to done in the same
commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 17:26:52 +02:00
ehoandClaude Sonnet 5 b8aced75e3 refactor: migrate the 3 wizards to the effect map and Primary (RD-08)
The three wizards paired a dispatch with a hand-written effect call
(onPrimary/onRetry + runIfSubmitting/runIfIndienen). A missed call failed
silently. RD-05 added the effect map and RD-07 added the Primary message;
this ticket moves each wizard onto both.

Each wizard now registers its submit effect on createStore, keyed by its
own submitting tag (Submitting for herregistratie and intake, Indienen for
registratie — the type catches a wrong key at compile time). The optimistic
begin/confirm/rollback calls stay inside the effect body, unchanged. The
template dispatches Primary and Retry directly, matching how Back already
worked. onPrimary, onRetry, and runIfSubmitting/runIfIndienen are deleted
from all three components.

herregistratie-wizard drops under the 250-rule-line budget, so its
eslint-disable max-lines header is removed in this same commit (RD-02's
self-cleaning mechanism). intake-wizard and registratie-wizard stay over
budget and keep theirs, both already flagged for RD-22/RD-23.

Three doc comments (in the three machine files, plus one in store.ts) named
the deleted onPrimary()/runIfSubmitting() identifiers in prose. Reworded
them so the "idiom is gone from the repo" grep check is not defeated by its
own explanatory comments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 17:20:52 +02:00
ehoandClaude Opus 5 d0d6a3764b docs: correct the A5 claim about the form-machine generator
PLAN.md stated that plop-templates/form-machine.hbs generates
runIfSubmitting, and that .claude/skills/form-machine/SKILL.md teaches
it, so the next scaffolded form would recreate the bug RD-06 fixed.

Both are false. The plop template is machine-only: 74 lines, no
@Component, no runIfSubmitting. The skill never mentions it.

Two real sites remain, both teaching the deleted idiom in a code block:
ARCHITECTURE.md:314 and :574, and fp-tea-atomic-design.md:342. RD-09 is
therefore a two-document fix, still worth doing, but not the urgent
generator fix this plan claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:14:29 +02:00
ehoandClaude Sonnet 5 be1fcb4b40 feat: add Primary message to the 3 wizard machines (RD-07)
Each wizard component re-derives the step-boundary decision the reducer
already owns: advance on a middle step, submit on the last step. This
ticket moves that decision into the machine, so RD-08 can replace the
component's guard with one dispatch.

Add a `Primary` message to each Msg union, and export a `primary(s)`
function next to the existing `next`/`submit` pair. `primary` is a
three-line branch that delegates to `next`/`submit` and writes no new
validation. Each machine tests "last step" in its own vocabulary, per
the ticket's Decisions block: `herregistratie` checks `step === 3`,
`intake` checks `currentStep(s) === 'review'`, `registratie` checks
`currentStep(s) === 'controle'`. `Next` and `Submit` stay in every
union and every reducer — `Primary` is purely additive.

Add 3 spec cases per machine (9 total): Primary advances from a
non-final step, Primary submits from the final step, and Primary is a
no-op outside the editing state. Each case also asserts the
equivalence the ticket requires for RD-08's migration:
`reduce(s, Primary)` equals `reduce(s, Next)` at a non-final step, and
equals `reduce(s, Submit)` at the final step.

Regenerate `behaviour-spec.mdx` for the 9 new `it()` titles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 17:06:02 +02:00
ehoandClaude Opus 5 d9c56b0c24 fix(ci): make npm run ci --full actually run the full gate
scripts/ci-local.sh gated its two storybook + axe steps on
`[[ "${1:-}" == "--full" ]]`, so the flag had to arrive as a positional
argument. CLAUDE.md documents `npm run ci --full`, but npm parses that
flag itself and exports `npm_config_full=true` instead of passing it
through. Proven with `npm run env --full`.

The documented command therefore skipped both steps and still printed
"local CI passed" — a gate that reported success without running. Anyone
following CLAUDE.md has been skipping storybook-a11y.

Accept either form. Fixing the script makes every existing document
correct, instead of changing four of them and re-teaching the reader.

Found while verifying RD-06, which adds stories and so depends on those
steps actually running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:57:37 +02:00
ehoandClaude Sonnet 5 0c6fd37ed8 fix: give Failed its own branch in the two single-step forms (RD-06)
Before this fix, a failed submit on the besluit-form or the
change-request-form left the user stuck. Both templates rendered `Failed`
through the same `@else` branch as the editable form. In besluit-form the
fields read from `Editing` only, so they went blank. In change-request-form
the fields still showed the sent value, but `SetField` only applies to
`Editing`, so typing did nothing. In both forms the submit button stayed
enabled, but `Submit` is a no-op outside `Editing`. The only escape was a
page reload.

After this fix, `Failed` gets its own template branch: an error message, a
read-only summary of what was sent (an `app-data-block`, reused from the
existing BRP-address pattern), and a "Opnieuw proberen" button that
dispatches `Retry`. Both machines already handle `Retry` (`Failed ->
Submitting` with the preserved data), so no machine change was needed.

Both components also move to `createStore`'s effect map (RD-05): the
`Submitting` handler replaces the hand-called `runIfSubmitting`, so
`onSubmit` is now a single `dispatch`. `runIfSubmitting`/`runIfIndienen`
now remain only in the three wizards, migrated later by RD-08.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 16:55:20 +02:00
ehoandClaude Sonnet 5 a71887a843 feat: add an optional effect map to createStore (RD-05)
createStore now takes a third, optional StoreEffects map. Each key is a
Model tag. The store runs that tag's handler after update() returns, and
only when the store enters the tag: the previous tag differs from the new
tag, and the message is not Seed (the mount/restore message in every
machine that has one).

This closes the gap where a component had to call dispatch(msg) and then
a private runIfSubmitting() by hand, or state got silently stuck. No call
site changes here. RD-06 and RD-08 migrate the 5 components that
duplicate that pattern today.

The effect map is a conditional type, not a generic constraint, so a
tagless Model (store.spec.ts's plain number store) still resolves it to
never and needs no third argument. Both tag checks use a typeof/in guard
for the same reason.

Regenerated libs/shared/docs/behaviour-spec.mdx for the 5 new spec titles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 16:34:28 +02:00
ehoandClaude Opus 5 0697afb737 docs: correct the stale cross-feature edge comment (RD-03 follow-up)
RD-03 added `overzicht: ['registratie']`, so the comment claiming
`herregistratie: ['registratie']` is "the one sanctioned cross-feature
edge" became false. There are two, and both point at registratie.

State what each edge is for instead of counting them, so the next
context added does not make the comment wrong again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:27:10 +02:00
ehoandClaude Sonnet 5 b9fd411283 refactor: move the portal home into its own overzicht context (RD-03)
The dashboard page lived inside registratie, a context with no permitted
outbound edges. Two of its sections do cross-context navigation: action
links to herregistratie/intake/brief/concepts, and an admin links section
that imported ADMIN_LINKS directly from the app shell.

Move the page and these two navigation sections into a new overzicht
context. Declare overzicht: ['registratie'] as the one sanctioned edge, so
the four data sections that stay in registratie/ui/dashboard/ remain
reachable. The route stays at /dashboard: it is a user-visible URL and four
e2e specs assert it, so only the import path and class name change.

beheer-links.section.ts now injects HEADER_ADMIN_LINKS, the same token the
site header uses, instead of importing the app shell's nav config directly.

Used npm run gen:context and corrected its three known-wrong outputs: the
overzicht: [] edge, the scaffolded overzicht.page.ts (the real page is the
moved dashboard.page.ts), and the scaffolded path: 'overzicht' route.

Verified the boundary is enforced, not decorative: a temporary import of a
herregistratie file into overzicht.page.ts makes dep:check fail, as
expected, then reverted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 16:26:11 +02:00
ehoandClaude Sonnet 5 b9d572cfdc feat: add max-lines guard with self-cleaning exemptions (RD-02)
The dashboard refactor proved a page can be 42 lines. This rule holds
the rest of the app to that budget, before the split work in RD-20
through RD-26 begins.

Add max-lines at 250 (skipBlankLines, skipComments), scoped to
{apps,libs}/**/*.{page,component,section,step}.ts. The glob includes
section and step, the file kinds the dashboard refactor invented, so
new files from this arc do not escape the guard.

Add linterOptions.reportUnusedDisableDirectives: 'error' repo-wide.
ESLint 9 only warns on an unused disable by default, so a stale
exemption would not fail the build. At 'error', every later file
split must delete its own exemption or the build goes red.

Add a dated /* eslint-disable max-lines */ header to each of the
seven files that exceed the budget today, each with a reason and the
ticket that removes it. letter-canvas keeps its disable after RD-26,
because most of its lines are CSS and the rest is one letter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 16:18:10 +02:00
ehoandClaude Opus 5 97d9697a84 docs(readable-codebase): scaffold the arc backlog (RD-01)
Convert the approved readable-codebase plan into the ticket protocol this
repo already uses for long arcs (docs/project/backlog/README.md, 75 work
packages driven to done through it).

Adds docs/project/readable-codebase/ with:
- README.md — the ledger: session protocol, GREEN definition, restart
  recovery, the agent loop, and an Order table of 35 tickets with their
  dependencies and status.
- PLAN.md — the design record, committed so the repository holds the
  complete state. A fresh session needs nothing outside it.

Two properties make the arc restart-safe. Each ticket updates its own
Status line and its README row in the same commit as its code, so the
ledger and git log cannot desync. And the plan lives in the repository,
so recovery is three commands and needs no prior context.

Records one correction found while scaffolding: the 22 abandoned agent
checkouts under .claude/worktrees/ are live registered git worktrees, not
orphaned directories. RD-15 must use `git worktree remove` and verify each
branch tip is an ancestor of main first. Spot-checked four; all are
already merged via 637d500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:09:00 +02:00
370 changed files with 10355 additions and 2688 deletions
+23
View File
@@ -109,6 +109,29 @@ module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName)
}, },
}, },
// --- Atomic ladder within libs/shared/src/ui (folder = layer, CLAUDE.md decision 2) ---
{
name: 'atoms-compose-nothing-above',
comment: 'An atom composes nothing above it — no molecule or organism. See CLAUDE.md §2.',
severity: 'error',
from: { path: '^libs/shared/src/ui/atoms/' },
to: { path: '^libs/shared/src/ui/(molecules|organisms)/' },
},
{
name: 'molecules-below-organisms',
comment: 'A molecule composes nothing above it — no organism. See CLAUDE.md §2.',
severity: 'error',
from: { path: '^libs/shared/src/ui/molecules/' },
to: { path: '^libs/shared/src/ui/organisms/' },
},
{
name: 'design-system-not-layout',
comment: 'The design system (ui/) does not depend on layout/ templates. See CLAUDE.md §2.',
severity: 'error',
from: { path: '^libs/shared/src/ui/' },
to: { path: '^libs/shared/src/layout/' },
},
{ {
name: 'no-testing-in-production', name: 'no-testing-in-production',
comment: comment:
+5 -1
View File
@@ -2,9 +2,13 @@
// scaffolding one (see `gen:context`, WP-44). // scaffolding one (see `gen:context`, WP-44).
module.exports = require('./.dependency-cruiser.base.js')( module.exports = require('./.dependency-cruiser.base.js')(
{ {
// Two sanctioned cross-feature edges, both pointing at registratie: the portal home
// composes registratie's dashboard sections (RD-03), and herregistratie builds on a
// registration. Every other context imports only libs/shared and libs/beheer.
overzicht: ['registratie'],
auth: [], auth: [],
registratie: [], registratie: [],
herregistratie: ['registratie'], // the one sanctioned cross-feature edge herregistratie: ['registratie'],
brief: [], brief: [],
showcase: null, // unrestricted — the sanctioned teaching page; nothing imports it showcase: null, // unrestricted — the sanctioned teaching page; nothing imports it
}, },
+4 -4
View File
@@ -210,10 +210,10 @@ each app has its **own Storybook instance** (`.storybook-ssp/`, `.storybook-beha
WP-67 — a single merged tsconfig can't resolve both apps' `@auth/*` at once), each globbing WP-67 — a single merged tsconfig can't resolve both apps' `@auth/*` at once), each globbing
its own app's stories plus both shared libraries'. **Story titles mirror the sidebar's its own app's stories plus both shared libraries'. **Story titles mirror the sidebar's
Design System/Domein split** (see `libs/shared/docs/layers.mdx`): a `libs/shared/ui|layout` Design System/Domein split** (see `libs/shared/docs/layers.mdx`): a `libs/shared/ui|layout`
or `libs/beheer/ui` component is titled `Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`; component is titled `Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`;
a component in an app context's `ui/` is titled `Domein/<Context>/<Name>` — full stop, a component in an app context's `ui/`, or in `libs/beheer/ui`, is titled
regardless of which atomic layer it is (a context organism doesn't get its own `Domein/<Context>/<Name>` — full stop, regardless of which atomic layer it is (a context
`Organisms/` bucket). organism doesn't get its own `Organisms/` bucket).
## Conventions ## Conventions
+3 -3
View File
@@ -20,7 +20,7 @@ export const routes: Routes = [
}, },
{ {
path: 'aanvraag/:id', path: 'aanvraag/:id',
// Same capability the werkvoorraad list itself is gated by (WP-64/65) — the // Same capability the werkvoorraad list itself is gated by — the
// detail page is reachable only from a row already filtered to that capability. // detail page is reachable only from a row already filtered to that capability.
canActivate: [capabilityGuard('aanvraag:beoordelen')], canActivate: [capabilityGuard('aanvraag:beoordelen')],
loadComponent: () => loadComponent: () =>
@@ -36,14 +36,14 @@ export const routes: Routes = [
}, },
{ {
path: 'beheer/audit', path: 'beheer/audit',
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default // Admin-only authz/PII-reveal audit trail. capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces. // unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
canActivate: [capabilityGuard('cases:manage')], canActivate: [capabilityGuard('cases:manage')],
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage), loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
}, },
{ {
path: 'beheer/functies', path: 'beheer/functies',
// Admin-only feature-flag toggles (WP-47), gated by `flags:manage`. // Admin-only feature-flag toggles, gated by `flags:manage`.
canActivate: [capabilityGuard('flags:manage')], canActivate: [capabilityGuard('flags:manage')],
loadComponent: () => loadComponent: () =>
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage), import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
@@ -4,7 +4,7 @@ import { MEDEWERKER_ID, currentRollen } from './medewerker';
/** /**
* Infrastructure: resolves the current medewerker identity into a `Principal` * Infrastructure: resolves the current medewerker identity into a `Principal`
* (ADR-C-004/RB-13). Stands in for a real employee-SSO redirect flow (ADR-0002 §3, * (ADR-C-004). Stands in for a real employee-SSO redirect flow (ADR-0002 §3,
* "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s * "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s
* BSN check, no format to reject, so `authenticate()` takes no input and returns the * BSN check, no format to reject, so `authenticate()` takes no input and returns the
* `Principal` directly rather than a `Result` with an error variant that can never * `Principal` directly rather than a `Result` with an error variant that can never
@@ -1,8 +1,8 @@
import { Component, output } from '@angular/core'; import { Component, output } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
/** /**
* Organism: employee-SSO-style mock login (ADR-C-004/RB-13). No real auth — and, * Organism: employee-SSO-style mock login (ADR-C-004). No real auth — and,
* unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no * unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no
* BSN, and this app has no password of its own to check either way. There is * BSN, and this app has no password of its own to check either way. There is
* nothing to compose beyond one button, which is itself evidence for the ADR — the * nothing to compose beyond one button, which is itself evidence for the ADR — the
@@ -8,7 +8,7 @@ import {
type Err = Error | undefined; type Err = Error | undefined;
/** One aanvraag's beoordeling detail (WP-65) — a root singleton like `WerkvoorraadStore`. /** One aanvraag's beoordeling detail — a root singleton like `WerkvoorraadStore`.
Keyed by id: navigating to a different case resets to Loading. */ Keyed by id: navigating to a different case resets to Loading. */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class BeoordelingStore { export class BeoordelingStore {
@@ -8,7 +8,7 @@ import {
type Err = Error | undefined; type Err = Error | undefined;
/** The behandelaar's queue (WP-64) — a root singleton like `AdminCasesStore`'s ssp /** The behandelaar's queue — a root singleton like `AdminCasesStore`'s ssp
counterpart. Fetch + parse at the trust boundary, publish as RemoteData. */ counterpart. Fetch + parse at the trust boundary, publish as RemoteData. */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class WerkvoorraadStore { export class WerkvoorraadStore {
@@ -2,8 +2,8 @@ import { formatDatumNl } from '@shared/kernel/datum';
import { AanvraagType } from './werkvoorraad-item'; import { AanvraagType } from './werkvoorraad-item';
import { BeoordelingStatus, BeoordelingView } from './beoordeling'; import { BeoordelingStatus, BeoordelingView } from './beoordeling';
/** View-model mapping shared by the werkvoorraad list (WP-64) and the beoordeling /** View-model mapping shared by the werkvoorraad list and the beoordeling
detail screen (WP-65): type/status → labels. Pure, no Angular. Lives here (not in detail screen: type/status → labels. Pure, no Angular. Lives here (not in
`werkvoorraad-item-view.ts`) because `BeoordelingStatus` is the wider of the two `werkvoorraad-item-view.ts`) because `BeoordelingStatus` is the wider of the two
status unions — `werkvoorraad-item-view.ts` re-exports these for its own use. */ status unions — `werkvoorraad-item-view.ts` re-exports these for its own use. */
@@ -1,8 +1,8 @@
import { AanvraagType } from './werkvoorraad-item'; import { AanvraagType } from './werkvoorraad-item';
/** /**
* A case's full status lifecycle as the beoordeling detail screen sees it (WP-65) — * A case's full status lifecycle as the beoordeling detail screen sees it —
* wider than `WerkvoorraadStatus` (WP-64), which only ever sees the two "still open" * wider than `WerkvoorraadStatus`, which only ever sees the two "still open"
* tags. This is the same five-tag union ssp's `AanvraagStatus` models (minus `Concept` * tags. This is the same five-tag union ssp's `AanvraagStatus` models (minus `Concept`
* — the detail endpoint 404s a Concept, it isn't a case a behandelaar can treat yet). * — the detail endpoint 404s a Concept, it isn't a case a behandelaar can treat yet).
*/ */
@@ -1,6 +1,6 @@
import { Result, assertNever } from '@shared/kernel/fp'; import { Result, assertNever } from '@shared/kernel/fp';
/** The three actions the beoordeling screen offers a behandelaar (WP-65b) — mirrors the /** The three actions the beoordeling screen offers a behandelaar — mirrors the
backend's `Besluit` enum member names 1:1 (the wire convention: a string, not a raw backend's `Besluit` enum member names 1:1 (the wire convention: a string, not a raw
enum — see `RecordBesluitRequest`). */ enum — see `RecordBesluitRequest`). */
const BESLUIT_TAGS = ['Goedkeuren', 'Afwijzen', 'MeerInfoOpvragen'] as const; const BESLUIT_TAGS = ['Goedkeuren', 'Afwijzen', 'MeerInfoOpvragen'] as const;
@@ -1,5 +1,5 @@
/** /**
* A queue entry as the behandelportal sees it (WP-64) — the parsed, domain-side view * A queue entry as the behandelportal sees it — the parsed, domain-side view
* of the backend's cross-owner `GET /werkvoorraad`. Pure types, no Angular. * of the backend's cross-owner `GET /werkvoorraad`. Pure types, no Angular.
* *
* The status union is narrower than the SSP's full `AanvraagStatus` (ssp's * The status union is narrower than the SSP's full `AanvraagStatus` (ssp's
@@ -13,7 +13,7 @@ import {
import { AanvraagType } from '@behandeling/domain/werkvoorraad-item'; import { AanvraagType } from '@behandeling/domain/werkvoorraad-item';
/** /**
* Infrastructure adapter for the beoordeling detail read (WP-65) — the only place its * Infrastructure adapter for the beoordeling detail read — the only place its
* HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response is validated + * HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response is validated +
* mapped to domain by the parse* boundary below. * mapped to domain by the parse* boundary below.
*/ */
@@ -3,7 +3,7 @@ import { ApiClient } from '@shared/infrastructure/api-client';
import { Valid } from '@behandeling/domain/besluit.machine'; import { Valid } from '@behandeling/domain/besluit.machine';
/** /**
* Infrastructure adapter for recording a behandelaar's decision (WP-65b) — the single * Infrastructure adapter for recording a behandelaar's decision — the single
* place its HTTP lives. No return value: a successful call means the server accepted * place its HTTP lives. No return value: a successful call means the server accepted
* the transition; the caller reloads `BeoordelingStore` to see the new status (the * the transition; the caller reloads `BeoordelingStore` to see the new status (the
* server, not this adapter, re-validates and is the authority). * server, not this adapter, re-validates and is the authority).
@@ -8,7 +8,7 @@ import {
} from '@behandeling/domain/werkvoorraad-item'; } from '@behandeling/domain/werkvoorraad-item';
/** /**
* Infrastructure adapter for the behandelportal's queue read (WP-64) — the only * Infrastructure adapter for the behandelportal's queue read — the only
* place its HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response * place its HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response
* is validated + mapped to the (narrower) queue domain shape by the parse* boundary * is validated + mapped to the (narrower) queue domain shape by the parse* boundary
* below; a case whose status isn't `Ingediend`/`InBehandeling` is a parse error, not * below; a case whose status isn't `Ingediend`/`InBehandeling` is a parse error, not
@@ -1,7 +1,7 @@
import { Component, input } from '@angular/core'; import { Component, input } from '@angular/core';
import { BeoordelingDocument } from '@behandeling/domain/beoordeling'; import { BeoordelingDocument } from '@behandeling/domain/beoordeling';
/** Organism: the documents linked to an aanvraag (WP-65) — plain links to the existing /** Organism: the documents linked to an aanvraag — plain links to the existing
(pre-existing, unauthenticated — same as ssp's own document previews) content (pre-existing, unauthenticated — same as ssp's own document previews) content
endpoint. No new shared atom: a context-local list, not a reusable building block. */ endpoint. No new shared atom: a context-local list, not a reusable building block. */
@Component({ @Component({
@@ -1,20 +1,21 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { successOf } from '@shared/application/remote-data';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BeoordelingStore } from '@behandeling/application/beoordeling.store'; import { BeoordelingStore } from '@behandeling/application/beoordeling.store';
import { detailRows } from '@behandeling/domain/beoordeling-view'; import { detailRows } from '@behandeling/domain/beoordeling-view';
import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component'; import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component';
import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.component'; import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.component';
/** /**
* Page: one aanvraag's beoordeling detail (WP-65). The werkvoorraad list (WP-64) links * Page: one aanvraag's beoordeling detail. The werkvoorraad list links
* here. `canBesluiten` (server-computed, ADR-0001) gates the decision form (WP-65b) — * here. `canBesluiten` (server-computed, ADR-0001) gates the decision form —
* the page never recomputes the lifecycle itself. On a recorded decision the form emits * the page never recomputes the lifecycle itself. On a recorded decision the form emits
* `decided`, and the page just reloads (the server is the authority on the new status). * `decided`, and the page just reloads (the server is the authority on the new status).
*/ */
@@ -73,10 +74,7 @@ export class BeoordelingPage {
protected retryText = $localize`:@@beoordeling.retry:Opnieuw proberen`; protected retryText = $localize`:@@beoordeling.retry:Opnieuw proberen`;
protected rows = detailRows; protected rows = detailRows;
protected readonly view = computed(() => { protected readonly view = computed(() => successOf(this.store.view()));
const rd = this.store.view();
return rd.tag === 'Success' ? rd.value : undefined;
});
constructor() { constructor() {
void this.store.load(this.id); void this.store.load(this.id);
@@ -1,18 +1,23 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component'; import {
RadioGroupComponent,
RadioOption,
} from '@shared/ui/atoms/radio-group/radio-group.component';
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp'; import { whenTag } from '@shared/kernel/fp';
import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine'; import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine';
import { createSubmitBesluit } from '@behandeling/application/submit-besluit'; import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
/** /**
* Organism: the decision form (WP-65b) — goedkeuren/afwijzen/meer-info-opvragen. Same * Organism: the decision form — goedkeuren/afwijzen/meer-info-opvragen. Same
* idiom as every other form in this house (`change-request-form`): all state in one * idiom as every other form in this house (`change-request-form`): all state in one
* signal driven by the pure `reduce` (besluit.machine.ts), submitted via a `submit-*` * signal driven by the pure `reduce` (besluit.machine.ts), submitted via a `submit-*`
* command returning `Result`. The server re-validates the transition and is the * command returning `Result`. The server re-validates the transition and is the
@@ -29,10 +34,35 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
FormFieldComponent, FormFieldComponent,
TextInputComponent, TextInputComponent,
RadioGroupComponent, RadioGroupComponent,
DataBlockComponent,
DataRowComponent,
], ],
template: ` template: `
@if (state().tag === 'Submitted') { @if (state().tag === 'Submitted') {
<app-alert type="ok" i18n="@@besluit.success">Het besluit is vastgelegd.</app-alert> <app-alert type="ok" i18n="@@besluit.success">Het besluit is vastgelegd.</app-alert>
} @else if (state().tag === 'Failed') {
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
<app-alert type="error"
><ng-container i18n="@@besluit.failed">Het vastleggen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
<app-data-block class="app-section" [ariaLabel]="besluitLabelText">
<div app-data-row [key]="besluitLabelText" [value]="besluitOptieLabel()"></div>
@if (toelichting()) {
<div app-data-row [key]="toelichtingLabelText" [value]="toelichting()"></div>
}
</app-data-block>
<div class="app-section">
<app-button
variant="secondary"
(click)="dispatch({ tag: 'Retry' })"
i18n="@@wizard.opnieuwProberen"
>Opnieuw proberen</app-button
>
</div>
} @else { } @else {
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading> <app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
@@ -70,13 +100,6 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
/> />
</app-form-field> </app-form-field>
@if (failedError()) {
<app-alert type="error"
><ng-container i18n="@@besluit.failed">Het vastleggen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
}
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'"> <app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
{{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }} {{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}
</app-button> </app-button>
@@ -86,7 +109,19 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
}) })
export class BesluitFormComponent { export class BesluitFormComponent {
private submit = createSubmitBesluit(); private submit = createSubmitBesluit();
private store = createStore<BesluitState, BesluitMsg>(initial, reduce); // Effect fires once, on Editing -> Submitting (RD-05's tag-transition rule; `Seed` is
// exempt, so a story mounting straight into `Submitting` does not call the network).
private store = createStore<BesluitState, BesluitMsg>(initial, reduce, {
Submitting: async (s, store) => {
const r = await this.submit(this.id(), s.data);
if (r.ok) {
store.dispatch({ tag: 'SubmitConfirmed' });
this.decided.emit();
} else {
store.dispatch({ tag: 'SubmitFailed', error: r.error });
}
},
});
id = input.required<string>(); id = input.required<string>();
decided = output<void>(); decided = output<void>();
@@ -109,12 +144,33 @@ export class BesluitFormComponent {
protected readonly submitLabel = $localize`:@@besluit.submit:Besluit vastleggen`; protected readonly submitLabel = $localize`:@@besluit.submit:Besluit vastleggen`;
protected readonly submitBezigLabel = $localize`:@@besluit.submitBezig:Bezig met vastleggen…`; protected readonly submitBezigLabel = $localize`:@@besluit.submitBezig:Bezig met vastleggen…`;
// Same ids as the form-field labels above, reused for the Failed data-block's row
// keys (the pattern change-request-form already uses for its read-only BRP rows).
protected readonly besluitLabelText = $localize`:@@besluit.besluitLabel:Besluit`;
protected readonly toelichtingLabelText = $localize`:@@besluit.toelichtingLabel:Toelichting`;
private editing = computed(() => whenTag(this.state(), 'Editing')); private editing = computed(() => whenTag(this.state(), 'Editing'));
protected errors = computed(() => this.editing()?.errors ?? {}); protected errors = computed(() => this.editing()?.errors ?? {});
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
protected besluit = computed(() => this.editing()?.draft.besluit ?? ''); /** The value shown in the field — the live draft while editing, the parsed value
protected toelichting = computed(() => this.editing()?.draft.toelichting ?? ''); while submitting/failed (so the user sees what they sent, same idiom as
change-request-form.telefoon()). */
protected besluit = computed(() => {
const s = this.state();
if (s.tag === 'Editing') return s.draft.besluit;
if (s.tag === 'Submitting' || s.tag === 'Failed') return s.data.besluit;
return '';
});
protected toelichting = computed(() => {
const s = this.state();
if (s.tag === 'Editing') return s.draft.toelichting;
if (s.tag === 'Submitting' || s.tag === 'Failed') return s.data.toelichting ?? '';
return '';
});
protected besluitOptieLabel = computed(
() => this.BESLUIT_OPTIONS.find((o) => o.value === this.besluit())?.label ?? '',
);
constructor() { constructor() {
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() })); queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
@@ -122,19 +178,5 @@ export class BesluitFormComponent {
onSubmit() { onSubmit() {
this.dispatch({ tag: 'Submit' }); this.dispatch({ tag: 'Submit' });
this.runIfSubmitting();
}
/** Effect: when we entered Submitting, call the command, then dispatch the outcome. */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
const r = await this.submit(this.id(), s.data);
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.decided.emit();
} else {
this.dispatch({ tag: 'SubmitFailed', error: r.error });
}
} }
} }
@@ -1,12 +1,12 @@
import { Component, input } from '@angular/core'; import { Component, input } from '@angular/core';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component'; import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item'; import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view'; import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view';
/** Organism: the behandelaar's queue as CIBG "aanvragen" rows (WP-64) — composition /** Organism: the behandelaar's queue as CIBG "aanvragen" rows — composition
of the two existing shared/ui molecules, no new atom. Each row links to the of the two existing shared/ui molecules, no new atom. Each row links to the
beoordeling detail page (WP-65). */ beoordeling detail page. */
@Component({ @Component({
selector: 'app-werkvoorraad-list', selector: 'app-werkvoorraad-list',
imports: [ApplicationListComponent, ApplicationLinkComponent], imports: [ApplicationListComponent, ApplicationLinkComponent],
@@ -1,18 +1,19 @@
import { Component, computed, effect, inject } from '@angular/core'; import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store'; import { AccessStore } from '@shared/application/access.store';
import { successOr } from '@shared/application/remote-data';
import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store'; import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store';
import { WerkvoorraadListComponent } from '@behandeling/ui/werkvoorraad-list/werkvoorraad-list.component'; import { WerkvoorraadListComponent } from '@behandeling/ui/werkvoorraad-list/werkvoorraad-list.component';
/** /**
* Page: the behandelaar's werkvoorraad (WP-64) — the behandelportal's landing page. * Page: the behandelaar's werkvoorraad — the behandelportal's landing page.
* Deny-by-default capability gate (`aanvraag:beoordelen`), same idiom as ssp's * Deny-by-default capability gate (`aanvraag:beoordelen`), same idiom as ssp's
* AdminCasesPage: a denial alert for a non-behandelaar, the queue for one. Opening * AdminCasesPage: a denial alert for a non-behandelaar, the queue for one. Opening
* a case's detail is out of scope here (WP-65). * a case's detail is out of scope here.
*/ */
@Component({ @Component({
selector: 'app-werkvoorraad-page', selector: 'app-werkvoorraad-page',
@@ -56,10 +57,7 @@ export class WerkvoorraadPage {
protected access = inject(AccessStore); protected access = inject(AccessStore);
protected canBeoordelen = computed(() => this.access.can('aanvraag:beoordelen')); protected canBeoordelen = computed(() => this.access.can('aanvraag:beoordelen'));
protected items = computed(() => { protected items = computed(() => successOr(this.store.items(), []));
const rd = this.store.items();
return rd.tag === 'Success' ? rd.value : [];
});
protected heading = $localize`:@@werkvoorraad.heading:Werkvoorraad`; protected heading = $localize`:@@werkvoorraad.heading:Werkvoorraad`;
protected intro = $localize`:@@werkvoorraad.intro:Aanvragen die op beoordeling wachten.`; protected intro = $localize`:@@werkvoorraad.intro:Aanvragen die op beoordeling wachten.`;
@@ -71,7 +69,7 @@ export class WerkvoorraadPage {
private loadRequested = false; private loadRequested = false;
constructor() { constructor() {
// Load once the capability resolves to allowed (a 403 GET would be wasted otherwise) — // Load once the capability resolves to allowed (a 403 GET would be wasted otherwise) —
// same guard-against-the-loop idiom as AdminCasesPage (WP-26 lesson). // same guard-against-the-loop idiom as AdminCasesPage.
effect(() => { effect(() => {
if (this.canBeoordelen() && !this.loadRequested) { if (this.canBeoordelen() && !this.loadRequested) {
this.loadRequested = true; this.loadRequested = true;
@@ -8,7 +8,7 @@ export const NAV_ITEMS: readonly HeaderNavItem[] = [
/** This app's admin pages — provided to the shared site header via HEADER_ADMIN_LINKS. /** This app's admin pages — provided to the shared site header via HEADER_ADMIN_LINKS.
No huisstijl (that's the SSP's brief context) or zaken entry — inherited as-is from No huisstijl (that's the SSP's brief context) or zaken entry — inherited as-is from
WP-61's bootstrap trim, not revisited by this migration. */ the bootstrap trim, not revisited by this migration. */
export const ADMIN_LINKS: readonly AdminLink[] = [ export const ADMIN_LINKS: readonly AdminLink[] = [
{ {
label: $localize`:@@header.nav.stamdata:Stamdata`, label: $localize`:@@header.nav.stamdata:Stamdata`,
+7 -5
View File
@@ -14,9 +14,11 @@ export const routes: Routes = [
loadComponent: () => import('@auth/ui/login.page').then((m) => m.LoginPage), loadComponent: () => import('@auth/ui/login.page').then((m) => m.LoginPage),
}, },
{ {
// Path stays 'dashboard' on purpose: it is a user-visible URL and four e2e
// specs assert it. The context is `overzicht`; only the path string differs.
path: 'dashboard', path: 'dashboard',
canActivate: [authGuard], canActivate: [authGuard],
loadComponent: () => import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage), loadComponent: () => import('@overzicht/ui/overzicht.page').then((m) => m.OverzichtPage),
}, },
{ {
path: 'registratie', path: 'registratie',
@@ -59,7 +61,7 @@ export const routes: Routes = [
}, },
{ {
path: 'brief/huisstijl', path: 'brief/huisstijl',
// Admin-only org-template editor (WP-26): capabilityGuard denies-by-default // Admin-only org-template editor: capabilityGuard denies-by-default
// unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces // unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces
// via the OrgAdmin gate — the guard just avoids loading a page that would 403. // via the OrgAdmin gate — the guard just avoids loading a page that would 403.
canActivate: [capabilityGuard('orgtemplate:edit')], canActivate: [capabilityGuard('orgtemplate:edit')],
@@ -76,7 +78,7 @@ export const routes: Routes = [
}, },
{ {
path: 'beheer/zaken', path: 'beheer/zaken',
// Admin-only cases overview + delete (WP-36): capabilityGuard denies-by-default // Admin-only cases overview + delete: capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (Admin role). Backend re-enforces via the // unless GET /me resolved `cases:manage` (Admin role). Backend re-enforces via the
// CasesAdmin gate — the guard just avoids loading a page that would 403. The page // CasesAdmin gate — the guard just avoids loading a page that would 403. The page
// lives in registratie/ui (which owns the Aanvraag aggregate); routed under /beheer. // lives in registratie/ui (which owns the Aanvraag aggregate); routed under /beheer.
@@ -86,14 +88,14 @@ export const routes: Routes = [
}, },
{ {
path: 'beheer/audit', path: 'beheer/audit',
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default // Admin-only authz/PII-reveal audit trail. capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces. // unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
canActivate: [capabilityGuard('cases:manage')], canActivate: [capabilityGuard('cases:manage')],
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage), loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
}, },
{ {
path: 'beheer/functies', path: 'beheer/functies',
// Admin-only feature-flag toggles (WP-47), gated by `flags:manage`. // Admin-only feature-flag toggles, gated by `flags:manage`.
canActivate: [capabilityGuard('flags:manage')], canActivate: [capabilityGuard('flags:manage')],
loadComponent: () => loadComponent: () =>
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage), import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
@@ -7,7 +7,7 @@ import { Principal } from '../domain/principal';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class DigidAdapter { export class DigidAdapter {
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity. // ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity // Real BSN validation (parseBsn) is the trust boundary; swap the fixed identity
// for a real OIDC redirect flow when there's an IdP. // for a real OIDC redirect flow when there's an IdP.
async authenticate(bsn: string): Promise<Result<string, Principal>> { async authenticate(bsn: string): Promise<Result<string, Principal>> {
const r = parseBsn(bsn); const r = parseBsn(bsn);
@@ -1,8 +1,8 @@
import { Component, output } from '@angular/core'; import { Component, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */ /** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
@Component({ @Component({
+1 -1
View File
@@ -1,7 +1,7 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component'; import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
import { SessionStore } from '@auth/application/session.store'; import { SessionStore } from '@auth/application/session.store';
@@ -54,7 +54,7 @@ const caseContext: CaseContext = {
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext }; const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of /** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of
touching the DOM, so a spec can assert a command's success path directly. */ touching the DOM, so a spec can assert a command's success path directly. */
function fakeBlobPresenter() { function fakeBlobPresenter() {
const opened: Blob[] = []; const opened: Blob[] = [];
@@ -158,7 +158,7 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
}); });
}); });
// --- WP-27: undo/redo history + rejection diff --- // --- Undo/redo history + rejection diff ---
function block(id: string, text: string): LetterBlock { function block(id: string, text: string): LetterBlock {
return { return {
@@ -179,7 +179,7 @@ const filledView: BriefView = { ...view, brief: filledBrief };
function loadedBrief(store: BriefStore): Brief { function loadedBrief(store: BriefStore): Brief {
const s = store.model(); const s = store.model();
if (s.tag !== 'loaded') throw new Error('not loaded'); if (s.tag !== 'Loaded') throw new Error('not loaded');
return s.brief; return s.brief;
} }
@@ -308,7 +308,7 @@ describe('BriefStore rejection diff', () => {
describe('BriefStore.previewLetter', () => { describe('BriefStore.previewLetter', () => {
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
it('opens the composed letter via BLOB_PRESENTER on success (RB-28)', async () => { it('opens the composed letter via BLOB_PRESENTER on success', async () => {
const { presenter, opened } = fakeBlobPresenter(); const { presenter, opened } = fakeBlobPresenter();
const store = setup( const store = setup(
{ {
@@ -412,11 +412,11 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
}); });
}); });
// --- RB-22 (CQ-007 expand half): a 404 from GET /brief tolerates by calling the // --- CQ-007's expand half: a 404 from GET /brief tolerates by calling the
// existing reset() command, exactly once. Today's backend never 404s (RB-23 adds // existing reset() command, exactly once. Today's backend never 404s yet;
// that); this fake adapter is what exercises the branch until then. --- // this fake adapter is what exercises the branch until then. ---
describe('BriefStore.load — 404 tolerance (RB-22)', () => { describe('BriefStore.load — 404 tolerance', () => {
const notFound: Result<BriefLoadFailure, BriefView> = { ok: false, error: { tag: 'notFound' } }; const notFound: Result<BriefLoadFailure, BriefView> = { ok: false, error: { tag: 'notFound' } };
const resetOk: Result<string, BriefView> = { ok: true, value: view }; const resetOk: Result<string, BriefView> = { ok: true, value: view };
@@ -431,7 +431,7 @@ describe('BriefStore.load — 404 tolerance (RB-22)', () => {
// Then reset() ran exactly once, and the store ends up loaded from its result. // Then reset() ran exactly once, and the store ends up loaded from its result.
expect(reset).toHaveBeenCalledTimes(1); expect(reset).toHaveBeenCalledTimes(1);
expect(store.model().tag).toBe('loaded'); expect(store.model().tag).toBe('Loaded');
}); });
it('a second 404 does not drive a second reset()', async () => { it('a second 404 does not drive a second reset()', async () => {
@@ -447,6 +447,6 @@ describe('BriefStore.load — 404 tolerance (RB-22)', () => {
// Then reset() ran exactly once — the once-only bound holds across calls, not // Then reset() ran exactly once — the once-only bound holds across calls, not
// just within one — and the second 404 surfaces as an ordinary load failure. // just within one — and the second 404 surfaces as an ordinary load failure.
expect(reset).toHaveBeenCalledTimes(1); expect(reset).toHaveBeenCalledTimes(1);
expect(store.model()).toEqual({ tag: 'failed', reason: BRIEF_LOAD_FAILED }); expect(store.model()).toEqual({ tag: 'Failed', reason: BRIEF_LOAD_FAILED });
}); });
}); });
@@ -1,10 +1,9 @@
import { Injectable, computed, inject, signal } from '@angular/core'; import { Injectable, computed, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp'; import { Result } from '@shared/kernel/fp';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state';
import { createHistory } from '@shared/application/history'; import { createHistory } from '@shared/application/history';
import { createDebouncedSave } from '@shared/application/debounced-save'; import { SaveState, createDebouncedSave } from '@shared/application/debounced-save';
import { machineRemoteData } from '@shared/application/machine-remote-data'; import { fromLoadLifecycle } from '@shared/application/remote-data';
import { import {
Brief, Brief,
CaseContext, CaseContext,
@@ -29,7 +28,7 @@ import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/ * outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never * `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase * stored. The permission flags come from the server's decision DTO (PRD-0002 phase
* P1) via `BriefState.loaded.decisions` — this store never computes them itself. * P1) via `BriefState.Loaded.decisions` — this store never computes them itself.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class BriefStore implements PendingSave { export class BriefStore implements PendingSave {
@@ -41,18 +40,23 @@ export class BriefStore implements PendingSave {
readonly model = this.store.model; readonly model = this.store.model;
private actionState = signal<ActionState>({ tag: 'Idle' }); /** The one-shot action lifecycle now lives on the machine's `Loaded.action` (RD-12);
readonly busy = computed(() => this.actionState().tag === 'Busy'); these stay as plain `computed`s so the render seam (four `busy = input(...)`
components, two page templates) keeps a byte-identical boolean/string API. */
readonly busy = computed(() => {
const s = this.model();
return s.tag === 'Loaded' && s.action.tag === 'Busy';
});
readonly lastError = computed(() => { readonly lastError = computed(() => {
const s = this.actionState(); const s = this.model();
return s.tag === 'Failed' ? s.error : null; return s.tag === 'Loaded' && s.action.tag === 'Failed' ? s.action.error : null;
}); });
/** Surfaced autosave state for the indicator + aria-live region. */ /** Surfaced autosave state for the indicator + aria-live region. */
readonly saveState = signal<SaveState>({ tag: 'Idle' }); readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** Undo/redo is SHELL state, not machine state (WP-27): a `createHistory` stack of /** Undo/redo is SHELL state, not machine state: a `createHistory` stack of
`Brief` snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded `Brief` snapshots (the mechanics live in a shared helper). Only CONTENT edits are recorded
(they flow through `edit()`); status transitions never enter history, or undo would (they flow through `edit()`); status transitions never enter history, or undo would
replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine
changes. */ changes. */
@@ -60,7 +64,7 @@ export class BriefStore implements PendingSave {
readonly canUndo = this.history.canUndo; readonly canUndo = this.history.canUndo;
readonly canRedo = this.history.canRedo; readonly canRedo = this.history.canRedo;
/** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The /** The letter as it stood when it was REJECTED, captured shell-side. The
approver diffs it against the resubmitted letter. POC limit: in-memory only, so a approver diffs it against the resubmitted letter. POC limit: in-memory only, so a
full page reload loses it — a real system would persist the rejected revision. */ full page reload loses it — a real system would persist the rejected revision. */
private rejectionSnapshot = signal<Brief | null>(null); private rejectionSnapshot = signal<Brief | null>(null);
@@ -77,7 +81,7 @@ export class BriefStore implements PendingSave {
); );
readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0); readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
/** The org template the letter renders with (WP-24). Server-owned appearance data, /** The org template the letter renders with. Server-owned appearance data,
not letter state — held beside the machine, never inside it (`brief.machine.ts` not letter state — held beside the machine, never inside it (`brief.machine.ts`
stays untouched by design). Set from every server view that carries it. */ stays untouched by design). Set from every server view that carries it. */
readonly orgTemplate = signal<OrgTemplate | null>(null); readonly orgTemplate = signal<OrgTemplate | null>(null);
@@ -95,11 +99,11 @@ export class BriefStore implements PendingSave {
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps /** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
purely a projection of its loading/failed tags onto the shared async seam. */ purely a projection of its loading/failed tags onto the shared async seam. */
readonly remoteData = computed(() => machineRemoteData(this.model())); readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
private brief = computed<Brief | null>(() => { private brief = computed<Brief | null>(() => {
const s = this.model(); const s = this.model();
return s.tag === 'loaded' ? s.brief : null; return s.tag === 'Loaded' ? s.brief : null;
}); });
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false); readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
@@ -111,7 +115,7 @@ export class BriefStore implements PendingSave {
private decisions = computed(() => { private decisions = computed(() => {
const s = this.model(); const s = this.model();
return s.tag === 'loaded' ? s.decisions : null; return s.tag === 'Loaded' ? s.decisions : null;
}); });
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : [])); readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : [])); readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
@@ -121,7 +125,7 @@ export class BriefStore implements PendingSave {
return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics()); return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());
}); });
/** True once a 404-triggered recovery has been attempted (RB-22, CQ-007's expand /** True once a 404-triggered recovery has been attempted (CQ-007's expand
half — see `recoverFromMissingBrief`). This is the structural once-only bound: half — see `recoverFromMissingBrief`). This is the structural once-only bound:
a repeated 404 falls straight to the `error` branch below and can never reach a repeated 404 falls straight to the `error` branch below and can never reach
`adapter.reset()` a second time, regardless of how many times `load()` runs. */ `adapter.reset()` a second time, regardless of how many times `load()` runs. */
@@ -182,7 +186,7 @@ export class BriefStore implements PendingSave {
} }
private restore(step: (current: Brief) => Brief | undefined) { private restore(step: (current: Brief) => Brief | undefined) {
const s = this.model(); const s = this.model();
if (s.tag !== 'loaded') return; if (s.tag !== 'Loaded') return;
const target = step(s.brief); const target = step(s.brief);
if (target === undefined) return; if (target === undefined) return;
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } }); this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
@@ -196,7 +200,7 @@ export class BriefStore implements PendingSave {
} }
// 600ms debounced autosave (the server is the store of record). Timer mechanics live in // 600ms debounced autosave (the server is the store of record). Timer mechanics live in
// the shared helper; `flushSave` below is the store-specific write + save-state (WP-31). // the shared helper; `flushSave` below is the store-specific write + save-state.
private debouncedSave = createDebouncedSave({ private debouncedSave = createDebouncedSave({
canSave: () => this.canEdit(), canSave: () => this.canEdit(),
flush: () => this.flushSave(), flush: () => this.flushSave(),
@@ -212,31 +216,33 @@ export class BriefStore implements PendingSave {
if (r.ok) { if (r.ok) {
this.saveState.set({ tag: 'Saved' }); this.saveState.set({ tag: 'Saved' });
} else { } else {
this.actionState.set({ tag: 'Failed', error: r.error }); // The autosave failure legitimately surfaces in two places: the small save
// indicator below (kept as-is) and the action error line (RD-12).
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
this.saveState.set({ tag: 'Error' }); this.saveState.set({ tag: 'Error' });
} }
} }
/** Retry a failed autosave — reuses the existing flush path, no new state (WP-27). */ /** Retry a failed autosave — reuses the existing flush path, no new state. */
retrySave() { retrySave() {
void this.flushSave(); void this.flushSave();
} }
/** Demo "start over": recreate the brief server-side and load the fresh view. */ /** Demo "start over": recreate the brief server-side and load the fresh view. */
async resetDemo() { async resetDemo() {
this.actionState.set({ tag: 'Busy' }); this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel(); this.debouncedSave.cancel();
const r = await this.adapter.reset(); const r = await this.adapter.reset();
this.saveState.set({ tag: 'Idle' }); this.saveState.set({ tag: 'Idle' });
if (r.ok) { if (r.ok) {
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
this.orgTemplate.set(r.value.orgTemplate); this.orgTemplate.set(r.value.orgTemplate);
this.caseContext.set(r.value.caseContext); this.caseContext.set(r.value.caseContext);
this.history.clear(); this.history.clear();
this.rejectionSnapshot.set(null); this.rejectionSnapshot.set(null);
this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else { } else {
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
} }
} }
@@ -249,13 +255,13 @@ export class BriefStore implements PendingSave {
letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the
object URL is never revoked. */ object URL is never revoked. */
async previewLetter() { async previewLetter() {
this.actionState.set({ tag: 'Busy' }); this.store.dispatch({ tag: 'ActionStarted' });
const r = await this.previewAdapter.preview(); const r = await this.previewAdapter.preview();
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return; return;
} }
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
this.blobPresenter.open(r.value); this.blobPresenter.open(r.value);
} }
@@ -269,7 +275,8 @@ export class BriefStore implements PendingSave {
async revealBigNummer() { async revealBigNummer() {
const r = await this.revealAdapter.reveal(true); const r = await this.revealAdapter.reveal(true);
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); // Never sets Busy — an existing asymmetry (RD-12), not fixed here.
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return; return;
} }
this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c)); this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c));
@@ -278,15 +285,15 @@ export class BriefStore implements PendingSave {
// A transition: flush any pending save, call the server (authoritative), then mirror // A transition: flush any pending save, call the server (authoritative), then mirror
// the returned status through the pure reducer's guarded transition. // the returned status through the pure reducer's guarded transition.
private async transition(action: () => Promise<Result<string, BriefView>>) { private async transition(action: () => Promise<Result<string, BriefView>>) {
this.actionState.set({ tag: 'Busy' }); this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel(); this.debouncedSave.cancel();
await this.flushSave(); await this.flushSave();
const r = await action(); const r = await action();
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return; return;
} }
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
this.applyServerStatus(r.value); this.applyServerStatus(r.value);
} }
@@ -304,7 +311,7 @@ export class BriefStore implements PendingSave {
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions }); this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
break; break;
case 'rejected': case 'rejected':
// Capture the letter as-rejected for the resubmission diff (WP-27). This is the // Capture the letter as-rejected for the resubmission diff. This is the
// "before" snapshot the approver later compares against. // "before" snapshot the approver later compares against.
this.rejectionSnapshot.set(brief); this.rejectionSnapshot.set(brief);
this.store.dispatch({ this.store.dispatch({
@@ -32,7 +32,7 @@ const subOrgs: SubOrgSummary[] = [
{ subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 }, { subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 },
]; ];
/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of /** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of
touching the DOM, so a spec can assert a command's success path directly. */ touching the DOM, so a spec can assert a command's success path directly. */
function fakeBlobPresenter() { function fakeBlobPresenter() {
const opened: Blob[] = []; const opened: Blob[] = [];
@@ -70,10 +70,10 @@ function setup(
return TestBed.inject(OrgTemplateStore); return TestBed.inject(OrgTemplateStore);
} }
// --- RB-28 (TE-006): proefbrief() ends in BLOB_PRESENTER.open, not a raw // --- TE-006: proefbrief() ends in BLOB_PRESENTER.open, not a raw
// window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. --- // window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. ---
describe('OrgTemplateStore.proefbrief (RB-28)', () => { describe('OrgTemplateStore.proefbrief', () => {
it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => { it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => {
// Given a loaded sub-org template. // Given a loaded sub-org template.
const { presenter, opened } = fakeBlobPresenter(); const { presenter, opened } = fakeBlobPresenter();
@@ -1,8 +1,7 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state'; import { SaveState, createDebouncedSave } from '@shared/application/debounced-save';
import { createDebouncedSave } from '@shared/application/debounced-save'; import { fromLoadLifecycle } from '@shared/application/remote-data';
import { machineRemoteData } from '@shared/application/machine-remote-data';
import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter'; import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter';
import { UploadShellService } from '@shared/application/upload-shell.service'; import { UploadShellService } from '@shared/application/upload-shell.service';
import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine'; import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine';
@@ -13,6 +12,7 @@ import {
SubOrgSummary, SubOrgSummary,
} from '@brief/domain/org-template'; } from '@brief/domain/org-template';
import { import {
OrgTemplateActionState,
OrgTemplateMsg, OrgTemplateMsg,
OrgTemplateState, OrgTemplateState,
initial, initial,
@@ -22,13 +22,13 @@ import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves'; import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
import { BLOB_PRESENTER } from '@shared/application/blob-presenter'; import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>; type LoadedState = Extract<OrgTemplateState, { tag: 'Loaded' }>;
const LOGO_CATEGORY = 'org-logo'; const LOGO_CATEGORY = 'org-logo';
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`; const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;
/** /**
* Root singleton for the admin org-template editor (WP-26). The Elm machine owns the * Root singleton for the admin org-template editor. The Elm machine owns the
* editable draft; commands here do the debounced save, publish (impact-confirm), * editable draft; commands here do the debounced save, publish (impact-confirm),
* rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The * rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The
* logo upload reuses the shared upload transport; its completion mutates the draft * logo upload reuses the shared upload transport; its completion mutates the draft
@@ -47,22 +47,28 @@ export class OrgTemplateStore implements PendingSave {
readonly subOrgs = signal<readonly SubOrgSummary[]>([]); readonly subOrgs = signal<readonly SubOrgSummary[]>([]);
readonly selectedSubOrgId = signal<string | null>(null); readonly selectedSubOrgId = signal<string | null>(null);
private actionState = signal<ActionState>({ tag: 'Idle' }); /** The one-shot action lifecycle and the publish impact-confirm gate now live on
readonly busy = computed(() => this.actionState().tag === 'Busy'); the machine's `Loaded.action` as one four-variant union (RD-13); these stay as
plain `computed`s so the render seam (the editor organism's `input()`s, the
page template) keeps a byte-identical boolean/string API. */
private action = computed<OrgTemplateActionState>(() => this.loaded()?.action ?? { tag: 'Idle' });
readonly busy = computed(() => this.action().tag === 'Busy');
readonly lastError = computed(() => { readonly lastError = computed(() => {
const s = this.actionState(); const a = this.action();
return s.tag === 'Failed' ? s.error : null; return a.tag === 'Failed' ? a.error : null;
}); });
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST).
Before RD-13 this was an independent boolean, so it could be `true` at the same
time `busy` was `true` — representable and meaningless. It is now derived from
the same union `busy` reads, so the two are mutually exclusive by construction. */
readonly pendingPublish = computed(() => this.action().tag === 'ConfirmingPublish');
readonly saveState = signal<SaveState>({ tag: 'Idle' }); readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */ readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
readonly pendingPublish = signal(false);
readonly remoteData = computed(() => machineRemoteData(this.model()));
private loaded = computed<LoadedState | null>(() => { private loaded = computed<LoadedState | null>(() => {
const s = this.model(); const s = this.model();
return s.tag === 'loaded' ? s : null; return s.tag === 'Loaded' ? s : null;
}); });
readonly draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null); readonly draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null);
readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload); readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);
@@ -101,7 +107,7 @@ export class OrgTemplateStore implements PendingSave {
// the length guard makes it idempotent (no dispatch loop). // the length guard makes it idempotent (no dispatch loop).
effect(() => { effect(() => {
const s = this.model(); const s = this.model();
if (s.tag !== 'loaded' || s.upload.categories.length > 0) return; if (s.tag !== 'Loaded' || s.upload.categories.length > 0) return;
const status = this.categoriesRes.status(); const status = this.categoriesRes.status();
if (status === 'resolved' || status === 'local') if (status === 'resolved' || status === 'local')
this.dispatchUpload({ this.dispatchUpload({
@@ -145,7 +151,7 @@ export class OrgTemplateStore implements PendingSave {
this.debouncedSave.schedule(); this.debouncedSave.schedule();
} }
// 600ms debounced autosave (same idiom as BriefStore, WP-31). Timer mechanics live in the // 600ms debounced autosave (same idiom as BriefStore). Timer mechanics live in the
// shared helper; `flushSave` below is the store-specific write + save-state. // shared helper; `flushSave` below is the store-specific write + save-state.
private debouncedSave = createDebouncedSave({ private debouncedSave = createDebouncedSave({
canSave: () => this.loaded() !== null, canSave: () => this.loaded() !== null,
@@ -165,60 +171,64 @@ export class OrgTemplateStore implements PendingSave {
this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft }); this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });
} else { } else {
this.saveState.set({ tag: 'Error' }); this.saveState.set({ tag: 'Error' });
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
} }
} }
// --- publish (impact-confirm) / rollback / proefbrief --- // --- publish (impact-confirm) / rollback / proefbrief ---
// RD-13: `requestPublish`/`cancelPublish` are the only two commands here that do
// NOT guard on `loaded()` — as dispatches they no-op outside `Loaded` by
// construction (the reducer's own guard), so behaviour is unchanged.
requestPublish() { requestPublish() {
this.pendingPublish.set(true); this.store.dispatch({ tag: 'PublishRequested' });
} }
cancelPublish() { cancelPublish() {
this.pendingPublish.set(false); this.store.dispatch({ tag: 'PublishCancelled' });
} }
async confirmPublish() { async confirmPublish() {
const s = this.loaded(); const s = this.loaded();
if (!s) return; if (!s) return;
this.pendingPublish.set(false); // ActionStarted overwrites `action` straight to Busy, so ConfirmingPublish and
this.actionState.set({ tag: 'Busy' }); // Busy are never simultaneously true (RD-13).
this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel(); this.debouncedSave.cancel();
await this.flushSave(); // publish the saved draft — flush any pending edit first await this.flushSave(); // publish the saved draft — flush any pending edit first
const r = await this.adapter.publish(s.subOrgId); const r = await this.adapter.publish(s.subOrgId);
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return; return;
} }
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0 await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0
} }
async rollback(version: number) { async rollback(version: number) {
const s = this.loaded(); const s = this.loaded();
if (!s) return; if (!s) return;
this.actionState.set({ tag: 'Busy' }); this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel(); this.debouncedSave.cancel();
const r = await this.adapter.rollback(s.subOrgId, version); const r = await this.adapter.rollback(s.subOrgId, version);
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return; return;
} }
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft
} }
async proefbrief() { async proefbrief() {
const s = this.loaded(); const s = this.loaded();
if (!s) return; if (!s) return;
this.actionState.set({ tag: 'Busy' }); this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel(); this.debouncedSave.cancel();
await this.flushSave(); // the proefbrief renders the server's draft await this.flushSave(); // the proefbrief renders the server's draft
const r = await this.adapter.proefbrief(s.subOrgId); const r = await this.adapter.proefbrief(s.subOrgId);
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return; return;
} }
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
this.blobPresenter.open(r.value); this.blobPresenter.open(r.value);
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import { Brief, LetterBlock, allBlocks } from './brief';
/** /**
* The rejection diff as a PURE function over two immutable `Brief` values — the whole * The rejection diff as a PURE function over two immutable `Brief` values — the whole
* teaching payload of WP-27: because state is one value, "what changed since the letter * teaching payload here: because state is one value, "what changed since the letter
* was rejected" is just a fold over two snapshots, no change-tracking bookkeeping. * was rejected" is just a fold over two snapshots, no change-tracking bookkeeping.
* *
* Blocks are matched by `blockId` (stable `local-N`/seed ids): * Blocks are matched by `blockId` (stable `local-N`/seed ids):
@@ -76,7 +76,7 @@ const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sectio
}); });
const sectionBlocks = (s: BriefState, key: string) => const sectionBlocks = (s: BriefState, key: string) =>
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : []; s.tag === 'Loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
const passageIds = (s: BriefState, key: string) => const passageIds = (s: BriefState, key: string) =>
sectionBlocks(s, key) sectionBlocks(s, key)
@@ -92,12 +92,12 @@ describe('brief.machine reduce', () => {
availablePassages: [], availablePassages: [],
decisions, decisions,
}).tag, }).tag,
).toBe('loaded'); ).toBe('Loaded');
}); });
it('BriefLoadFailed moves loading to failed with the reason', () => { it('BriefLoadFailed moves loading to failed with the reason', () => {
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({ expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
tag: 'failed', tag: 'Failed',
reason: 'x', reason: 'x',
}); });
}); });
@@ -210,7 +210,7 @@ describe('brief.machine reduce', () => {
comments: 'graag aanpassen', comments: 'graag aanpassen',
}); });
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' }); const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft'); expect(next.tag === 'Loaded' && next.brief.status.tag).toBe('draft');
expect(sectionBlocks(next, 'slot')).toHaveLength(1); expect(sectionBlocks(next, 'slot')).toHaveLength(1);
}); });
@@ -220,7 +220,7 @@ describe('brief.machine reduce', () => {
// fill the required section via the besluit, then submit // fill the required section via the besluit, then submit
const filled = reduce(loaded(), besluit('positief')); const filled = reduce(loaded(), besluit('positief'));
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions }); const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({ expect(submitted.tag === 'Loaded' && submitted.brief.status).toEqual({
tag: 'submitted', tag: 'submitted',
submittedBy: 'u1', submittedBy: 'u1',
submittedAt: 't', submittedAt: 't',
@@ -232,7 +232,7 @@ describe('brief.machine reduce', () => {
// approve from draft is a no-op // approve from draft is a no-op
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded()); expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions }); const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({ expect(approved.tag === 'Loaded' && approved.brief.status).toEqual({
tag: 'approved', tag: 'approved',
approvedBy: 'u2', approvedBy: 'u2',
approvedAt: 't2', approvedAt: 't2',
@@ -248,7 +248,7 @@ describe('brief.machine reduce', () => {
comments: 'nee', comments: 'nee',
decisions, decisions,
}); });
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({ expect(rejected.tag === 'Loaded' && rejected.brief.status).toEqual({
tag: 'rejected', tag: 'rejected',
rejectedBy: 'u2', rejectedBy: 'u2',
rejectedAt: 't2', rejectedAt: 't2',
@@ -262,7 +262,40 @@ describe('brief.machine reduce', () => {
// send from submitted is a no-op // send from submitted is a no-op
expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted); expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions }); const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions });
expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' }); expect(sent.tag === 'Loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
});
// --- RD-12: the action lifecycle lives on `Loaded.action`, driven by three msgs ---
it('ActionStarted moves a loaded brief to Busy', () => {
const s = reduce(loaded(), { tag: 'ActionStarted' });
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Busy' });
});
it('ActionFailed carries the error', () => {
const s = reduce(loaded(), { tag: 'ActionFailed', error: 'niet gelukt' });
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Failed', error: 'niet gelukt' });
});
it('ActionFinished returns to Idle', () => {
const busy = reduce(loaded(), { tag: 'ActionStarted' });
const s = reduce(busy, { tag: 'ActionFinished' });
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Idle' });
});
it('BriefLoaded resets a stale action error to Idle', () => {
const failed = reduce(loaded(), { tag: 'ActionFailed', error: 'niet gelukt' });
const reloaded = reduce(failed, {
tag: 'BriefLoaded',
brief: briefWith({ tag: 'draft' }),
availablePassages: lib,
decisions,
});
expect(reloaded.tag === 'Loaded' && reloaded.action).toEqual({ tag: 'Idle' });
});
it('an action message is a no-op when the brief is not loaded', () => {
expect(reduce(initialLoading(), { tag: 'ActionStarted' })).toEqual(initialLoading());
}); });
it('a status transition replaces decisions with the fresh server value', () => { it('a status transition replaces decisions with the fresh server value', () => {
@@ -280,10 +313,10 @@ describe('brief.machine reduce', () => {
at: 't2', at: 't2',
decisions: staleApprover, decisions: staleApprover,
}); });
expect(approved.tag === 'loaded' && approved.decisions).toEqual(staleApprover); expect(approved.tag === 'Loaded' && approved.decisions).toEqual(staleApprover);
}); });
}); });
function initialLoading(): BriefState { function initialLoading(): BriefState {
return { tag: 'loading' }; return { tag: 'Loading' };
} }
+30 -10
View File
@@ -36,17 +36,22 @@ import { passagesForBesluit } from './besluit';
* structurally impossible (a pasted `{{…}}` is caught by the linter as `malformed`). * structurally impossible (a pasted `{{…}}` is caught by the linter as `malformed`).
*/ */
/** The one-shot action lifecycle (submit/approve/reject/send/preview/reveal/reset),
owned by the reducer instead of an imperative store-level signal (RD-12). */
export type BriefActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
export type BriefState = export type BriefState =
| { tag: 'loading' } | { tag: 'Loading' }
| { | {
tag: 'loaded'; tag: 'Loaded';
brief: Brief; brief: Brief;
availablePassages: readonly LibraryPassage[]; availablePassages: readonly LibraryPassage[];
decisions: BriefDecisions; decisions: BriefDecisions;
action: BriefActionState;
} }
| { tag: 'failed'; reason: string }; | { tag: 'Failed'; reason: string };
export const initial: BriefState = { tag: 'loading' }; export const initial: BriefState = { tag: 'Loading' };
export type BriefMsg = export type BriefMsg =
| { | {
@@ -65,7 +70,10 @@ export type BriefMsg =
| { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved | { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
| { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected | { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
| { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent | { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent
| { tag: 'Seed'; state: BriefState }; | { tag: 'Seed'; state: BriefState }
| { tag: 'ActionStarted' } // a one-shot action (submit/approve/preview/…) began
| { tag: 'ActionFinished' } // it completed successfully
| { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */ /** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
function isEditable(status: BriefStatus): boolean { function isEditable(status: BriefStatus): boolean {
@@ -110,7 +118,7 @@ function mapBlocks(brief: Brief, f: (blocks: readonly LetterBlock[]) => LetterBl
/** Apply an edit to the brief, guarded by status. A rejected letter reopens to draft. */ /** Apply an edit to the brief, guarded by status. A rejected letter reopens to draft. */
function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState { function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
if (s.tag !== 'loaded' || !isEditable(s.brief.status)) return s; if (s.tag !== 'Loaded' || !isEditable(s.brief.status)) return s;
let brief = f(s.brief); let brief = f(s.brief);
if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } }; if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } };
return { ...s, brief }; return { ...s, brief };
@@ -189,13 +197,16 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
switch (m.tag) { switch (m.tag) {
case 'BriefLoaded': case 'BriefLoaded':
return { return {
tag: 'loaded', tag: 'Loaded',
brief: m.brief, brief: m.brief,
availablePassages: m.availablePassages, availablePassages: m.availablePassages,
decisions: m.decisions, decisions: m.decisions,
// A fresh load clears a stale action error rather than letting it outlive
// the reload (RD-12, decision 4).
action: { tag: 'Idle' },
}; };
case 'BriefLoadFailed': case 'BriefLoadFailed':
return { tag: 'failed', reason: m.reason }; return { tag: 'Failed', reason: m.reason };
case 'Seed': case 'Seed':
return m.state; return m.state;
@@ -203,7 +214,7 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
// drafter's free text. `availablePassages` lives on the loaded state, so this stays pure. // drafter's free text. `availablePassages` lives on the loaded state, so this stays pure.
case 'BesluitSelected': case 'BesluitSelected':
return withEdit(s, (b) => return withEdit(s, (b) =>
s.tag === 'loaded' && isSectionEditable(b, 'kern') s.tag === 'Loaded' && isSectionEditable(b, 'kern')
? composeKern(b, s.availablePassages, m.besluit, m.reasons) ? composeKern(b, s.availablePassages, m.besluit, m.reasons)
: b, : b,
); );
@@ -260,6 +271,15 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
case 'Sent': case 'Sent':
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions); return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions);
// The action lifecycle (RD-12): a no-op unless a brief is loaded, since there is
// nothing to attach the action state to otherwise.
case 'ActionStarted':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s;
case 'ActionFinished':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
case 'ActionFailed':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s;
default: default:
return assertNever(m); return assertNever(m);
} }
@@ -275,6 +295,6 @@ function transition(
decisions: BriefDecisions, decisions: BriefDecisions,
guard: (b: Brief) => boolean = () => true, guard: (b: Brief) => boolean = () => true,
): BriefState { ): BriefState {
if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s; if (s.tag !== 'Loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
return { ...s, brief: { ...s.brief, status: next() }, decisions }; return { ...s, brief: { ...s.brief, status: next() }, decisions };
} }
@@ -26,7 +26,7 @@ const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView =>
}); });
const loaded = (): OrgTemplateState => const loaded = (): OrgTemplateState =>
reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() }); reduce({ tag: 'Loading' }, { tag: 'DraftLoaded', view: view() });
const logoCategory: DocumentCategory = { const logoCategory: DocumentCategory = {
categoryId: 'org-logo', categoryId: 'org-logo',
@@ -41,7 +41,7 @@ const logoCategory: DocumentCategory = {
describe('org-template.machine', () => { describe('org-template.machine', () => {
it('DraftLoaded moves to loaded with the draft, clean', () => { it('DraftLoaded moves to loaded with the draft, clean', () => {
const s = expectTag(loaded(), 'loaded'); const s = expectTag(loaded(), 'Loaded');
expect(s.draft.orgName).toBe('CIBG'); expect(s.draft.orgName).toBe('CIBG');
expect(s.subOrgId).toBe('cibg-registers'); expect(s.subOrgId).toBe('cibg-registers');
expect(s.unsentBriefs).toBe(2); expect(s.unsentBriefs).toBe(2);
@@ -49,14 +49,14 @@ describe('org-template.machine', () => {
}); });
it('LoadFailed carries the reason', () => { it('LoadFailed carries the reason', () => {
const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' }); const s = reduce({ tag: 'Loading' }, { tag: 'LoadFailed', reason: 'boom' });
expect(s).toEqual({ tag: 'failed', reason: 'boom' }); expect(s).toEqual({ tag: 'Failed', reason: 'boom' });
}); });
it('FieldEdited edits the draft and marks dirty', () => { it('FieldEdited edits the draft and marks dirty', () => {
const s = expectTag( const s = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }), reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }),
'loaded', 'Loaded',
); );
expect(s.draft.orgName).toBe('CIBG Nieuw'); expect(s.draft.orgName).toBe('CIBG Nieuw');
expect(s.dirty).toBe(true); expect(s.dirty).toBe(true);
@@ -65,7 +65,7 @@ describe('org-template.machine', () => {
it('MarginEdited edits one edge and marks dirty', () => { it('MarginEdited edits one edge and marks dirty', () => {
const s = expectTag( const s = expectTag(
reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }), reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }),
'loaded', 'Loaded',
); );
expect(s.draft.margins.topMm).toBe(40); expect(s.draft.margins.topMm).toBe(40);
expect(s.draft.margins.leftMm).toBe(20); expect(s.draft.margins.leftMm).toBe(20);
@@ -75,9 +75,9 @@ describe('org-template.machine', () => {
it('DraftSaved clears dirty when the saved draft is the current one', () => { it('DraftSaved clears dirty when the saved draft is the current one', () => {
const edited = expectTag( const edited = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }), reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
'loaded', 'Loaded',
); );
const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'loaded'); const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'Loaded');
expect(s.dirty).toBe(false); expect(s.dirty).toBe(false);
expect(s.draft.orgName).toBe('X'); expect(s.draft.orgName).toBe('X');
}); });
@@ -85,20 +85,20 @@ describe('org-template.machine', () => {
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => { it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
const editing = expectTag( const editing = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }), reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
'loaded', 'Loaded',
); );
const savedDraft = editing.draft; const savedDraft = editing.draft;
// a further edit changes the draft reference before the save resolves // a further edit changes the draft reference before the save resolves
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' }); const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'loaded'); const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'Loaded');
expect(s.dirty).toBe(true); expect(s.dirty).toBe(true);
}); });
it('edits are no-ops in non-loaded states', () => { it('edits are no-ops in non-loaded states', () => {
expect( expect(
reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }), reduce({ tag: 'Loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
).toEqual({ ).toEqual({
tag: 'loading', tag: 'Loading',
}); });
}); });
@@ -122,7 +122,7 @@ describe('org-template.machine', () => {
tag: 'Upload', tag: 'Upload',
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' }, msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
}), }),
'loaded', 'Loaded',
); );
expect(done.draft.logoDocumentId).toBe('doc-1'); expect(done.draft.logoDocumentId).toBe('doc-1');
expect(done.dirty).toBe(true); expect(done.dirty).toBe(true);
@@ -138,7 +138,7 @@ describe('org-template.machine', () => {
tag: 'Upload', tag: 'Upload',
msg: { type: 'UploadRemoved', localId: 'a' }, msg: { type: 'UploadRemoved', localId: 'a' },
}), }),
'loaded', 'Loaded',
); );
expect(removed.draft.logoDocumentId).toBeUndefined(); expect(removed.draft.logoDocumentId).toBeUndefined();
expect(removed.dirty).toBe(true); expect(removed.dirty).toBe(true);
@@ -154,10 +154,50 @@ describe('org-template.machine', () => {
tag: 'DraftLoaded', tag: 'DraftLoaded',
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }), view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
}), }),
'loaded', 'Loaded',
); );
expect(switched.upload.categories).toHaveLength(1); expect(switched.upload.categories).toHaveLength(1);
expect(switched.upload.uploads).toHaveLength(0); expect(switched.upload.uploads).toHaveLength(0);
expect(switched.subOrgId).toBe('cibg-vakbekwaamheid'); expect(switched.subOrgId).toBe('cibg-vakbekwaamheid');
}); });
// --- the action lifecycle + publish impact-confirm gate, folded into one union (RD-13) ---
it('PublishRequested moves a loaded template to ConfirmingPublish', () => {
const s = expectTag(reduce(loaded(), { tag: 'PublishRequested' }), 'Loaded');
expect(s.action).toEqual({ tag: 'ConfirmingPublish' });
});
it('PublishCancelled returns to Idle', () => {
const confirming = reduce(loaded(), { tag: 'PublishRequested' });
const s = expectTag(reduce(confirming, { tag: 'PublishCancelled' }), 'Loaded');
expect(s.action).toEqual({ tag: 'Idle' });
});
it('ActionStarted from ConfirmingPublish goes to Busy, so confirming and busy cannot coexist', () => {
const confirming = expectTag(reduce(loaded(), { tag: 'PublishRequested' }), 'Loaded');
expect(confirming.action.tag).toBe('ConfirmingPublish');
const s = expectTag(reduce(confirming, { tag: 'ActionStarted' }), 'Loaded');
expect(s.action).toEqual({ tag: 'Busy' });
});
it('ActionFailed carries the error', () => {
const busy = reduce(loaded(), { tag: 'ActionStarted' });
const s = expectTag(reduce(busy, { tag: 'ActionFailed', error: 'mislukt' }), 'Loaded');
expect(s.action).toEqual({ tag: 'Failed', error: 'mislukt' });
});
it('DraftLoaded resets a stale action error to Idle', () => {
const failed = reduce(loaded(), { tag: 'ActionFailed', error: 'mislukt' });
const s = expectTag(reduce(failed, { tag: 'DraftLoaded', view: view() }), 'Loaded');
expect(s.action).toEqual({ tag: 'Idle' });
});
it('an action message is a no-op when the template is not loaded', () => {
expect(reduce({ tag: 'Loading' }, { tag: 'PublishRequested' })).toEqual({ tag: 'Loading' });
expect(reduce({ tag: 'Loading' }, { tag: 'ActionStarted' })).toEqual({ tag: 'Loading' });
expect(reduce({ tag: 'Loading' }, { tag: 'ActionFailed', error: 'x' })).toEqual({
tag: 'Loading',
});
});
}); });
@@ -3,12 +3,19 @@ import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from '
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine'; import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine';
/** /**
* The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) — * The admin org-template editor as one Elm-style machine (PRD Brief v2 §5) —
* the same idiom as the wizards. The DRAFT org template is form state (edited in * the same idiom as the wizards. The DRAFT org template is form state (edited in
* place on the canvas); publish/rollback are effects that come back as `DraftLoaded`. * place on the canvas); publish/rollback are effects that come back as `DraftLoaded`.
* `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is * `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is
* the composable upload sub-machine folded in, exactly like the wizards fold * the composable upload sub-machine folded in, exactly like the wizards fold
* `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`. * `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`.
*
* `action` (RD-13) owns the one-shot action lifecycle AND the publish impact-confirm
* gate as ONE four-variant union, replacing two independent store-level signals
* (`actionState` + `pendingPublish`). Before RD-13, `pendingPublish === true && busy
* === true` was representable and meaningless — the confirm dialog could show while a
* publish was already in flight. A single field with one tag at a time makes that
* combination unrepresentable.
*/ */
/** The org-identity text fields editable directly on the letter canvas. */ /** The org-identity text fields editable directly on the letter canvas. */
@@ -21,11 +28,22 @@ export type OrgTemplateTextField =
| 'signatureRole' | 'signatureRole'
| 'signatureClosing'; | 'signatureClosing';
/** The one-shot action lifecycle (publish/rollback/proefbrief), plus the publish
impact-confirm gate, owned by the reducer instead of two independent store-level
signals (RD-13). `ConfirmingPublish` is a variant of this SAME union, so
"confirming a publish while one is already in flight" is unrepresentable — no
state can ever carry both at once. */
export type OrgTemplateActionState =
| { tag: 'Idle' }
| { tag: 'ConfirmingPublish' }
| { tag: 'Busy' }
| { tag: 'Failed'; error: string };
export type OrgTemplateState = export type OrgTemplateState =
| { tag: 'loading' } | { tag: 'Loading' }
| { tag: 'failed'; reason: string } | { tag: 'Failed'; reason: string }
| { | {
tag: 'loaded'; tag: 'Loaded';
subOrgId: string; subOrgId: string;
draft: OrgTemplate; draft: OrgTemplate;
publishedVersion: number; publishedVersion: number;
@@ -34,9 +52,10 @@ export type OrgTemplateState =
dirty: boolean; dirty: boolean;
/** Logo upload sub-state (single file, `org-logo` category). */ /** Logo upload sub-state (single file, `org-logo` category). */
upload: UploadState; upload: UploadState;
action: OrgTemplateActionState;
}; };
export const initial: OrgTemplateState = { tag: 'loading' }; export const initial: OrgTemplateState = { tag: 'Loading' };
export type OrgTemplateMsg = export type OrgTemplateMsg =
| { tag: 'Loading' } | { tag: 'Loading' }
@@ -47,22 +66,27 @@ export type OrgTemplateMsg =
/** Carries the draft that was saved: clears `dirty` only if no edit landed during /** Carries the draft that was saved: clears `dirty` only if no edit landed during
the round-trip (reference-equal), so a concurrent edit keeps its pending save. */ the round-trip (reference-equal), so a concurrent edit keeps its pending save. */
| { tag: 'DraftSaved'; savedDraft: OrgTemplate } | { tag: 'DraftSaved'; savedDraft: OrgTemplate }
| { tag: 'Upload'; msg: UploadMsg }; | { tag: 'Upload'; msg: UploadMsg }
| { tag: 'PublishRequested' } // opens the publish impact-confirm gate
| { tag: 'PublishCancelled' } // closes it without publishing
| { tag: 'ActionStarted' } // a one-shot action (publish/rollback/proefbrief) began
| { tag: 'ActionFinished' } // it completed successfully
| { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */ /** 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; return s.tag === 'Loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
} }
export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState { export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState {
switch (m.tag) { switch (m.tag) {
case 'Loading': case 'Loading':
return { tag: 'loading' }; return { tag: 'Loading' };
case 'LoadFailed': case 'LoadFailed':
return { tag: 'failed', reason: m.reason }; return { tag: 'Failed', reason: m.reason };
case 'DraftLoaded': case 'DraftLoaded':
return { return {
tag: 'loaded', tag: 'Loaded',
subOrgId: m.view.draft.subOrgId, subOrgId: m.view.draft.subOrgId,
draft: m.view.draft, draft: m.view.draft,
publishedVersion: m.view.publishedVersion, publishedVersion: m.view.publishedVersion,
@@ -71,16 +95,19 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
dirty: false, dirty: false,
// Keep the loaded logo category across sub-org switches (it's the same // Keep the loaded logo category across sub-org switches (it's the same
// `org-logo` category, loaded once); drop only any in-flight/finished uploads. // `org-logo` category, loaded once); drop only any in-flight/finished uploads.
upload: s.tag === 'loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload, upload: s.tag === 'Loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
// A fresh load clears a stale action error rather than letting it outlive
// the reload (RD-13, same as brief's RD-12).
action: { tag: 'Idle' },
}; };
case 'FieldEdited': case 'FieldEdited':
return editDraft(s, (d) => ({ ...d, [m.field]: m.value })); return editDraft(s, (d) => ({ ...d, [m.field]: m.value }));
case 'MarginEdited': case 'MarginEdited':
return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } })); return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } }));
case 'DraftSaved': case 'DraftSaved':
return s.tag === 'loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s; return s.tag === 'Loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
case 'Upload': { case 'Upload': {
if (s.tag !== 'loaded') return s; if (s.tag !== 'Loaded') return s;
const upload = reduceUpload(s.upload, m.msg); const upload = reduceUpload(s.upload, m.msg);
// A completed/removed logo upload also updates the draft's logoDocumentId. // A completed/removed logo upload also updates the draft's logoDocumentId.
if (m.msg.type === 'UploadComplete') if (m.msg.type === 'UploadComplete')
@@ -96,6 +123,22 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
} }
return { ...s, upload }; return { ...s, upload };
} }
// The action lifecycle (RD-13): a no-op unless a template is loaded, since there
// is nothing to attach the action state to otherwise. `ConfirmingPublish` and
// `Busy` are variants of one field, so ActionStarted overwriting it to `Busy` is
// what makes the two mutually exclusive by construction — not by convention.
case 'PublishRequested':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'ConfirmingPublish' } } : s;
case 'PublishCancelled':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
case 'ActionStarted':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s;
case 'ActionFinished':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
case 'ActionFailed':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s;
default: default:
return assertNever(m); return assertNever(m);
} }
@@ -1,9 +1,9 @@
/** /**
* The organization template (Brief v2 PRD §3, WP-23/24): the SECOND template axis — * The organization template (Brief v2 PRD §3): the SECOND template axis —
* appearance/identity per sub-organization (letterhead, footer, signature, margins). * appearance/identity per sub-organization (letterhead, footer, signature, margins).
* Orthogonal to the case-type template (sections + placeholders); the two only meet * Orthogonal to the case-type template (sections + placeholders); the two only meet
* at render time, on the letter canvas. Server-owned: the FE renders it verbatim, * at render time, on the letter canvas. Server-owned: the FE renders it verbatim,
* never edits it here (the admin editor is WP-26). * never edits it here (the admin editor does).
*/ */
export interface Margins { export interface Margins {
@@ -30,7 +30,7 @@ export interface OrgTemplate {
readonly version: number; readonly version: number;
} }
// --- admin editor (WP-26) --- // --- admin editor ---
/** A published snapshot in the version history: who is faked, `publishedAt` is real. */ /** A published snapshot in the version history: who is faked, `publishedAt` is real. */
export interface OrgTemplateVersion { export interface OrgTemplateVersion {
@@ -0,0 +1,51 @@
import { Brief } from './brief';
/** A minimal read-only sample letter, so the admin sees the org identity in context
while editing (content itself is not the admin's to change). Production content —
the letter the org-template editor previews — not a test fixture, so it lives here
rather than in `brief.testing.ts` (dependency-cruiser's no-testing-in-production
rule forbids production code from reaching any `*.testing.ts`). */
export const SAMPLE_LETTER_BRIEF: Brief = {
briefId: 'VOORBEELD-0001',
beroep: 'arts',
templateId: 'sample',
drafterId: 'sample',
status: { tag: 'draft' },
placeholders: [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'datum', label: 'Datum', autoResolvable: true },
],
sections: [
{
sectionKey: 'body',
title: 'Voorbeeldinhoud',
required: true,
locked: true,
blocks: [
{
type: 'freeText',
blockId: 'sample-1',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Geachte ' },
{ type: 'placeholder', key: 'naam_zorgverlener' },
{ type: 'text', text: ',' },
],
},
{
nodes: [
{
type: 'text',
text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
},
],
},
],
},
},
],
},
],
};
@@ -38,9 +38,9 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric
* (ProblemDetails → error string, plus the Idempotency-Key mint), then parses the * (ProblemDetails → error string, plus the Idempotency-Key mint), then parses the
* returned brief. `load` (the only read) does its own try/catch instead of the * returned brief. `load` (the only read) does its own try/catch instead of the
* shared `runResult` fold, because it needs one extra bit `runResult` throws away: * shared `runResult` fold, because it needs one extra bit `runResult` throws away:
* whether the failure was an HTTP 404 (see `BriefLoadFailure` — RB-22, CQ-007's * whether the failure was an HTTP 404 (see `BriefLoadFailure` — CQ-007's
* expand half). Today's backend never 404s `GET /brief` (RB-23 adds that), so the * expand half). Today's backend never 404s `GET /brief`, so the
* `notFound` branch is unreached until RB-23 ships; this adapter is ready in advance. * `notFound` branch is unreached until it does; this adapter is ready in advance.
*/ */
export interface BriefView { export interface BriefView {
@@ -66,7 +66,7 @@ export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is
/** True when the thrown value carries an HTTP 404 status — matches both the /** True when the thrown value carries an HTTP 404 status — matches both the
generic `SwaggerException` (today's shape, since `GET /brief` declares no 404 generic `SwaggerException` (today's shape, since `GET /brief` declares no 404
response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once
RB-23 gives the endpoint a documented 404 response). */ the endpoint gets a documented 404 response). */
function isHttpNotFound(e: unknown): boolean { function isHttpNotFound(e: unknown): boolean {
return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404; return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404;
} }
@@ -14,19 +14,19 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
* to keep the NSwag-generated client JSON-only (same seam as uploads) — so this is a * to keep the NSwag-generated client JSON-only (same seam as uploads) — so this is a
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s * hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
* `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set * `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set
* here explicitly (WP-74 — without `X-Subject` this always previewed * here explicitly (without `X-Subject` this always previewed
* `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are * `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are
* dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under * dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under
* `isDevMode()`, mirroring how the interceptors themselves are only registered in dev * `isDevMode()`, mirroring how the interceptors themselves are only registered in dev
* (`app.config.ts`) — a production build sends neither header from this call (BIO-012). * (`app.config.ts`) — a production build sends neither header from this call (BIO-012).
* *
* `cache: 'no-store'` (WP-74): the endpoint has no `Cache-Control`, only a CORS-driven * `cache: 'no-store'`: the endpoint has no `Cache-Control`, only a CORS-driven
* `Vary: Origin`, and its content changes at the SAME URL as the letter moves * `Vary: Origin`, and its content changes at the SAME URL as the letter moves
* draft → sent. Explicitly bypassing the HTTP cache is the correct default for any * draft → sent. Explicitly bypassing the HTTP cache is the correct default for any
* mutable resource served under one unversioned URL — independent of WP-74's * mutable resource served under one unversioned URL — independent of the
* identity work, and not a complete fix by itself: see the KNOWN GAP note below. * identity work above, and not a complete fix by itself: see the KNOWN GAP note below.
* *
* KNOWN GAP (WP-74, not fixed here): under a non-`DocumentStore.DemoOwner` `X-Subject`, * KNOWN GAP (not fixed here): under a non-`DocumentStore.DemoOwner` `X-Subject`,
* this repo's own e2e run against a real backend observed this endpoint's SENT * this repo's own e2e run against a real backend observed this endpoint's SENT
* response still carrying the draft watermark, even though (a) the outgoing request * response still carrying the draft watermark, even though (a) the outgoing request
* carried the correct `X-Subject`, and (b) `curl` against the same backend at the * carried the correct `X-Subject`, and (b) `curl` against the same backend at the
@@ -34,7 +34,7 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
* did not change the outcome, so it is very unlikely a client-side caching artifact — * did not change the outcome, so it is very unlikely a client-side caching artifact —
* it looks like a genuine backend-side staleness/race in `BriefStore`'s SQLite-backed * it looks like a genuine backend-side staleness/race in `BriefStore`'s SQLite-backed
* read path, reproducible for MULTIPLE distinct owners and NOT reproducible for * read path, reproducible for MULTIPLE distinct owners and NOT reproducible for
* `DemoOwner`, which needs backend-side investigation (out of WP-74's file scope — * `DemoOwner`, which needs backend-side investigation (out of this file's scope —
* see `e2e/brief-v2.spec.ts`'s header comment, which keeps that spec on the shared * see `e2e/brief-v2.spec.ts`'s header comment, which keeps that spec on the shared
* `zorgverlener` identity until this is root-caused). * `zorgverlener` identity until this is root-caused).
*/ */
@@ -1,10 +1,10 @@
import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core'; import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component'; import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component'; import { MaskedValueComponent } from '@shared/ui/atoms/masked-value/masked-value.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { StepperComponent } from '@shared/ui/stepper/stepper.component'; import { StepperComponent } from '@shared/ui/molecules/stepper/stepper.component';
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief'; import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
import { besluitGuidance, inferSelection } from '@brief/domain/besluit'; import { besluitGuidance, inferSelection } from '@brief/domain/besluit';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
@@ -1,8 +1,11 @@
import { Component, computed, input, linkedSignal, output } from '@angular/core'; import { Component, computed, input, linkedSignal, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component'; import { CheckboxComponent } from '@shared/ui/atoms/checkbox/checkbox.component';
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component'; import {
import { HeadingComponent } from '@shared/ui/heading/heading.component'; RadioGroupComponent,
RadioOption,
} from '@shared/ui/atoms/radio-group/radio-group.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { Besluit, LibraryPassage } from '@brief/domain/brief'; import { Besluit, LibraryPassage } from '@brief/domain/brief';
import { redenenFor } from '@brief/domain/besluit'; import { redenenFor } from '@brief/domain/besluit';
+6 -6
View File
@@ -1,8 +1,8 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BriefStore } from '@brief/application/brief.store'; import { BriefStore } from '@brief/application/brief.store';
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component'; import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-scherm.component'; import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-scherm.component';
@@ -167,19 +167,19 @@ export class BriefPage {
void this.store.resetDemo(); void this.store.resetDemo();
} }
/** Typed narrowing for the `<app-async>` loaded slot — see WP-06: a structural /** Typed narrowing for the `<app-async>` loaded slot: a structural
directive's context can't inherit a generic from a sibling host input, so the directive's context can't inherit a generic from a sibling host input, so the
Success value is unwrapped here instead of through `let-`. */ Success value is unwrapped here instead of through `let-`. */
protected readonly loaded = computed(() => { protected readonly loaded = computed(() => {
const s = this.model(); const s = this.model();
return s.tag === 'loaded' ? s : undefined; return s.tag === 'Loaded' ? s : undefined;
}); });
protected reload() { protected reload() {
void this.store.load(); void this.store.load();
} }
/** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo (WP-27). Ignored while focus is in the /** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo. Ignored while focus is in the
rich-text editor or a form control, so the browser's own text undo keeps working rich-text editor or a form control, so the browser's own text undo keeps working
there — our shell-level undo is for structural edits (add/remove/reorder blocks). */ there — our shell-level undo is for structural edits (add/remove/reorder blocks). */
protected onKey(e: KeyboardEvent) { protected onKey(e: KeyboardEvent) {
@@ -1,5 +1,5 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
/** Molecule: lists all letter diagnostics grouped by severity. Errors block /** Molecule: lists all letter diagnostics grouped by severity. Errors block
@@ -3,8 +3,8 @@ import { RichTextBlock } from '@shared/kernel/rich-text';
import { import {
RichTextEditorComponent, RichTextEditorComponent,
PlaceholderOption, PlaceholderOption,
} from '@shared/ui/rich-text-editor/rich-text-editor.component'; } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { LetterBlock } from '@brief/domain/brief'; import { LetterBlock } from '@brief/domain/brief';
/** Molecule: one block in a section — its editor plus provenance + block controls. /** Molecule: one block in a section — its editor plus provenance + block controls.
@@ -1,3 +1,6 @@
/* eslint-disable max-lines */ // 77 lines of CSS + one letter's markup; splitting it into
// letterhead/body/signature/footer makes "what does the letter look like" a five-file
// question for no behavioural seam. Deliberate, not deferred.
import { import {
Component, Component,
DestroyRef, DestroyRef,
@@ -11,9 +14,7 @@ import {
signal, signal,
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
import { formatDatumNl } from '@shared/kernel/datum'; import { formatDatumNl } from '@shared/kernel/datum';
import { Paragraph } from '@shared/kernel/rich-text'; import { Paragraph } from '@shared/kernel/rich-text';
import { Brief, LetterBlock } from '@brief/domain/brief'; import { Brief, LetterBlock } from '@brief/domain/brief';
@@ -21,6 +22,7 @@ import { OrgTemplate } from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine'; import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
import { BlockDiffKind } from '@brief/domain/brief-diff'; import { BlockDiffKind } from '@brief/domain/brief-diff';
import { LetterLineComponent } from './letter-line.component';
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */ /** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
type PreviewSegment = { type PreviewSegment = {
@@ -39,12 +41,6 @@ function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
return out; return out;
} }
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
const SAMPLE_VALUES: Record<string, string> = {
naam_zorgverlener: 'J. Jansen',
big_nummer: '12345678901',
};
/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */ /** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */
const A4_HEIGHT_PX = (297 * 96) / 25.4; const A4_HEIGHT_PX = (297 * 96) / 25.4;
@@ -52,12 +48,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
footer around the case-type template's sections. `editableRegions` picks who edits footer around the case-type template's sections. `editableRegions` picks who edits
what: `'content'` hosts the editable letter-sections in place (drafter), `'none'` what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`
renders everything read-only (approver/locked, absorbs the old letter-preview), renders everything read-only (approver/locked, absorbs the old letter-preview),
`'template'` reserves the org-identity regions for the admin editor (WP-26). `'template'` reserves the org-identity regions for the admin editor.
Letter typography/geometry come from the shared `public/letter.css` contract — Letter typography/geometry come from the shared `public/letter.css` contract —
the same file the backend preview renderer inlines (WP-25). */ the same file the backend preview renderer inlines. Each rendered line is its own
`app-letter-line` (RD-26), replacing the outlet-template indirection that stood
in for it. */
@Component({ @Component({
selector: 'app-letter-canvas', selector: 'app-letter-canvas',
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent], imports: [ButtonComponent, LetterLineComponent],
styles: [ styles: [
` `
:host { :host {
@@ -81,7 +79,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
color: var(--rhc-color-foreground-subtle); color: var(--rhc-color-foreground-subtle);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* Rejection-diff badge (WP-27): a small pill above a changed/added block. */ /* Rejection-diff badge: a small pill above a changed/added block. */
.diff-block.diff-changed { .diff-block.diff-changed {
border-inline-start: 3px solid var(--rhc-color-oranje-500); border-inline-start: 3px solid var(--rhc-color-oranje-500);
padding-inline-start: var(--rhc-space-max-sm); padding-inline-start: var(--rhc-space-max-sm);
@@ -97,7 +95,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
background: var(--rhc-color-oranje-500); background: var(--rhc-color-oranje-500);
} }
.diff-badge.added { .diff-badge.added {
/* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (WP-29 axe). */ /* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (axe). */
color: var(--rhc-color-wit); color: var(--rhc-color-wit);
background: var(--rhc-color-groen-700); background: var(--rhc-color-groen-700);
} }
@@ -136,37 +134,19 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
`, `,
], ],
template: ` template: `
<ng-template #line let-nodes>
@for (node of nodes; track $index) {
@switch (node.type) {
@case ('text') {
<span>{{ node.text }}</span>
}
@case ('lineBreak') {
<br />
}
@case ('placeholder') {
@if (showSample() && autoFor(node.key)) {
<span>{{ sampleFor(node.key) }}</span>
} @else {
<app-placeholder-chip
[label]="labelFor(node.key)"
[autoResolvable]="autoFor(node.key)"
[state]="stateFor(node.key)"
/>
}
}
}
}
</ng-template>
@if (editableRegions() !== 'template') { @if (editableRegions() !== 'template') {
<div class="toolbar"> <div class="toolbar">
<div class="zoom" role="group" [attr.aria-label]="zoomGroupLabel()"> <div
class="zoom"
role="group"
aria-label="Zoomniveau"
i18n-aria-label="@@brief.canvas.zoom"
>
<app-button <app-button
variant="subtle" variant="subtle"
[disabled]="zoomLevel() <= 0.5" [disabled]="zoomLevel() <= 0.5"
[attr.aria-label]="zoomOutLabel()" aria-label="Uitzoomen"
i18n-aria-label="@@brief.canvas.zoomOut"
(click)="zoomBy(-0.1)" (click)="zoomBy(-0.1)"
>−</app-button >−</app-button
> >
@@ -174,13 +154,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<app-button <app-button
variant="subtle" variant="subtle"
[disabled]="zoomLevel() >= 1.5" [disabled]="zoomLevel() >= 1.5"
[attr.aria-label]="zoomInLabel()" aria-label="Inzoomen"
i18n-aria-label="@@brief.canvas.zoomIn"
(click)="zoomBy(0.1)" (click)="zoomBy(0.1)"
>+</app-button >+</app-button
> >
<app-button variant="subtle" (click)="zoomLevel.set(1)">{{ <app-button variant="subtle" (click)="zoomLevel.set(1)" i18n="@@brief.canvas.zoomReset"
zoomResetLabel() >100%</app-button
}}</app-button> >
</div> </div>
@if (editableRegions() === 'none') { @if (editableRegions() === 'none') {
<app-button <app-button
@@ -188,7 +169,13 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
(click)="showSample.set(!showSample())" (click)="showSample.set(!showSample())"
[attr.aria-pressed]="showSample()" [attr.aria-pressed]="showSample()"
> >
{{ showSample() ? hideSampleLabel() : showSampleLabel() }} @if (showSample()) {
<ng-container i18n="@@brief.preview.hideSample">Testwaarden verbergen</ng-container>
} @else {
<ng-container i18n="@@brief.preview.showSample"
>Voorbeeld met testwaarden</ng-container
>
}
</app-button> </app-button>
} }
</div> </div>
@@ -200,20 +187,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
(robijn footer background) — the letter surface must stay letter.css-only. --> (robijn footer background) — the letter surface must stay letter.css-only. -->
<div class="letter__letterhead"> <div class="letter__letterhead">
@if (logoUrl()) { @if (logoUrl()) {
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" /> <img
class="org-logo"
[src]="logoUrl()"
alt="Logo van de organisatie"
i18n-alt="@@brief.canvas.logoAlt"
/>
} }
@if (editing()) { @if (editing()) {
<input <input
class="tmpl-input org-wordmark" class="tmpl-input org-wordmark"
[value]="orgTemplate().orgName" [value]="orgTemplate().orgName"
[attr.aria-label]="orgNameLabel()" aria-label="Organisatienaam"
i18n-aria-label="@@brief.canvas.orgName"
(input)="emitEdit('orgName', $event)" (input)="emitEdit('orgName', $event)"
/> />
<textarea <textarea
class="tmpl-textarea return-address" class="tmpl-textarea return-address"
rows="2" rows="2"
[value]="orgTemplate().returnAddress" [value]="orgTemplate().returnAddress"
[attr.aria-label]="returnAddressLabel()" aria-label="Retouradres"
i18n-aria-label="@@brief.canvas.returnAddress"
(input)="emitEdit('returnAddress', $event)" (input)="emitEdit('returnAddress', $event)"
></textarea> ></textarea>
} @else { } @else {
@@ -223,11 +217,11 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<address class="address-window">{{ recipientText() }}</address> <address class="address-window">{{ recipientText() }}</address>
<dl class="reference"> <dl class="reference">
<div> <div>
<dt>{{ referenceLabel() }}</dt> <dt i18n="@@brief.canvas.reference">Ons kenmerk</dt>
<dd>{{ brief().briefId }}</dd> <dd>{{ brief().briefId }}</dd>
</div> </div>
<div> <div>
<dt>{{ dateLabel() }}</dt> <dt i18n="@@brief.canvas.date">Datum</dt>
<dd>{{ letterDate }}</dd> <dd>{{ letterDate }}</dd>
</div> </div>
</dl> </dl>
@@ -241,18 +235,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined; @let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
<div class="diff-block" [class.diff-changed]="!!diffKind"> <div class="diff-block" [class.diff-changed]="!!diffKind">
@if (diffKind) { @if (diffKind) {
<span class="diff-badge" [class.added]="diffKind === 'added'">{{ <span class="diff-badge" [class.added]="diffKind === 'added'">
diffLabel(diffKind) @if (diffKind === 'added') {
}}</span> <ng-container i18n="@@brief.diff.added">nieuw</ng-container>
} @else {
<ng-container i18n="@@brief.diff.changed"
>gewijzigd sinds afwijzing</ng-container
>
}
</span>
} }
@for (seg of segmentsOf(block); track $index) { @for (seg of segmentsOf(block); track $index) {
@if (seg.list === 'bullet') { @if (seg.list === 'bullet') {
<ul> <ul>
@for (para of seg.items; track $index) { @for (para of seg.items; track $index) {
<li> <li>
<ng-container <app-letter-line
[ngTemplateOutlet]="line" [nodes]="para.nodes"
[ngTemplateOutletContext]="{ $implicit: para.nodes }" [showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/> />
</li> </li>
} }
@@ -261,18 +264,24 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<ol> <ol>
@for (para of seg.items; track $index) { @for (para of seg.items; track $index) {
<li> <li>
<ng-container <app-letter-line
[ngTemplateOutlet]="line" [nodes]="para.nodes"
[ngTemplateOutletContext]="{ $implicit: para.nodes }" [showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/> />
</li> </li>
} }
</ol> </ol>
} @else { } @else {
<p> <p>
<ng-container <app-letter-line
[ngTemplateOutlet]="line" [nodes]="seg.items[0].nodes"
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }" [showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/> />
</p> </p>
} }
@@ -288,19 +297,22 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<input <input
class="tmpl-input" class="tmpl-input"
[value]="orgTemplate().signatureClosing" [value]="orgTemplate().signatureClosing"
[attr.aria-label]="signatureClosingLabel()" aria-label="Afsluiting"
i18n-aria-label="@@brief.canvas.signatureClosing"
(input)="emitEdit('signatureClosing', $event)" (input)="emitEdit('signatureClosing', $event)"
/> />
<input <input
class="tmpl-input signature-name" class="tmpl-input signature-name"
[value]="orgTemplate().signatureName" [value]="orgTemplate().signatureName"
[attr.aria-label]="signatureNameLabel()" aria-label="Naam ondertekenaar"
i18n-aria-label="@@brief.canvas.signatureName"
(input)="emitEdit('signatureName', $event)" (input)="emitEdit('signatureName', $event)"
/> />
<input <input
class="tmpl-input" class="tmpl-input"
[value]="orgTemplate().signatureRole" [value]="orgTemplate().signatureRole"
[attr.aria-label]="signatureRoleLabel()" aria-label="Functie ondertekenaar"
i18n-aria-label="@@brief.canvas.signatureRole"
(input)="emitEdit('signatureRole', $event)" (input)="emitEdit('signatureRole', $event)"
/> />
} @else { } @else {
@@ -316,13 +328,15 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
class="tmpl-textarea footer-contact" class="tmpl-textarea footer-contact"
rows="2" rows="2"
[value]="orgTemplate().footerContact" [value]="orgTemplate().footerContact"
[attr.aria-label]="footerContactLabel()" aria-label="Contactgegevens (voettekst)"
i18n-aria-label="@@brief.canvas.footerContact"
(input)="emitEdit('footerContact', $event)" (input)="emitEdit('footerContact', $event)"
></textarea> ></textarea>
<input <input
class="tmpl-input footer-legal" class="tmpl-input footer-legal"
[value]="orgTemplate().footerLegal" [value]="orgTemplate().footerLegal"
[attr.aria-label]="footerLegalLabel()" aria-label="Juridische voettekst"
i18n-aria-label="@@brief.canvas.footerLegal"
(input)="emitEdit('footerLegal', $event)" (input)="emitEdit('footerLegal', $event)"
/> />
} @else { } @else {
@@ -333,7 +347,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
@for (top of pageBreaks(); track $index) { @for (top of pageBreaks(); track $index) {
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true"> <div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
<span>{{ pageBreakCaption() }}</span> <span i18n="@@brief.canvas.pageBreak">±pagina-einde — afdrukvoorbeeld is leidend</span>
</div> </div>
} }
</div> </div>
@@ -344,12 +358,12 @@ export class LetterCanvasComponent {
brief = input.required<Brief>(); brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>(); orgTemplate = input.required<OrgTemplate>();
/** Who edits what on the surface: read-only ('none', the drafter preview + approver /** Who edits what on the surface: read-only ('none', the drafter preview + approver
view) or admin editor ('template', WP-26). Authoring moved to letter-editor. */ view) or admin editor ('template'). Authoring moved to letter-editor. */
editableRegions = input<'template' | 'none'>('none'); editableRegions = input<'template' | 'none'>('none');
diagnostics = input<readonly Diagnostic[]>([]); diagnostics = input<readonly Diagnostic[]>([]);
/** Initial zoom; the in-canvas controls take over from here (WP-27). */ /** Initial zoom; the in-canvas controls take over from here. */
zoom = input(1); zoom = input(1);
/** Blocks changed/added/removed since the letter was rejected (WP-27); badged when /** Blocks changed/added/removed since the letter was rejected; badged when
`showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer `showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer
exist in the letter — the composer surfaces them as a count. */ exist in the letter — the composer surfaces them as a count. */
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map()); blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
@@ -359,30 +373,13 @@ export class LetterCanvasComponent {
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */ /** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
templateEdit = output<{ field: OrgTemplateTextField; value: string }>(); templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`); /** The one label kept as an `input()` rather than inlined `i18n` (RD-26, decision 2):
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`); its message embeds a literal `\n`. As template text that `\n` becomes a source
pageBreakCaption = input( line break, a different string to Angular's extractor — so inlining it would
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`, change the extracted source text, unlike the other 19 labels this ticket inlines. */
);
recipientText = input( recipientText = input(
$localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`, $localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`,
); );
referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);
dateLabel = input($localize`:@@brief.canvas.date:Datum`);
logoAlt = input($localize`:@@brief.canvas.logoAlt:Logo van de organisatie`);
orgNameLabel = input($localize`:@@brief.canvas.orgName:Organisatienaam`);
returnAddressLabel = input($localize`:@@brief.canvas.returnAddress:Retouradres`);
signatureClosingLabel = input($localize`:@@brief.canvas.signatureClosing:Afsluiting`);
signatureNameLabel = input($localize`:@@brief.canvas.signatureName:Naam ondertekenaar`);
signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);
footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);
footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);
zoomGroupLabel = input($localize`:@@brief.canvas.zoom:Zoomniveau`);
zoomInLabel = input($localize`:@@brief.canvas.zoomIn:Inzoomen`);
zoomOutLabel = input($localize`:@@brief.canvas.zoomOut:Uitzoomen`);
zoomResetLabel = input($localize`:@@brief.canvas.zoomReset:100%`);
addedLabel = input($localize`:@@brief.diff.added:nieuw`);
changedLabel = input($localize`:@@brief.diff.changed:gewijzigd sinds afwijzing`);
protected showSample = signal(false); protected showSample = signal(false);
protected letterDate = formatDatumNl(new Date()); protected letterDate = formatDatumNl(new Date());
@@ -394,9 +391,6 @@ export class LetterCanvasComponent {
// clamp 0.5–1.5; round to avoid float drift accumulating on repeated clicks. // clamp 0.5–1.5; round to avoid float drift accumulating on repeated clicks.
this.zoomLevel.update((z) => Math.round(Math.min(1.5, Math.max(0.5, z + delta)) * 10) / 10); this.zoomLevel.update((z) => Math.round(Math.min(1.5, Math.max(0.5, z + delta)) * 10) / 10);
} }
protected diffLabel = (kind: BlockDiffKind) =>
kind === 'added' ? this.addedLabel() : this.changedLabel();
/** Admin edit-in-place: the org-identity regions render as controls. */ /** Admin edit-in-place: the org-identity regions render as controls. */
protected editing = computed(() => this.editableRegions() === 'template'); protected editing = computed(() => this.editableRegions() === 'template');
@@ -417,25 +411,9 @@ export class LetterCanvasComponent {
}; };
}); });
// --- read-only rendering helpers (migrated from the superseded letter-preview) --- // --- read-only rendering helper (migrated from the superseded letter-preview) ---
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
private worst = computed(() => {
const m = new Map<string, 'error' | 'warning'>();
for (const d of this.diagnostics()) {
if (!d.placeholderKey) continue;
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
}
return m;
});
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs); protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
protected sampleFor = (key: string) =>
SAMPLE_VALUES[key] ?? (key === 'datum' ? this.letterDate : this.labelFor(key));
// --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) --- // --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) ---
@@ -444,7 +422,7 @@ export class LetterCanvasComponent {
constructor() { constructor() {
// ponytail: whole-surface height / A4-interval — ignores that a break never truly // ponytail: whole-surface height / A4-interval — ignores that a break never truly
// falls mid-line; the caption says "±" and WP-25's server preview is authoritative. // falls mid-line; the caption says "±" and the server preview is authoritative.
const observer = new ResizeObserver(([entry]) => { const observer = new ResizeObserver(([entry]) => {
// ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark. // ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.
const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX); const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);
@@ -127,12 +127,12 @@ export const ReadOnlyZonderBevindingen: Story = {
args: { editableRegions: 'none', diagnostics: [] }, args: { editableRegions: 'none', diagnostics: [] },
}; };
/** Admin editor focus (consumer arrives in WP-26): body read-only, no "not yours" tint. */ /** Admin editor focus: body read-only, no "not yours" tint. */
export const TemplateMode: Story = { args: { editableRegions: 'template' } }; export const TemplateMode: Story = { args: { editableRegions: 'template' } };
export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } }; export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } };
/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged (WP-27). */ /** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged. */
export const WithDiff: Story = { export const WithDiff: Story = {
args: { args: {
editableRegions: 'none', editableRegions: 'none',
@@ -150,14 +150,14 @@ export const PageBreak: Story = {
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] }, args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
}; };
// Inline SVG so the story needs no backend/upload round-trip (WP-26 logo upload). // Inline SVG so the story needs no backend/upload round-trip (the logo upload).
const sampleLogo = const sampleLogo =
'data:image/svg+xml;utf8,' + 'data:image/svg+xml;utf8,' +
encodeURIComponent( encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>', '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
); );
/** Published org logo (WP-26 AC2): the letterhead shows it above the org name. */ /** Published org logo: the letterhead shows it above the org name. */
export const MetLogo: Story = { export const MetLogo: Story = {
args: { editableRegions: 'none', diagnostics: [], logoUrl: sampleLogo }, args: { editableRegions: 'none', diagnostics: [], logoUrl: sampleLogo },
}; };
@@ -0,0 +1,104 @@
import { Component, computed, input } from '@angular/core';
import { PlaceholderChipComponent } from '@shared/ui/atoms/placeholder-chip/placeholder-chip.component';
import { RichTextNode } from '@shared/kernel/rich-text';
import { Diagnostic, PlaceholderDef } from '@brief/domain/placeholders';
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
const SAMPLE_VALUES: Record<string, string> = {
naam_zorgverlener: 'J. Jansen',
big_nummer: '12345678901',
};
/** `brief().placeholders` keyed by `key` for O(1) lookup from a node. */
export function placeholderDefs(
placeholders: readonly PlaceholderDef[],
): Map<string, PlaceholderDef> {
return new Map(placeholders.map((p) => [p.key, p]));
}
/** The worst (error over warning) diagnostic severity per placeholder key. */
export function worstSeverities(
diagnostics: readonly Diagnostic[],
): Map<string, 'error' | 'warning'> {
const m = new Map<string, 'error' | 'warning'>();
for (const d of diagnostics) {
if (!d.placeholderKey) continue;
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
}
return m;
}
export function resolveLabel(defs: ReadonlyMap<string, PlaceholderDef>, key: string): string {
return defs.get(key)?.label ?? key;
}
export function resolveAuto(defs: ReadonlyMap<string, PlaceholderDef>, key: string): boolean {
return defs.get(key)?.autoResolvable ?? false;
}
export function resolveState(
worst: ReadonlyMap<string, 'error' | 'warning'>,
key: string,
): 'ok' | 'warning' | 'error' {
return worst.get(key) ?? 'ok';
}
/** The "Voorbeeld" toggle's stand-in for an auto-resolvable placeholder: a canned
sample, the caller's sample date for `datum`, or the field's own label. */
export function resolveSample(
defs: ReadonlyMap<string, PlaceholderDef>,
sampleDate: string,
key: string,
): string {
return SAMPLE_VALUES[key] ?? (key === 'datum' ? sampleDate : resolveLabel(defs, key));
}
/** Organism: one rendered line of letter content — text runs, line breaks and
placeholder chips (an auto-resolvable one swaps to a sample value when
`showSample` is on). Extracted from `letter-canvas` (RD-26): the `#line`
template plus the label/auto/state/sample helpers it needs, so the canvas's
three `ngTemplateOutlet` incantations become one tag each. */
@Component({
selector: 'app-letter-line',
imports: [PlaceholderChipComponent],
template: `
@for (node of nodes(); track $index) {
@switch (node.type) {
@case ('text') {
<span>{{ node.text }}</span>
}
@case ('lineBreak') {
<br />
}
@case ('placeholder') {
@if (showSample() && autoFor(node.key)) {
<span>{{ sampleFor(node.key) }}</span>
} @else {
<app-placeholder-chip
[label]="labelFor(node.key)"
[autoResolvable]="autoFor(node.key)"
[state]="stateFor(node.key)"
/>
}
}
}
}
`,
})
export class LetterLineComponent {
nodes = input.required<readonly RichTextNode[]>();
showSample = input(false);
placeholders = input<readonly PlaceholderDef[]>([]);
diagnostics = input<readonly Diagnostic[]>([]);
/** The canvas's `formatDatumNl(new Date())`, passed down so every line agrees. */
sampleDate = input('');
private defs = computed(() => placeholderDefs(this.placeholders()));
private worst = computed(() => worstSeverities(this.diagnostics()));
protected labelFor = (key: string) => resolveLabel(this.defs(), key);
protected autoFor = (key: string) => resolveAuto(this.defs(), key);
protected stateFor = (key: string) => resolveState(this.worst(), key);
protected sampleFor = (key: string) => resolveSample(this.defs(), this.sampleDate(), key);
}
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import { PlaceholderDef, Diagnostic } from '@brief/domain/placeholders';
import {
placeholderDefs,
resolveAuto,
resolveLabel,
resolveSample,
resolveState,
worstSeverities,
} from './letter-line.component';
const DEFS: readonly PlaceholderDef[] = [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
];
const LOCATION = { blockId: 'b1', paragraphIndex: 0, nodeIndex: 0 };
function diagnostic(placeholderKey: string, severity: 'error' | 'warning'): Diagnostic {
return { severity, code: 'unresolved-at-send', message: 'x', placeholderKey, location: LOCATION };
}
describe('placeholderDefs', () => {
it('keys the placeholder list by its key', () => {
const defs = placeholderDefs(DEFS);
expect(defs.get('naam_zorgverlener')?.label).toBe('Naam zorgverlener');
expect(defs.get('unknown')).toBeUndefined();
});
});
describe('worstSeverities', () => {
it('ignores a diagnostic with no placeholder key', () => {
const worst = worstSeverities([
{ severity: 'error', code: 'malformed', message: 'x', location: LOCATION },
]);
expect(worst.size).toBe(0);
});
it('keeps error over a warning already recorded for the same key', () => {
const worst = worstSeverities([
diagnostic('naam_zorgverlener', 'warning'),
diagnostic('naam_zorgverlener', 'error'),
]);
expect(worst.get('naam_zorgverlener')).toBe('error');
});
it('does not let a later warning downgrade an error', () => {
const worst = worstSeverities([
diagnostic('naam_zorgverlener', 'error'),
diagnostic('naam_zorgverlener', 'warning'),
]);
expect(worst.get('naam_zorgverlener')).toBe('error');
});
});
describe('resolveLabel', () => {
it('returns the field label for a known key', () => {
expect(resolveLabel(placeholderDefs(DEFS), 'reden_besluit')).toBe('Reden besluit');
});
it('falls back to the bare key when the field is unknown', () => {
expect(resolveLabel(placeholderDefs(DEFS), 'unknown')).toBe('unknown');
});
});
describe('resolveAuto', () => {
it('reads autoResolvable off the field', () => {
const defs = placeholderDefs(DEFS);
expect(resolveAuto(defs, 'naam_zorgverlener')).toBe(true);
expect(resolveAuto(defs, 'reden_besluit')).toBe(false);
});
it('defaults to false for an unknown key', () => {
expect(resolveAuto(placeholderDefs(DEFS), 'unknown')).toBe(false);
});
});
describe('resolveState', () => {
it('defaults to ok when the key has no diagnostic', () => {
expect(resolveState(worstSeverities([]), 'naam_zorgverlener')).toBe('ok');
});
it('surfaces the worst recorded severity', () => {
const worst = worstSeverities([diagnostic('naam_zorgverlener', 'warning')]);
expect(resolveState(worst, 'naam_zorgverlener')).toBe('warning');
});
});
describe('resolveSample', () => {
it('prefers the canned sample value over the label', () => {
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'naam_zorgverlener')).toBe(
'J. Jansen',
);
});
it('resolves datum to the caller-supplied sample date', () => {
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'datum')).toBe(
'4 september 2026',
);
});
it('falls back to the field label for anything else', () => {
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'reden_besluit')).toBe(
'Reden besluit',
);
});
});
@@ -1,8 +1,8 @@
import { Component, computed, input, output, signal } from '@angular/core'; import { Component, computed, input, output, signal } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component'; import { StatusBadgeComponent } from '@shared/ui/atoms/status-badge/status-badge.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { Brief } from '@brief/domain/brief'; import { Brief } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
@@ -137,7 +137,7 @@ export class LetterComposerComponent {
canReject = input(false); canReject = input(false);
canSend = input(false); canSend = input(false);
busy = input(false); busy = input(false);
/** Rejection diff (WP-27): the changed/added/removed blocks and their count. The /** Rejection diff: the changed/added/removed blocks and their count. The
"Toon wijzigingen" toggle only appears when there's something to show. */ "Toon wijzigingen" toggle only appears when there's something to show. */
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map()); blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
removedCount = input(0); removedCount = input(0);
@@ -181,7 +181,7 @@ export const Sent: Story = {
}), }),
}; };
/** Approver's "Toon wijzigingen" (WP-27): a resubmitted letter with blocks changed, /** Approver's "Toon wijzigingen": a resubmitted letter with blocks changed,
added and removed since the last rejection. */ added and removed since the last rejection. */
export const RejectionDiff: Story = { export const RejectionDiff: Story = {
render: () => render: () =>
@@ -1,5 +1,5 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component'; import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { Brief } from '@brief/domain/brief'; import { Brief } from '@brief/domain/brief';
import { BriefMsg } from '@brief/domain/brief.machine'; import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component'; import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
@@ -1,8 +1,8 @@
import { Component, input, output } from '@angular/core'; import { Component, input, output } from '@angular/core';
import { RichTextBlock } from '@shared/kernel/rich-text'; import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component'; import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { LetterSection } from '@brief/domain/brief'; import { LetterSection } from '@brief/domain/brief';
import { BriefMsg } from '@brief/domain/brief.machine'; import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component'; import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component';
@@ -0,0 +1,77 @@
import { Component, computed, input, output } from '@angular/core';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { FileInputComponent } from '@shared/ui/atoms/upload/file-input/file-input.component';
import { SingleUploadComponent } from '@shared/ui/molecules/upload/single-upload/single-upload.component';
import { UploadState } from '@shared/domain/upload.machine';
const LOGO_CATEGORY = 'org-logo';
/**
* Organism: the org-template editor's logo-upload block, split out of
* `org-template-editor.component.ts` (RD-25) — one of its two self-contained
* mutation clusters. Presentational: every mutation is an output the parent
* re-emits unchanged.
*/
@Component({
selector: 'app-logo-upload',
imports: [HeadingComponent, AlertComponent, FileInputComponent, SingleUploadComponent],
styles: [
`
:host {
display: block;
}
.section {
margin-block-start: var(--rhc-space-max-xl);
}
`,
],
template: `
<section class="section">
<app-heading [level]="3" i18n="@@orgTemplate.logo">Logo</app-heading>
@if (logoCategory()) {
<app-file-input
inputId="org-logo-input"
[accept]="logoCategory()!.acceptedTypes"
[maxSizeMb]="logoCategory()!.maxSizeMb"
i18n-label="@@orgTemplate.logo"
label="Logo"
(filesSelected)="logoSelected.emit($event)"
/>
}
@if (logoRejection()) {
<app-alert type="error">{{ logoRejection() }}</app-alert>
}
@if (logoUploads().length) {
<ul class="file-list">
@for (u of logoUploads(); track u.localId) {
<li
app-single-upload
[upload]="u"
[previewUrlFor]="previewUrlFor()"
(remove)="logoRemoved.emit(u.localId)"
(retry)="logoRetry.emit(u.localId)"
></li>
}
</ul>
}
</section>
`,
})
export class LogoUploadComponent {
logoUrl = input<string | null>(null);
uploadState = input.required<UploadState>();
previewUrlFor = input<(documentId: string) => string | undefined>();
logoSelected = output<File[]>();
logoRemoved = output<string>();
logoRetry = output<string>();
protected logoCategory = computed(() =>
this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),
);
protected logoUploads = computed(() =>
this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),
);
protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);
}
@@ -1,12 +1,9 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { DatePipe } from '@angular/common'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';
import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';
import { UploadState } from '@shared/domain/upload.machine'; import { UploadState } from '@shared/domain/upload.machine';
import { Brief } from '@brief/domain/brief'; import { Brief } from '@brief/domain/brief';
import { SAMPLE_LETTER_BRIEF } from '@brief/domain/sample-letter';
import { import {
MARGIN_MAX_MM, MARGIN_MAX_MM,
MARGIN_MIN_MM, MARGIN_MIN_MM,
@@ -17,74 +14,29 @@ import {
} from '@brief/domain/org-template'; } from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine'; import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component'; import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
import { LogoUploadComponent } from './logo-upload.component';
import { VersionHistoryComponent } from './version-history.component';
const LOGO_CATEGORY = 'org-logo';
const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm']; const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm'];
/** A minimal read-only sample letter, so the admin sees the org identity in context
while editing (content itself is not the admin's to change). */
export const SAMPLE_LETTER_BRIEF: Brief = {
briefId: 'VOORBEELD-0001',
beroep: 'arts',
templateId: 'sample',
drafterId: 'sample',
status: { tag: 'draft' },
placeholders: [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'datum', label: 'Datum', autoResolvable: true },
],
sections: [
{
sectionKey: 'body',
title: 'Voorbeeldinhoud',
required: true,
locked: true,
blocks: [
{
type: 'freeText',
blockId: 'sample-1',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Geachte ' },
{ type: 'placeholder', key: 'naam_zorgverlener' },
{ type: 'text', text: ',' },
],
},
{
nodes: [
{
type: 'text',
text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
},
],
},
],
},
},
],
},
],
};
/** /**
* Organism (WP-26): the admin org-template editor. The mirror of the drafter's * Organism: the admin org-template editor. The mirror of the drafter's
* composer — the letter canvas runs in `editableRegions='template'` so the * composer — the letter canvas runs in `editableRegions='template'` so the
* letterhead/signature/footer are edited in place, while the content is a read-only * letterhead/signature/footer are edited in place, while the content is a read-only
* sample. Margins, logo upload, version history and the publish bar sit around it. * sample. Margins and the publish bar sit around it; the logo uploader and version
* Presentational: every mutation is an output the store turns into a command. * history are their own children (`app-logo-upload`, `app-version-history`, RD-25) —
* each a self-contained mutation cluster. Presentational: every mutation is an
* output the store turns into a command, whether sourced here or re-emitted from
* a child.
*/ */
@Component({ @Component({
selector: 'app-org-template-editor', selector: 'app-org-template-editor',
imports: [ imports: [
DatePipe,
HeadingComponent,
ButtonComponent, ButtonComponent,
AlertComponent, AlertComponent,
FileInputComponent,
SingleUploadComponent,
LetterCanvasComponent, LetterCanvasComponent,
LogoUploadComponent,
VersionHistoryComponent,
], ],
styles: [ styles: [
` `
@@ -122,22 +74,6 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
.margins input { .margins input {
width: 6rem; width: 6rem;
} }
.history-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--rhc-space-max-sm);
}
.history-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rhc-space-max-md);
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
padding-block-end: var(--rhc-space-max-sm);
}
.bar { .bar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -153,7 +89,7 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
template: ` template: `
<div class="toolbar"> <div class="toolbar">
<label class="field"> <label class="field">
<span>{{ subOrgLabel() }}</span> <span i18n="@@orgTemplate.subOrg">Organisatieonderdeel</span>
<select class="form-select" (change)="onSelectSubOrg($event)"> <select class="form-select" (change)="onSelectSubOrg($event)">
@for (o of subOrgs(); track o.subOrgId) { @for (o of subOrgs(); track o.subOrgId) {
<option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()"> <option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()">
@@ -190,80 +126,62 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
} }
</fieldset> </fieldset>
<section class="section"> <app-logo-upload
<app-heading [level]="3">{{ logoHeading() }}</app-heading> [logoUrl]="logoUrl()"
@if (logoCategory()) { [uploadState]="uploadState()"
<app-file-input [previewUrlFor]="previewUrlFor()"
inputId="org-logo-input" (logoSelected)="logoSelected.emit($event)"
[accept]="logoCategory()!.acceptedTypes" (logoRemoved)="logoRemoved.emit($event)"
[maxSizeMb]="logoCategory()!.maxSizeMb" (logoRetry)="logoRetry.emit($event)"
[label]="logoHeading()" />
(filesSelected)="logoSelected.emit($event)"
/>
}
@if (logoRejection()) {
<app-alert type="error">{{ logoRejection() }}</app-alert>
}
@if (logoUploads().length) {
<ul class="file-list">
@for (u of logoUploads(); track u.localId) {
<li
app-single-upload
[upload]="u"
[previewUrlFor]="previewUrlFor()"
(remove)="logoRemoved.emit(u.localId)"
(retry)="logoRetry.emit(u.localId)"
></li>
}
</ul>
}
</section>
<section class="section"> <app-version-history
<app-heading [level]="3">{{ historyHeading() }}</app-heading> [history]="history()"
@if (history().length === 0) { [publishedVersion]="publishedVersion()"
<p class="published">{{ noHistory() }}</p> [busy]="busy()"
} @else { (rollback)="rollback.emit($event)"
<ul class="history-list"> />
@for (v of history(); track v.version) {
<li class="history-row">
<span
>{{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}</span
>
<app-button variant="subtle" [disabled]="busy()" (click)="rollback.emit(v.version)">
{{ rollbackLabel() }}
</app-button>
</li>
}
</ul>
}
</section>
<div class="bar"> <div class="bar">
<span class="published">{{ publishedLabel() }} {{ publishedVersion() }}</span> <span class="published"
><span i18n="@@orgTemplate.published">Gepubliceerde versie:</span>
{{ publishedVersion() }}</span
>
@if (pendingPublish()) { @if (pendingPublish()) {
<app-alert type="warning">{{ impactText() }}</app-alert> <app-alert type="warning">{{ impactText() }}</app-alert>
<app-button variant="primary" [disabled]="busy()" (click)="confirmPublish.emit()"> <app-button
{{ confirmLabel() }} variant="primary"
</app-button> [disabled]="busy()"
<app-button variant="subtle" [disabled]="busy()" (click)="cancelPublish.emit()"> (click)="confirmPublish.emit()"
{{ cancelLabel() }} i18n="@@orgTemplate.publish.confirm"
</app-button> >Bevestigen</app-button
>
<app-button
variant="subtle"
[disabled]="busy()"
(click)="cancelPublish.emit()"
i18n="@@orgTemplate.publish.cancel"
>Annuleren</app-button
>
} @else { } @else {
<app-button <app-button
variant="primary" variant="primary"
[disabled]="!draftValid() || busy()" [disabled]="!draftValid() || busy()"
(click)="requestPublish.emit()" (click)="requestPublish.emit()"
i18n="@@orgTemplate.publish"
>Publiceren</app-button
> >
{{ publishLabel() }}
</app-button>
@if (!draftValid()) { @if (!draftValid()) {
<span class="published">{{ invalidHint() }}</span> <span class="published">{{ invalidHint() }}</span>
} }
} }
<app-button variant="secondary" [disabled]="busy()" (click)="proefbrief.emit()"> <app-button
{{ proefbriefLabel() }} variant="secondary"
</app-button> [disabled]="busy()"
(click)="proefbrief.emit()"
i18n="@@orgTemplate.proefbrief"
>Proefbrief</app-button
>
</div> </div>
`, `,
}) })
@@ -299,14 +217,6 @@ export class OrgTemplateEditorComponent {
protected readonly MIN = MARGIN_MIN_MM; protected readonly MIN = MARGIN_MIN_MM;
protected readonly MAX = MARGIN_MAX_MM; protected readonly MAX = MARGIN_MAX_MM;
protected logoCategory = computed(() =>
this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),
);
protected logoUploads = computed(() =>
this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),
);
protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);
protected onSelectSubOrg(event: Event) { protected onSelectSubOrg(event: Event) {
this.selectSubOrg.emit((event.target as HTMLSelectElement).value); this.selectSubOrg.emit((event.target as HTMLSelectElement).value);
} }
@@ -333,20 +243,9 @@ export class OrgTemplateEditorComponent {
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`, $localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
); );
protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);
protected marginsLegend = input( protected marginsLegend = input(
$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`, $localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`,
); );
protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`);
protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);
protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`);
protected versionLabel = input($localize`:@@orgTemplate.version:Versie`);
protected rollbackLabel = input($localize`:@@orgTemplate.rollback:Terugzetten in concept`);
protected publishedLabel = input($localize`:@@orgTemplate.published:Gepubliceerde versie:`);
protected publishLabel = input($localize`:@@orgTemplate.publish:Publiceren`);
protected confirmLabel = input($localize`:@@orgTemplate.publish.confirm:Bevestigen`);
protected cancelLabel = input($localize`:@@orgTemplate.publish.cancel:Annuleren`);
protected proefbriefLabel = input($localize`:@@orgTemplate.proefbrief:Proefbrief`);
protected invalidHint = input( protected invalidHint = input(
$localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`, $localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,
); );
@@ -87,12 +87,12 @@ const sampleLogo =
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>', '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
); );
/** Published logo (WP-26 AC2): the letterhead canvas shows it above the org name. */ /** Published logo: the letterhead canvas shows it above the org name. */
export const MetLogo: Story = { export const MetLogo: Story = {
args: { logoUrl: sampleLogo }, args: { logoUrl: sampleLogo },
}; };
/** Client-side upload rejection (existing `rejectReason`, WP-26 AC5) — type/size caught /** Client-side upload rejection (existing `rejectReason`) — type/size caught
before the file ever reaches the backend. */ before the file ever reaches the backend. */
export const LogoUploadFout: Story = { export const LogoUploadFout: Story = {
args: { args: {
@@ -0,0 +1,78 @@
import { Component, input, output } from '@angular/core';
import { DatePipe } from '@angular/common';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { OrgTemplateVersion } from '@brief/domain/org-template';
/**
* Organism: the org-template editor's version-history block, split out of
* `org-template-editor.component.ts` (RD-25) — one of its two self-contained
* mutation clusters. Presentational: `rollback` is the parent's own output,
* re-emitted unchanged.
*/
@Component({
selector: 'app-version-history',
imports: [DatePipe, HeadingComponent, ButtonComponent],
styles: [
`
:host {
display: block;
}
.section {
margin-block-start: var(--rhc-space-max-xl);
}
.history-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--rhc-space-max-sm);
}
.history-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rhc-space-max-md);
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
padding-block-end: var(--rhc-space-max-sm);
}
.published {
color: var(--rhc-color-foreground-subtle);
}
`,
],
template: `
<section class="section">
<app-heading [level]="3" i18n="@@orgTemplate.history">Versiegeschiedenis</app-heading>
@if (history().length === 0) {
<p class="published" i18n="@@orgTemplate.history.none">Nog niets gepubliceerd.</p>
} @else {
<ul class="history-list">
@for (v of history(); track v.version) {
<li class="history-row">
<span
><span i18n="@@orgTemplate.version">Versie</span> {{ v.version }} ·
{{ v.publishedAt | date: 'longDate' }}</span
>
<app-button
variant="subtle"
[disabled]="busy()"
(click)="rollback.emit(v.version)"
i18n="@@orgTemplate.rollback"
>Terugzetten in concept</app-button
>
</li>
}
</ul>
}
</section>
`,
})
export class VersionHistoryComponent {
history = input<readonly OrgTemplateVersion[]>([]);
publishedVersion = input(0);
busy = input(false);
rollback = output<number>();
}
@@ -1,13 +1,13 @@
import { Component, computed, effect, inject } from '@angular/core'; import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store'; import { AccessStore } from '@shared/application/access.store';
import { OrgTemplateStore } from '@brief/application/org-template.store'; import { OrgTemplateStore } from '@brief/application/org-template.store';
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component'; import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';
/** Page: thin container for the admin org-template editor (WP-26). Deny-by-default /** Page: thin container for the admin org-template editor. Deny-by-default
capability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor capability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor
for admins. Loads once the capability resolves; wires store commands to the organism. */ for admins. Loads once the capability resolves; wires store commands to the organism. */
@Component({ @Component({
@@ -1,8 +1,8 @@
import { Component, computed, input, output, signal } from '@angular/core'; import { Component, computed, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component'; import { CheckboxComponent } from '@shared/ui/atoms/checkbox/checkbox.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { textOf } from '@shared/kernel/rich-text'; import { textOf } from '@shared/kernel/rich-text';
import { LibraryPassage } from '@brief/domain/brief'; import { LibraryPassage } from '@brief/domain/brief';
@@ -10,8 +10,8 @@ import { LibraryPassage } from '@brief/domain/brief';
inserts ALL checked passages at once (a single message upstream) — there is no inserts ALL checked passages at once (a single message upstream) — there is no
single-insert path. Presentational: emits the chosen passages in list order. single-insert path. Presentational: emits the chosen passages in list order.
Superseded by `besluit-panel` (WP-27's guided drafting): no consumer left in Superseded by `besluit-panel`'s guided drafting: no consumer left in
`src/app` outside its own story (WP-28 audit). Kept for now rather than deleted `src/app` outside its own story. Kept for now rather than deleted
in-flight of an unrelated WP; a future cleanup can remove it. */ in-flight of an unrelated WP; a future cleanup can remove it. */
@Component({ @Component({
selector: 'app-passage-picker', selector: 'app-passage-picker',
@@ -87,7 +87,7 @@ export class PassagePickerComponent {
protected checked = signal<Record<string, boolean>>({}); protected checked = signal<Record<string, boolean>>({});
protected query = signal(''); protected query = signal('');
/** Client-side filter on label + rendered content text — the library is small, so no /** Client-side filter on label + rendered content text — the library is small, so no
server search (WP-27). Placeholder keys are searchable too (see `textOf`). */ server search. Placeholder keys are searchable too (see `textOf`). */
protected filtered = computed(() => { protected filtered = computed(() => {
const q = this.query().trim().toLowerCase(); const q = this.query().trim().toLowerCase();
if (!q) return this.passages(); if (!q) return this.passages();
@@ -1,7 +1,7 @@
import { Component, input, output, signal } from '@angular/core'; import { Component, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
/** Molecule: shows the rejection comments (drafter view) or collects them from the /** Molecule: shows the rejection comments (drafter view) or collects them from the
approver. The approver rejects WITH comments; they never edit the letter. */ approver. The approver rejects WITH comments; they never edit the letter. */
@@ -8,6 +8,7 @@ import {
back, back,
gaNaarStap, gaNaarStap,
submit, submit,
primary,
resolve, resolve,
reduce, reduce,
WizardState, WizardState,
@@ -104,6 +105,25 @@ describe('wizard.machine', () => {
}); });
}); });
describe('primary', () => {
it('Primary advances to the next step from a non-final step', () => {
const s = toStep2('4160', '200'); // Editing, step 2 — not the final step
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Next' }));
expect(expectTag(primary(s), 'Editing').step).toBe(3);
});
it('Primary submits from the final step', () => {
const s = toStep3('4160', '200'); // Editing, step 3 — the final step
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Submit' }));
expect(primary(s).tag).toBe('Submitting');
});
it('Primary is a no-op from a non-editing state', () => {
const submitting = submit(toStep3('4160', '200'));
expect(primary(submitting)).toBe(submitting);
});
});
describe('reduce (message-driven)', () => { describe('reduce (message-driven)', () => {
it('drives the full happy path via messages', () => { it('drives the full happy path via messages', () => {
let s: WizardState = initial; let s: WizardState = initial;
@@ -7,6 +7,7 @@ import {
reduceUpload, reduceUpload,
requiredCategoriesSatisfied, requiredCategoriesSatisfied,
deliveryRefs, deliveryRefs,
digitalDocumentIds,
} from '@shared/domain/upload.machine'; } from '@shared/domain/upload.machine';
/** What the user is typing (raw, possibly invalid). */ /** What the user is typing (raw, possibly invalid). */
@@ -53,7 +54,7 @@ export function hasProgress(s: Extract<WizardState, { tag: 'Editing' }>): boolea
!!s.draft.uren || !!s.draft.uren ||
!!s.draft.jaren || !!s.draft.jaren ||
!!s.draft.punten || !!s.draft.punten ||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId) digitalDocumentIds(s.upload).length > 0
); );
} }
@@ -121,6 +122,13 @@ export function submit(s: WizardState): WizardState {
return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error }; return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
} }
/** The primary button's action: advance, or submit from the last step. No-op
outside Editing — this is the one decision the old component-side handler used to make. */
export function primary(s: WizardState): WizardState {
if (s.tag !== 'Editing') return s;
return s.step === 3 ? submit(s) : next(s);
}
/** Route an upload sub-message through the pure upload reducer (Editing only). */ /** Route an upload sub-message through the pure upload reducer (Editing only). */
export function upload(s: WizardState, msg: UploadMsg): WizardState { export function upload(s: WizardState, msg: UploadMsg): WizardState {
if (s.tag !== 'Editing') return s; if (s.tag !== 'Editing') return s;
@@ -152,6 +160,7 @@ export type WizardMsg =
| { tag: 'Back' } | { tag: 'Back' }
| { tag: 'GaNaarStap'; step: 1 | 2 | 3 } | { tag: 'GaNaarStap'; step: 1 | 2 | 3 }
| { tag: 'Submit' } | { tag: 'Submit' }
| { tag: 'Primary' }
| { tag: 'Retry' } | { tag: 'Retry' }
| { tag: 'SubmitConfirmed' } | { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string } | { tag: 'SubmitFailed'; error: string }
@@ -170,6 +179,8 @@ export function reduce(s: WizardState, m: WizardMsg): WizardState {
return gaNaarStap(s, m.step); return gaNaarStap(s, m.step);
case 'Submit': case 'Submit':
return submit(s); return submit(s);
case 'Primary':
return primary(s);
case 'Retry': case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s; return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed': case 'SubmitConfirmed':
@@ -147,7 +147,7 @@ describe('intake acceptance journeys', () => {
}); });
}); });
it('raising uren above the threshold after answering scholing drops both fields (WP-69 §6)', () => { it('raising uren above the threshold after answering scholing drops both fields', () => {
// Given a journey that answered the scholing question while uren was low. // Given a journey that answered the scholing question while uren was low.
const atReview = givenIntake( const atReview = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
@@ -170,7 +170,7 @@ describe('intake acceptance journeys', () => {
); );
// Then the submission succeeds, and BOTH the stale answer and its punten are gone — // Then the submission succeeds, and BOTH the stale answer and its punten are gone —
// exactly the crafted-POST-shaped payload WP-69's server rule rejects. // exactly the crafted-POST-shaped payload the server rule rejects.
expect(done.tag).toBe('Submitted'); expect(done.tag).toBe('Submitted');
expect(done.tag === 'Submitted' && done.data.aanvullendeScholing).toBeUndefined(); expect(done.tag === 'Submitted' && done.data.aanvullendeScholing).toBeUndefined();
expect(done.tag === 'Submitted' && done.data.punten).toBeUndefined(); expect(done.tag === 'Submitted' && done.data.punten).toBeUndefined();
@@ -10,6 +10,7 @@ import {
back, back,
gaNaarStap, gaNaarStap,
submit, submit,
primary,
resolve, resolve,
reduce, reduce,
IntakeState, IntakeState,
@@ -170,7 +171,7 @@ describe('submit', () => {
expect(withScholing.data.punten).toBe(200); expect(withScholing.data.punten).toBe(200);
}); });
it('does not require punten for a hidden question (WP-69 §6)', () => { it('does not require punten for a hidden question', () => {
// scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above // scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above
// threshold — the template hides the question, so punten must not be required either. // threshold — the template hides the question, so punten must not be required either.
const staleScholingNoPunten = givenIntake( const staleScholingNoPunten = givenIntake(
@@ -182,7 +183,7 @@ describe('submit', () => {
expect(good.data.aanvullendeScholing).toBeUndefined(); expect(good.data.aanvullendeScholing).toBeUndefined();
}); });
it('drops punten when raising uren hides the question (WP-69 §6)', () => { it('drops punten when raising uren hides the question', () => {
// Same stale answer, but this time punten was also filled in while uren was low. // Same stale answer, but this time punten was also filled in while uren was low.
const staleScholingWithPunten = givenIntake( const staleScholingWithPunten = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
@@ -207,6 +208,33 @@ describe('submit', () => {
}); });
}); });
describe('primary', () => {
// Same fixture as the 'submit' describe block above: buitenland answered 'nee',
// uren high enough to skip the scholing question.
const highUren = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
);
it('Primary advances to the next step from a non-final step', () => {
expect(currentStep(expectTag(highUren, 'Answering'))).toBe('buitenland'); // not the final step
expect(reduce(highUren, { tag: 'Primary' })).toEqual(reduce(highUren, { tag: 'Next' }));
expect(expectTag(primary(highUren), 'Answering').cursor).toBe(1);
});
it('Primary submits from the final step', () => {
const atReview = reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' });
expect(currentStep(expectTag(atReview, 'Answering'))).toBe('review'); // the final step
expect(reduce(atReview, { tag: 'Primary' })).toEqual(reduce(atReview, { tag: 'Submit' }));
expect(primary(atReview).tag).toBe('Submitting');
});
it('Primary is a no-op from a non-editing state', () => {
const submitting = submit(reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' }));
expect(primary(submitting)).toBe(submitting);
});
});
describe('reduce (message-driven happy path)', () => { describe('reduce (message-driven happy path)', () => {
it('drives abroad branch end to end', () => { it('drives abroad branch end to end', () => {
let s: IntakeState = initial; let s: IntakeState = initial;
@@ -60,7 +60,7 @@ export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
// #endregion showcase:steps // #endregion showcase:steps
/** Per-field error map: one message per question, since a step holds several. */ /** Per-field error map: one message per question, since a step holds several. */
type Errors = Partial<Record<keyof Answers, string>>; export type Errors = Partial<Record<keyof Answers, string>>;
export type IntakeState = export type IntakeState =
| { | {
@@ -116,7 +116,7 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
// visible (lageUren) AND scholing was followed — matching the template's // visible (lageUren) AND scholing was followed — matching the template's
// `@if (scholingZichtbaar())`. Without the `lageUren` guard, answering 'ja' and then // `@if (scholingZichtbaar())`. Without the `lageUren` guard, answering 'ja' and then
// raising uren above the threshold left an error on a field the template no longer // raising uren above the threshold left an error on a field the template no longer
// renders (WP-69 §6). // renders.
if (lageUren(a, scholingThreshold) && a.scholingGevolgd === 'ja') { if (lageUren(a, scholingThreshold) && a.scholingGevolgd === 'ja') {
const p = parseUren(a.punten ?? ''); const p = parseUren(a.punten ?? '');
if (!p.ok) errors.punten = p.error; if (!p.ok) errors.punten = p.error;
@@ -149,8 +149,8 @@ function validateAll(a: Answers, scholingThreshold: number): Result<Errors, Vali
const aanvullendeScholing = lageUren(a, scholingThreshold) const aanvullendeScholing = lageUren(a, scholingThreshold)
? a.scholingGevolgd === 'ja' ? a.scholingGevolgd === 'ja'
: undefined; : undefined;
// Punten are derived from aanvullendeScholing, NOT the raw scholingGevolgd answer (WP-69 // Punten are derived from aanvullendeScholing, NOT the raw scholingGevolgd answer —
// §6) — a stale 'ja' left over from when uren was low, after uren was raised above the // a stale 'ja' left over from when uren was low, after uren was raised above the
// threshold, must not leak a punten value into the parsed, submitted ValidIntake. // threshold, must not leak a punten value into the parsed, submitted ValidIntake.
const punten = aanvullendeScholing === true ? parseUren(a.punten ?? '') : undefined; const punten = aanvullendeScholing === true ? parseUren(a.punten ?? '') : undefined;
return ok({ return ok({
@@ -200,6 +200,13 @@ export function submit(s: IntakeState): IntakeState {
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error }; return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
} }
/** The primary button's action: advance, or submit from the review step. No-op
outside Answering — this is the one decision the old component-side handler used to make. */
export function primary(s: IntakeState): IntakeState {
if (s.tag !== 'Answering') return s;
return currentStep(s) === 'review' ? submit(s) : next(s);
}
export function resolve(s: IntakeState, r: Result<string, void>): IntakeState { export function resolve(s: IntakeState, r: Result<string, void>): IntakeState {
if (s.tag !== 'Submitting') return s; if (s.tag !== 'Submitting') return s;
return r.ok return r.ok
@@ -213,6 +220,7 @@ export type IntakeMsg =
| { tag: 'Back' } | { tag: 'Back' }
| { tag: 'GaNaarStap'; cursor: number } | { tag: 'GaNaarStap'; cursor: number }
| { tag: 'Submit' } | { tag: 'Submit' }
| { tag: 'Primary' }
| { tag: 'Retry' } | { tag: 'Retry' }
| { tag: 'SubmitConfirmed' } | { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string } | { tag: 'SubmitFailed'; error: string }
@@ -231,6 +239,8 @@ export function reduce(s: IntakeState, m: IntakeMsg): IntakeState {
return gaNaarStap(s, m.cursor); return gaNaarStap(s, m.cursor);
case 'Submit': case 'Submit':
return submit(s); return submit(s);
case 'Primary':
return primary(s);
case 'Retry': case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s; return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed': case 'SubmitConfirmed':
@@ -1,15 +1,16 @@
import { Component, computed, inject, input } from '@angular/core'; import { Component, computed, inject, input } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { import {
WizardShellComponent, WizardShellComponent,
WizardError, WizardError,
WizardStatus, WizardPhase,
naarStapLabel, naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component'; } from '@shared/layout/wizard-shell/wizard-shell.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component'; import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp'; import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
@@ -22,9 +23,9 @@ import {
hasProgress, hasProgress,
} from '@herregistratie/domain/herregistratie.machine'; } from '@herregistratie/domain/herregistratie.machine';
import { createDraftSync } from '@registratie/application/draft-sync'; import { createDraftSync } from '@registratie/application/draft-sync';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component'; import { DocumentUploadComponent } from '@shared/ui/organisms/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/application/upload-controller'; import { createUploadController } from '@shared/application/upload-controller';
import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine'; import { UploadState, initialUpload, digitalDocumentIds } from '@shared/domain/upload.machine';
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal /** Organism: multi-step herregistratie wizard. ALL state lives in one signal
driven by the pure `reduce` function (see herregistratie.machine.ts) via an driven by the pure `reduce` function (see herregistratie.machine.ts) via an
@@ -50,15 +51,14 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.
[stepTitle]="stepTitle()" [stepTitle]="stepTitle()"
i18n-processName="@@herregWizard.processName" i18n-processName="@@herregWizard.processName"
processName="Herregistratie aanvragen" processName="Herregistratie aanvragen"
[status]="shellStatus()" [phase]="phase()"
[primaryLabel]="primaryLabel()" [primaryLabel]="primaryLabel()"
[canGoBack]="step() > 1" [canGoBack]="step() > 1"
[errors]="errorList()" [errors]="errorList()"
[errorMessage]="errorMessage()" (primary)="dispatch({ tag: 'Primary' })"
(primary)="onPrimary()"
(back)="dispatch({ tag: 'Back' })" (back)="dispatch({ tag: 'Back' })"
(cancel)="restart()" (cancel)="restart()"
(retry)="onRetry()" (retry)="dispatch({ tag: 'Retry' })"
(goToStep)="goToStep($event)" (goToStep)="goToStep($event)"
> >
@switch (step()) { @switch (step()) {
@@ -148,37 +148,31 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.
}) })
export class HerregistratieWizardComponent { export class HerregistratieWizardComponent {
private profile = inject(BigProfileStore); private profile = inject(BigProfileStore);
private store = createStore<WizardState, WizardMsg>(initial, reduce);
/** Preview/download link for a completed upload; delegates to the upload
controller (application layer), which knows the dev-simulation `demo-*` ids
have no stored bytes and returns no link for them. */
protected previewUrlFor = (documentId: string): string | undefined =>
this.uploadCtl.previewUrlFor(documentId);
/** Optional seed so Storybook / the showcase can mount any state directly. */ /** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<WizardState>(initial); seed = input<WizardState>(initial);
// --- The store: all state in one signal, changed only by a pure reduce -----
// The effect fires once, on the `Editing -> Submitting` transition. `Seed` is exempt,
// so a story that mounts straight into `Submitting` does not call the network.
// `draftSync` is declared below (both callbacks are deferred, so the cycle is safe).
private store = createStore<WizardState, WizardMsg>(initial, reduce, {
Submitting: async (s, store) => {
this.profile.beginHerregistratie();
const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents });
if (r.ok) {
store.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();
} else {
store.dispatch({ tag: 'SubmitFailed', error: r.error });
this.profile.rollbackHerregistratie();
}
},
});
readonly state = this.store.model; // public so the showcase can highlight the live state readonly state = this.store.model; // public so the showcase can highlight the live state
protected dispatch = this.store.dispatch; protected dispatch = this.store.dispatch;
// Backend draft-sync (new persistence for this wizard): create a Concept on first // --- Static copy: stepper labels and per-step headings ---------------------
// progress, debounced-sync the snapshot, resume by `?aanvraag=<id>`.
private draftSync = createDraftSync({
type: 'herregistratie',
snapshot: () => {
const s = this.state();
if (s.tag !== 'Editing' || !hasProgress(s)) return null;
const documentIds = deliveryRefs(s.upload)
.filter((r) => r.channel === 'digital' && r.documentId)
.map((r) => r.documentId!);
return { draft: s, stepIndex: s.step - 1, stepCount: this.stepLabels.length, documentIds };
},
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }),
enabled: () => this.seed() === initial,
});
// Stepper labels + per-step heading titles (presentational only).
readonly stepLabels = [ readonly stepLabels = [
$localize`:@@herregWizard.step.werkervaring:Werkervaring`, $localize`:@@herregWizard.step.werkervaring:Werkervaring`,
$localize`:@@herregWizard.step.nascholing:Nascholing`, $localize`:@@herregWizard.step.nascholing:Nascholing`,
@@ -190,6 +184,7 @@ export class HerregistratieWizardComponent {
$localize`:@@herregWizard.title.documenten:Documenten aanleveren`, $localize`:@@herregWizard.title.documenten:Documenten aanleveren`,
]; ];
// --- State projections: one narrow, then read-only views of it -------------
private editing = computed(() => whenTag(this.state(), 'Editing')); private editing = computed(() => whenTag(this.state(), 'Editing'));
protected step = computed(() => this.editing()?.step ?? 1); protected step = computed(() => this.editing()?.step ?? 1);
protected draft = computed<Draft>( protected draft = computed<Draft>(
@@ -200,12 +195,35 @@ export class HerregistratieWizardComponent {
protected errJaren = computed(() => this.editing()?.errors.jaren ?? ''); protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
protected errPunten = computed(() => this.editing()?.errors.punten ?? ''); protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? ''); protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? '');
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
// --- Controllers: persistence and uploads ----------------------------------
// Create a Concept on first progress, then debounced-sync the snapshot.
// `?aanvraag=<id>` resumes it.
private draftSync = createDraftSync({
type: 'herregistratie',
snapshot: () => {
const s = this.state();
if (s.tag !== 'Editing' || !hasProgress(s)) return null;
return {
draft: s,
stepIndex: s.step - 1,
stepCount: this.stepLabels.length,
documentIds: digitalDocumentIds(s.upload),
};
},
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }),
enabled: () => this.seed() === initial,
});
protected uploadCtl = createUploadController({ protected uploadCtl = createUploadController({
wizardId: 'herregistratie', wizardId: 'herregistratie',
getUpload: () => this.upload(), getUpload: () => this.upload(),
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }), dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
}); });
/** Preview/download link for a completed upload; delegates to the upload
controller (application layer), which knows the dev-simulation `demo-*` ids
have no stored bytes and returns no link for them. */
protected previewUrlFor = (documentId: string): string | undefined =>
this.uploadCtl.previewUrlFor(documentId);
// --- Presentational wiring for the shared wizard shell --------------------- // --- Presentational wiring for the shared wizard shell ---------------------
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]); protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
@@ -215,33 +233,32 @@ export class HerregistratieWizardComponent {
? naarStapLabel(step + 1, this.stepLabels[step]) ? naarStapLabel(step + 1, this.stepLabels[step])
: $localize`:@@herregWizard.indienen:Herregistratie aanvragen`; : $localize`:@@herregWizard.indienen:Herregistratie aanvragen`;
}); });
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
composing the localized failure prefix so the `Failed` message arrives intact. */
protected phase = computed<WizardPhase>(() => {
const s = this.state();
switch (s.tag) {
case 'Editing':
return { tag: 'Editing' };
case 'Submitting':
return { tag: 'Submitting' };
case 'Submitted':
return { tag: 'Submitted' };
case 'Failed':
return {
tag: 'Failed',
message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`,
};
}
});
/** Current step's field errors, flattened for the shell's error summary. */
protected errorList = computed<WizardError[]>(() => toWizardErrors(this.editing()?.errors ?? {}));
// --- Event handlers: narrow a child event into a message -------------------
/** Stepper emits a 0-based index for an earlier (visited) step. */ /** Stepper emits a 0-based index for an earlier (visited) step. */
protected goToStep(index: number) { protected goToStep(index: number) {
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 }); this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 });
} }
protected errorMessage = computed(
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
);
protected shellStatus = computed<WizardStatus>(() => {
switch (this.state().tag) {
case 'Editing':
return 'editing';
case 'Submitting':
return 'submitting';
case 'Submitted':
return 'submitted';
case 'Failed':
return 'failed';
}
});
/** Current step's field errors, flattened for the shell's error summary. */
protected errorList = computed<WizardError[]>(() => {
const e = this.editing()?.errors ?? {};
return (Object.keys(e) as (keyof typeof e)[])
.filter((k) => e[k])
.map((k) => ({ id: k, message: e[k]! }));
});
constructor() { constructor() {
// An explicit seed (stories/tests) wins; otherwise resume the backend draft // An explicit seed (stories/tests) wins; otherwise resume the backend draft
@@ -252,37 +269,9 @@ export class HerregistratieWizardComponent {
); );
} }
onPrimary() {
const s = this.state();
if (s.tag !== 'Editing') return;
this.dispatch(s.step < 3 ? { tag: 'Next' } : { tag: 'Submit' });
this.runIfSubmitting();
}
onRetry() {
this.dispatch({ tag: 'Retry' });
this.runIfSubmitting();
}
/** Reset the wizard to a fresh, empty start. */ /** Reset the wizard to a fresh, empty start. */
restart() { restart() {
this.draftSync.reset(); this.draftSync.reset();
this.dispatch({ tag: 'Seed', state: initial }); this.dispatch({ tag: 'Seed', state: initial });
} }
/** The effect: when we entered Submitting, submit through the aanvraag lifecycle,
flip the optimistic cross-page flag, then dispatch the result (commit/rollback). */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie();
const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents });
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();
} else {
this.dispatch({ tag: 'SubmitFailed', error: r.error });
this.profile.rollbackHerregistratie();
}
}
} }
@@ -1,7 +1,7 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { map } from '@shared/application/remote-data'; import { map } from '@shared/application/remote-data';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component'; import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
@@ -0,0 +1,77 @@
import { Component, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/atoms/radio-group/radio-group.component';
import { Answers, Errors } from '@herregistratie/domain/intake.machine';
/** Step: the intake wizard's first screen (foreign work in the last 5 years).
Pure & presentational — values in via `answers`/`errors`, every keystroke out
via `answerChange`. No store, no services, no internal state; the parent owns
the Model and decides what a change means. */
@Component({
selector: 'app-intake-buitenland-step',
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent],
template: `
<fieldset>
<app-form-field
i18n-label="@@intake.q.buitenland"
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
fieldId="buitenlandGewerkt"
required
[error]="err('buitenlandGewerkt')"
>
<app-radio-group
name="buitenlandGewerkt"
[options]="jaNee"
[ngModel]="answers().buitenlandGewerkt ?? ''"
(ngModelChange)="answerChange.emit({ key: 'buitenlandGewerkt', value: $event })"
/>
</app-form-field>
</fieldset>
@if (answers().buitenlandGewerkt === 'ja') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.land"
label="In welk land?"
fieldId="land"
required
[error]="err('land')"
>
<app-text-input
inputId="land"
[ngModel]="answers().land ?? ''"
(ngModelChange)="answerChange.emit({ key: 'land', value: $event })"
name="land"
i18n-placeholder="@@intake.q.landPlaceholder"
placeholder="bijv. België"
/>
</app-form-field>
<app-form-field
i18n-label="@@intake.q.buitenlandseUren"
label="Hoeveel uur heeft u daar gewerkt?"
fieldId="buitenlandseUren"
required
[error]="err('buitenlandseUren')"
>
<app-text-input
inputId="buitenlandseUren"
[ngModel]="answers().buitenlandseUren ?? ''"
(ngModelChange)="answerChange.emit({ key: 'buitenlandseUren', value: $event })"
name="buitenlandseUren"
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
placeholder="bijv. 800"
/>
</app-form-field>
</fieldset>
}
`,
})
export class BuitenlandStep {
answers = input.required<Answers>();
errors = input.required<Errors>();
answerChange = output<{ key: keyof Answers; value: string }>();
readonly jaNee = JA_NEE;
protected err = (k: keyof Answers) => this.errors()[k] ?? '';
}
@@ -1,19 +1,13 @@
import { Component, computed, effect, inject, input, untracked } from '@angular/core'; import { Component, computed, effect, inject, input, untracked } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import { import {
WizardShellComponent, WizardShellComponent,
WizardError, WizardError,
WizardStatus, WizardPhase,
naarStapLabel, naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component'; } from '@shared/layout/wizard-shell/wizard-shell.component';
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp'; import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
@@ -21,35 +15,35 @@ import {
IntakeState, IntakeState,
IntakeMsg, IntakeMsg,
Answers, Answers,
Errors,
StepId, StepId,
initial, initial,
reduce, reduce,
STEPS, STEPS,
lageUren,
hasProgress, hasProgress,
SCHOLING_THRESHOLD_DEFAULT, SCHOLING_THRESHOLD_DEFAULT,
} from '@herregistratie/domain/intake.machine'; } from '@herregistratie/domain/intake.machine';
import { createDraftSync } from '@registratie/application/draft-sync'; import { createDraftSync } from '@registratie/application/draft-sync';
import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store'; import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';
import { BuitenlandStep } from './buitenland.step';
import { WerkStep } from './werk.step';
import { ReviewStep } from './review.step';
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal /** Organism: a BRANCHING intake questionnaire. All state lives in one signal
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
from the answers via `visibleSteps`, never stored — so editing an earlier from the answers via `visibleSteps`, never stored — so editing an earlier
answer immediately changes the remaining steps. Answers are persisted to answer immediately changes the remaining steps. The draft persists to the
sessionStorage so a page reload keeps the user's progress (cleared on tab close). */ backend as a Concept aanvraag (createDraftSync), so a reload — or a "Verder
gaan" from the dashboard via `?aanvraag=<id>` — resumes progress. */
@Component({ @Component({
selector: 'app-intake-wizard', selector: 'app-intake-wizard',
imports: [ imports: [
FormsModule,
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
ButtonComponent, ButtonComponent,
AlertComponent,
DataRowComponent,
ReviewSectionComponent,
ConfirmationComponent, ConfirmationComponent,
WizardShellComponent, WizardShellComponent,
BuitenlandStep,
WerkStep,
ReviewStep,
], ],
template: ` template: `
<app-wizard-shell <app-wizard-shell
@@ -58,193 +52,38 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
[stepTitle]="stepTitle()" [stepTitle]="stepTitle()"
i18n-processName="@@intake.processName" i18n-processName="@@intake.processName"
processName="Herregistratie-intake" processName="Herregistratie-intake"
[status]="shellStatus()" [phase]="phase()"
[primaryLabel]="primaryLabel()" [primaryLabel]="primaryLabel()"
[canGoBack]="cursor() > 0" [canGoBack]="cursor() > 0"
[errors]="errorList()" [errors]="errorList()"
[errorMessage]="errorMessage()" (primary)="dispatch({ tag: 'Primary' })"
(primary)="onPrimary()"
(back)="dispatch({ tag: 'Back' })" (back)="dispatch({ tag: 'Back' })"
(cancel)="restart()" (cancel)="restart()"
(retry)="onRetry()" (retry)="dispatch({ tag: 'Retry' })"
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })" (goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
> >
@switch (step()) { @switch (step()) {
@case ('buitenland') { @case ('buitenland') {
<fieldset> <app-intake-buitenland-step
<app-form-field [answers]="answers()"
i18n-label="@@intake.q.buitenland" [errors]="errors()"
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" (answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
fieldId="buitenlandGewerkt" />
required
[error]="err('buitenlandGewerkt')"
>
<app-radio-group
name="buitenlandGewerkt"
[options]="jaNee"
[ngModel]="answers().buitenlandGewerkt ?? ''"
(ngModelChange)="set('buitenlandGewerkt', $event)"
/>
</app-form-field>
</fieldset>
@if (answers().buitenlandGewerkt === 'ja') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.land"
label="In welk land?"
fieldId="land"
required
[error]="err('land')"
>
<app-text-input
inputId="land"
[ngModel]="answers().land ?? ''"
(ngModelChange)="set('land', $event)"
name="land"
i18n-placeholder="@@intake.q.landPlaceholder"
placeholder="bijv. België"
/>
</app-form-field>
<app-form-field
i18n-label="@@intake.q.buitenlandseUren"
label="Hoeveel uur heeft u daar gewerkt?"
fieldId="buitenlandseUren"
required
[error]="err('buitenlandseUren')"
>
<app-text-input
inputId="buitenlandseUren"
[ngModel]="answers().buitenlandseUren ?? ''"
(ngModelChange)="set('buitenlandseUren', $event)"
name="buitenlandseUren"
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
placeholder="bijv. 800"
/>
</app-form-field>
</fieldset>
}
} }
@case ('werk') { @case ('werk') {
<fieldset> <app-intake-werk-step
<app-form-field [answers]="answers()"
i18n-label="@@intake.q.urenNl" [errors]="errors()"
label="Gewerkte uren in Nederland (afgelopen 5 jaar)" [scholingThreshold]="scholingThreshold()"
fieldId="uren" (answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
required />
[error]="err('uren')"
>
<app-text-input
inputId="uren"
[ngModel]="answers().uren ?? ''"
(ngModelChange)="set('uren', $event)"
name="uren"
i18n-placeholder="@@intake.q.urenNlPlaceholder"
placeholder="bijv. 4160"
/>
</app-form-field>
</fieldset>
@if (scholingZichtbaar()) {
<fieldset>
<app-form-field
i18n-label="@@intake.q.scholing"
label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?"
fieldId="scholingGevolgd"
required
[error]="err('scholingGevolgd')"
>
<app-radio-group
name="scholingGevolgd"
[options]="jaNee"
[ngModel]="answers().scholingGevolgd ?? ''"
(ngModelChange)="set('scholingGevolgd', $event)"
/>
</app-form-field>
</fieldset>
}
@if (answers().scholingGevolgd === 'ja') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.punten"
label="Behaalde nascholingspunten"
fieldId="punten"
required
[error]="err('punten')"
>
<app-text-input
inputId="punten"
[ngModel]="answers().punten ?? ''"
(ngModelChange)="set('punten', $event)"
name="punten"
i18n-placeholder="@@intake.q.puntenPlaceholder"
placeholder="bijv. 200"
/>
</app-form-field>
</fieldset>
}
} }
@case ('review') { @case ('review') {
<app-alert type="info" i18n="@@intake.review.controleer" <app-intake-review-step
>Controleer uw antwoorden en dien de aanvraag in.</app-alert [answers]="answers()"
> [scholingThreshold]="scholingThreshold()"
<app-review-section (edit)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
i18n-heading="@@intake.sectie.buitenland" />
heading="Buitenland"
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
editAriaLabel="Wijzigen buitenland"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
>
<div
app-data-row
i18n-key="@@intake.review.buitenNl"
key="Buiten NL gewerkt"
[value]="answers().buitenlandGewerkt ?? '—'"
></div>
@if (answers().buitenlandGewerkt === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.land"
key="Land"
[value]="answers().land ?? ''"
></div>
<div
app-data-row
i18n-key="@@intake.review.buitenlandseUren"
key="Buitenlandse uren"
[value]="answers().buitenlandseUren ?? ''"
></div>
}
</app-review-section>
<app-review-section
class="app-section"
i18n-heading="@@intake.sectie.werk"
heading="Werk in Nederland"
i18n-editAriaLabel="@@intake.werkWijzigenAria"
editAriaLabel="Wijzigen werk in Nederland"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
>
<div
app-data-row
i18n-key="@@intake.review.urenNl"
key="Uren NL"
[value]="answers().uren ?? ''"
></div>
@if (scholingZichtbaar()) {
<div
app-data-row
i18n-key="@@intake.review.scholing"
key="Aanvullende scholing"
[value]="answers().scholingGevolgd ?? ''"
></div>
}
@if (answers().scholingGevolgd === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.punten"
key="Nascholingspunten"
[value]="answers().punten ?? ''"
></div>
}
</app-review-section>
} }
} }
@@ -268,16 +107,66 @@ export class IntakeWizardComponent {
// Server-owned policy (scholing threshold): fetched from the backend via the // Server-owned policy (scholing threshold): fetched from the backend via the
// application facade, not hardcoded. The backend stays the authority on submit. // application facade, not hardcoded. The backend stays the authority on submit.
private policyStore = inject(IntakePolicyStore); private policyStore = inject(IntakePolicyStore);
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
/** Optional seed so Storybook / the showcase can mount any state directly. */ /** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<IntakeState>(initial); seed = input<IntakeState>(initial);
readonly jaNee = JA_NEE; // --- The store: all state in one signal, changed only by a pure reduce -----
// The effect fires once, on the `Answering -> Submitting` transition. `Seed` is exempt,
// so a story that mounts straight into `Submitting` does not call the network.
// `draftSync` is declared below (both callbacks are deferred, so the cycle is safe).
private store = createStore<IntakeState, IntakeMsg>(initial, reduce, {
Submitting: async (s, store) => {
this.profile.beginHerregistratie();
// The scholing answer rides along so the server can re-validate it as the
// authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by
// JSON.stringify, so a wizard above the threshold sends neither field.
const r = await this.draftSync.submit({
uren: s.data.uren,
aanvullendeScholing: s.data.aanvullendeScholing,
scholingPunten: s.data.punten,
});
if (r.ok) {
store.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();
} else {
store.dispatch({ tag: 'SubmitFailed', error: r.error });
this.profile.rollbackHerregistratie();
}
},
});
readonly state = this.store.model; readonly state = this.store.model;
readonly dispatch = this.store.dispatch; readonly dispatch = this.store.dispatch;
// Backend draft-sync (replaces sessionStorage); the intake has no uploads. // --- Static copy: stepper labels and per-step headings ---------------------
readonly stepLabels = [
$localize`:@@intake.step.buitenland:Buitenland`,
$localize`:@@intake.step.werk:Werk`,
$localize`:@@intake.step.controle:Controle`,
];
private stepTitles: Record<StepId, string> = {
buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,
werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,
review: $localize`:@@intake.title.review:Controleren en indienen`,
};
// --- State projections: one narrow, then read-only views of it -------------
private answering = computed(() => whenTag(this.state(), 'Answering'));
/** Public so the showcase can render the (fixed) step list next to the wizard. */
readonly steps = STEPS;
protected cursor = computed(() => this.answering()?.cursor ?? 0);
protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
/** Server-owned threshold from the policy endpoint (mirrored into machine state). */
protected scholingThreshold = computed(
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
);
protected errors = computed<Errors>(() => this.answering()?.errors ?? {});
// --- Controllers: persistence and uploads ----------------------------------
// Create a Concept on first progress, then debounced-sync the snapshot.
// `?aanvraag=<id>` resumes it. The intake has no uploads.
private draftSync = createDraftSync({ private draftSync = createDraftSync({
type: 'intake', type: 'intake',
snapshot: () => { snapshot: () => {
@@ -289,64 +178,36 @@ export class IntakeWizardComponent {
enabled: () => this.seed() === initial, enabled: () => this.seed() === initial,
}); });
private answering = computed(() => whenTag(this.state(), 'Answering'));
/** Public so the showcase can render the (fixed) step list next to the wizard. */
readonly steps = STEPS;
protected cursor = computed(() => this.answering()?.cursor ?? 0);
protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
/** Server-owned threshold from the policy endpoint (mirrored into machine state). */
protected scholingThreshold = computed(
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
);
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
// --- Presentational wiring for the shared wizard shell --------------------- // --- Presentational wiring for the shared wizard shell ---------------------
readonly stepLabels = [
$localize`:@@intake.step.buitenland:Buitenland`,
$localize`:@@intake.step.werk:Werk`,
$localize`:@@intake.step.controle:Controle`,
];
private stepTitles: Record<StepId, string> = {
buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,
werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,
review: $localize`:@@intake.title.review:Controleren en indienen`,
};
protected stepTitle = computed(() => this.stepTitles[this.step()]); protected stepTitle = computed(() => this.stepTitles[this.step()]);
protected primaryLabel = computed(() => { protected primaryLabel = computed(() => {
if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`; if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`;
const next = this.cursor() + 1; const next = this.cursor() + 1;
return naarStapLabel(next + 1, this.stepLabels[next]); return naarStapLabel(next + 1, this.stepLabels[next]);
}); });
protected errorMessage = computed( /** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`, composing the localized failure prefix so the `Failed` message arrives intact. */
); protected phase = computed<WizardPhase>(() => {
protected shellStatus = computed<WizardStatus>(() => { const s = this.state();
switch (this.state().tag) { switch (s.tag) {
case 'Answering': case 'Answering':
return 'editing'; return { tag: 'Editing' };
case 'Submitting': case 'Submitting':
return 'submitting'; return { tag: 'Submitting' };
case 'Submitted': case 'Submitted':
return 'submitted'; return { tag: 'Submitted' };
case 'Failed': case 'Failed':
return 'failed'; return {
tag: 'Failed',
message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`,
};
} }
}); });
/** Current step's field errors, flattened for the shell's error summary. The /** Current step's field errors, flattened for the shell's error summary. The
field ids match the answer keys, so the summary anchors jump to the field. */ field ids match the answer keys, so the summary anchors jump to the field. */
protected errorList = computed<WizardError[]>(() => { protected errorList = computed<WizardError[]>(() =>
const e = this.answering()?.errors ?? {}; toWizardErrors(this.answering()?.errors ?? {}),
return (Object.keys(e) as (keyof Answers)[]) );
.filter((k) => e[k])
.map((k) => ({ id: k, message: e[k]! }));
});
protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
protected set = (key: keyof Answers, value: string) =>
this.dispatch({ tag: 'SetAnswer', key, value });
constructor() { constructor() {
// An explicit seed (stories/tests) wins; otherwise resume the backend draft // An explicit seed (stories/tests) wins; otherwise resume the backend draft
@@ -364,43 +225,8 @@ export class IntakeWizardComponent {
}); });
} }
onPrimary() {
const s = this.state();
if (s.tag !== 'Answering') return;
this.dispatch(this.step() === 'review' ? { tag: 'Submit' } : { tag: 'Next' });
this.runIfSubmitting();
}
onRetry() {
this.dispatch({ tag: 'Retry' });
this.runIfSubmitting();
}
restart() { restart() {
this.draftSync.reset(); this.draftSync.reset();
this.dispatch({ tag: 'Seed', state: initial }); this.dispatch({ tag: 'Seed', state: initial });
} }
/** The effect: when we enter Submitting, submit through the aanvraag lifecycle,
flip the optimistic cross-page flag, then dispatch the outcome (commit/rollback). */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie();
// WP-69: the scholing answer rides along so the server can re-validate it as the
// authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by
// JSON.stringify, so a wizard above the threshold sends neither field.
const r = await this.draftSync.submit({
uren: s.data.uren,
aanvullendeScholing: s.data.aanvullendeScholing,
scholingPunten: s.data.punten,
});
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();
} else {
this.dispatch({ tag: 'SubmitFailed', error: r.error });
this.profile.rollbackHerregistratie();
}
}
} }
@@ -0,0 +1,85 @@
import { Component, computed, input, output } from '@angular/core';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ReviewSectionComponent } from '@shared/ui/molecules/review-section/review-section.component';
import { Answers, lageUren } from '@herregistratie/domain/intake.machine';
/** Step: the intake wizard's review screen. Pure & presentational — values in via
`answers`/`scholingThreshold`, the cursor to jump back to out via `edit`. No
store, no services, no internal state; the parent maps the cursor onto its own
`GaNaarStap` message. */
@Component({
selector: 'app-intake-review-step',
imports: [AlertComponent, DataRowComponent, ReviewSectionComponent],
template: `
<app-alert type="info" i18n="@@intake.review.controleer"
>Controleer uw antwoorden en dien de aanvraag in.</app-alert
>
<app-review-section
i18n-heading="@@intake.sectie.buitenland"
heading="Buitenland"
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
editAriaLabel="Wijzigen buitenland"
(edit)="edit.emit(0)"
>
<div
app-data-row
i18n-key="@@intake.review.buitenNl"
key="Buiten NL gewerkt"
[value]="answers().buitenlandGewerkt ?? '—'"
></div>
@if (answers().buitenlandGewerkt === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.land"
key="Land"
[value]="answers().land ?? ''"
></div>
<div
app-data-row
i18n-key="@@intake.review.buitenlandseUren"
key="Buitenlandse uren"
[value]="answers().buitenlandseUren ?? ''"
></div>
}
</app-review-section>
<app-review-section
class="app-section"
i18n-heading="@@intake.sectie.werk"
heading="Werk in Nederland"
i18n-editAriaLabel="@@intake.werkWijzigenAria"
editAriaLabel="Wijzigen werk in Nederland"
(edit)="edit.emit(1)"
>
<div
app-data-row
i18n-key="@@intake.review.urenNl"
key="Uren NL"
[value]="answers().uren ?? ''"
></div>
@if (scholingZichtbaar()) {
<div
app-data-row
i18n-key="@@intake.review.scholing"
key="Aanvullende scholing"
[value]="answers().scholingGevolgd ?? ''"
></div>
}
@if (answers().scholingGevolgd === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.punten"
key="Nascholingspunten"
[value]="answers().punten ?? ''"
></div>
}
</app-review-section>
`,
})
export class ReviewStep {
answers = input.required<Answers>();
scholingThreshold = input.required<number>();
edit = output<number>();
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
}
@@ -0,0 +1,84 @@
import { Component, computed, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/atoms/radio-group/radio-group.component';
import { Answers, Errors, lageUren } from '@herregistratie/domain/intake.machine';
/** Step: the intake wizard's second screen (work experience in the Netherlands,
with the inline scholing follow-up). Pure & presentational — values in via
`answers`/`errors`/`scholingThreshold`, every keystroke out via `answerChange`.
No store, no services, no internal state; the parent owns the Model and
decides what a change means. */
@Component({
selector: 'app-intake-werk-step',
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent],
template: `
<fieldset>
<app-form-field
i18n-label="@@intake.q.urenNl"
label="Gewerkte uren in Nederland (afgelopen 5 jaar)"
fieldId="uren"
required
[error]="err('uren')"
>
<app-text-input
inputId="uren"
[ngModel]="answers().uren ?? ''"
(ngModelChange)="answerChange.emit({ key: 'uren', value: $event })"
name="uren"
i18n-placeholder="@@intake.q.urenNlPlaceholder"
placeholder="bijv. 4160"
/>
</app-form-field>
</fieldset>
@if (scholingZichtbaar()) {
<fieldset>
<app-form-field
i18n-label="@@intake.q.scholing"
label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?"
fieldId="scholingGevolgd"
required
[error]="err('scholingGevolgd')"
>
<app-radio-group
name="scholingGevolgd"
[options]="jaNee"
[ngModel]="answers().scholingGevolgd ?? ''"
(ngModelChange)="answerChange.emit({ key: 'scholingGevolgd', value: $event })"
/>
</app-form-field>
</fieldset>
}
@if (answers().scholingGevolgd === 'ja') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.punten"
label="Behaalde nascholingspunten"
fieldId="punten"
required
[error]="err('punten')"
>
<app-text-input
inputId="punten"
[ngModel]="answers().punten ?? ''"
(ngModelChange)="answerChange.emit({ key: 'punten', value: $event })"
name="punten"
i18n-placeholder="@@intake.q.puntenPlaceholder"
placeholder="bijv. 200"
/>
</app-form-field>
</fieldset>
}
`,
})
export class WerkStep {
answers = input.required<Answers>();
errors = input.required<Errors>();
scholingThreshold = input.required<number>();
answerChange = output<{ key: keyof Answers; value: string }>();
readonly jaNee = JA_NEE;
protected err = (k: keyof Answers) => this.errors()[k] ?? '';
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
}
@@ -1,6 +1,6 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component'; import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
/** Page: the branching intake questionnaire. Built entirely from existing /** Page: the branching intake questionnaire. Built entirely from existing
@@ -1,9 +1,9 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component'; import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { AccessStore } from '@shared/application/access.store'; import { AccessStore } from '@shared/application/access.store';
import { ADMIN_LINKS } from '../../../shell/nav.config'; import { HEADER_ADMIN_LINKS } from '@shared/layout/site-header/nav-config';
/** Section: "Beheer" — the admin pages the current principal may reach, capability- /** Section: "Beheer" — the admin pages the current principal may reach, capability-
gated (never role-derived), the same source + filter the site header uses. gated (never role-derived), the same source + filter the site header uses.
@@ -31,5 +31,6 @@ import { ADMIN_LINKS } from '../../../shell/nav.config';
}) })
export class BeheerLinksSection { export class BeheerLinksSection {
private access = inject(AccessStore); private access = inject(AccessStore);
protected adminLinks = computed(() => ADMIN_LINKS.filter((l) => this.access.can(l.cap))); private rawAdminLinks = inject(HEADER_ADMIN_LINKS);
protected adminLinks = computed(() => this.rawAdminLinks.filter((l) => this.access.can(l.cap)));
} }
@@ -1,17 +1,18 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { MijnAanvragenSection } from './dashboard/mijn-aanvragen.section'; import { MijnAanvragenSection } from '@registratie/ui/dashboard/mijn-aanvragen.section';
import { WatMoetIkRegelenSection } from './dashboard/wat-moet-ik-regelen.section'; import { WatMoetIkRegelenSection } from '@registratie/ui/dashboard/wat-moet-ik-regelen.section';
import { MijnRegistratieSection } from './dashboard/mijn-registratie.section'; import { MijnRegistratieSection } from '@registratie/ui/dashboard/mijn-registratie.section';
import { SpecialismenSection } from './dashboard/specialismen.section'; import { SpecialismenSection } from '@registratie/ui/dashboard/specialismen.section';
import { WatWiltUDoenSection } from './dashboard/wat-wilt-u-doen.section'; import { WatWiltUDoenSection } from './wat-wilt-u-doen.section';
import { BeheerLinksSection } from './dashboard/beheer-links.section'; import { BeheerLinksSection } from './beheer-links.section';
/** Page: "Mijn overzicht" — the portal home, following the NL Design System "Mijn /** Page: "Mijn overzicht" — the portal home, following the NL Design System "Mijn
omgeving" pattern. Composition only: each section below answers its own data omgeving" pattern. Composition only: each section below answers its own data
question (own store, own async state) — see `ui/dashboard/*.section.ts`. */ question (own store, own async state) — four sections stay in `registratie/ui/dashboard/`
(they render registratie data), two live here (cross-context navigation). */
@Component({ @Component({
selector: 'app-dashboard-page', selector: 'app-overzicht-page',
imports: [ imports: [
PageShellComponent, PageShellComponent,
MijnAanvragenSection, MijnAanvragenSection,
@@ -39,4 +40,4 @@ import { BeheerLinksSection } from './dashboard/beheer-links.section';
</app-page-shell> </app-page-shell>
`, `,
}) })
export class DashboardPage {} export class OverzichtPage {}
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { WatWiltUDoenSection } from './wat-wilt-u-doen.section';
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
const meta: Meta<WatWiltUDoenSection> = {
title: 'Domein/Overzicht/Wat Wilt U Doen',
component: WatWiltUDoenSection,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
};
export default meta;
type Story = StoryObj<WatWiltUDoenSection>;
export const InschrijvingOpen: Story = {
decorators: [
applicationConfig({
providers: [{ provide: FeatureFlagStore, useValue: { enabled: () => true } }],
}),
],
};
export const InschrijvingDicht: Story = {
decorators: [
applicationConfig({
providers: [{ provide: FeatureFlagStore, useValue: { enabled: () => false } }],
}),
],
};
@@ -1,7 +1,7 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component'; import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { FeatureFlagStore } from '@shared/application/feature-flags.store'; import { FeatureFlagStore } from '@shared/application/feature-flags.store';
import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag'; import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag';
@@ -46,8 +46,8 @@ describe('AanvragenStore', () => {
expect(store.lastError()).toBeNull(); expect(store.lastError()).toBeNull();
}); });
// RB-20: a failed cancel must not be silent — the row rolls back AND the store // A failed cancel must not be silent — the row rolls back AND the store
// surfaces the error the page renders. Before RB-20 this only rolled back // surfaces the error the page renders. Before this fix it only rolled back
// (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever. // (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
it('rolls back the removal and surfaces the error when the cancel fails', async () => { it('rolls back the removal and surfaces the error when the cancel fails', async () => {
const cancel = vi.fn().mockRejectedValue(new Error('boom')); const cancel = vi.fn().mockRejectedValue(new Error('boom'));
@@ -14,7 +14,7 @@ type Err = Error | undefined;
* change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches * change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches
* so a page revisit reflects auto-approval (Concept → In behandeling → Goedgekeurd is * so a page revisit reflects auto-approval (Concept → In behandeling → Goedgekeurd is
* computed server-side on read). Cancel goes through `runSubmit` and rolls back plus * computed server-side on read). Cancel goes through `runSubmit` and rolls back plus
* surfaces `lastError` on failure (RB-20). * surfaces `lastError` on failure.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AanvragenStore { export class AanvragenStore {
@@ -23,7 +23,7 @@ export class AanvragenStore {
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' }); private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
readonly aanvragen = this.state.asReadonly(); readonly aanvragen = this.state.asReadonly();
/** Set on a failed cancel (RB-20): the optimistic removal already rolled back by /** Set on a failed cancel: the optimistic removal already rolled back by
then, this is only the message for the alert the page renders above the list. */ then, this is only the message for the alert the page renders above the list. */
private error = signal<string | null>(null); private error = signal<string | null>(null);
readonly lastError = this.error.asReadonly(); readonly lastError = this.error.asReadonly();
@@ -55,7 +55,7 @@ export class AanvragenStore {
/** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE. /** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE.
No resync — the delete succeeded, so the optimistic removal is authoritative. On No resync — the delete succeeded, so the optimistic removal is authoritative. On
failure, roll back AND surface the error (RB-20) — a silent reappearance leaves the failure, roll back AND surface the error — a silent reappearance leaves the
user guessing why the block came back. */ user guessing why the block came back. */
async cancel(id: string) { async cancel(id: string) {
const before = this.state(); const before = this.state();
@@ -44,8 +44,8 @@ describe('AdminCasesStore', () => {
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']); expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']);
}); });
// RB-20: a failed delete must not be silent — the row rolls back AND the store // A failed delete must not be silent — the row rolls back AND the store
// surfaces the error the page renders. Before RB-20 this only rolled back // surfaces the error the page renders. Before this fix it only rolled back
// (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever. // (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
it('rolls back the removal and surfaces the error when the delete fails', async () => { it('rolls back the removal and surfaces the error when the delete fails', async () => {
const deleteAny = vi.fn().mockRejectedValue(new Error('boom')); const deleteAny = vi.fn().mockRejectedValue(new Error('boom'));
@@ -7,11 +7,11 @@ import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aa
type Err = Error | undefined; type Err = Error | undefined;
/** /**
* Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office * Admin view of ALL cases across owners (`cases:manage`) — the back-office
* counterpart of the user-facing `AanvragenStore`. Same shape: one root singleton * counterpart of the user-facing `AanvragenStore`. Same shape: one root singleton
* owns the list as a writable RemoteData signal, delete removes the row synchronously * owns the list as a writable RemoteData signal, delete removes the row synchronously
* (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on * (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on
* failure (RB-20). Admin delete removes any case (any owner, submitted or not — the * failure. Admin delete removes any case (any owner, submitted or not — the
* server enforces the capability). * server enforces the capability).
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -21,7 +21,7 @@ export class AdminCasesStore {
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' }); private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
readonly cases = this.state.asReadonly(); readonly cases = this.state.asReadonly();
/** Set on a failed delete (RB-20): the optimistic removal already rolled back by /** Set on a failed delete: the optimistic removal already rolled back by
then, this is only the message for the alert the page renders above the list. */ then, this is only the message for the alert the page renders above the list. */
private error = signal<string | null>(null); private error = signal<string | null>(null);
readonly lastError = this.error.asReadonly(); readonly lastError = this.error.asReadonly();
@@ -47,7 +47,7 @@ export class AdminCasesStore {
} }
/** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error /** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error
AND surface it (RB-20) — a silent reappearance leaves the admin guessing why. */ AND surface it — a silent reappearance leaves the admin guessing why. */
async delete(id: string) { async delete(id: string) {
const before = this.state(); const before = this.state();
if (before.tag === 'Success') { if (before.tag === 'Success') {
@@ -52,10 +52,12 @@ export class BigProfileStore {
); );
/** Specialisms/notes stay a separate stream (they have their own empty state). */ /** Specialisms/notes stay a separate stream (they have their own empty state). */
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() => { readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() =>
const rd = fromResource(this.aantekeningenRes, (v) => !v || v.length === 0); map(
return rd.tag === 'Success' ? { tag: 'Success', value: rd.value ?? [] } : rd; fromResource(this.aantekeningenRes, (v) => !v || v.length === 0),
}); (v) => v ?? [],
),
);
// --- Optimistic herregistratie state, shared with the dashboard ----------- // --- Optimistic herregistratie state, shared with the dashboard -----------
private pending = signal(false); private pending = signal(false);
@@ -96,7 +96,7 @@ describe('createDraftSync', () => {
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
}); });
it('recovers from a create conflict by adopting the existing Concept (WP-35)', async () => { it('recovers from a create conflict by adopting the existing Concept', async () => {
// Server enforces one Concept per type: a stale/cross-tab create is rejected (409), // Server enforces one Concept per type: a stale/cross-tab create is rejected (409),
// and ensureId adopts the existing Concept from the list instead of erroring. // and ensureId adopts the existing Concept from the list instead of erroring.
const create = vi.fn().mockRejectedValue({ status: 409 }); const create = vi.fn().mockRejectedValue({ status: 409 });
@@ -63,7 +63,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
if (id) return id; if (id) return id;
ensuring ??= adapter ensuring ??= adapter
.create(deps.type) .create(deps.type)
// WP-35: one Concept per type is server-enforced. Within a tab the resumeGate // One Concept per type is server-enforced. Within a tab the resumeGate
// already prevents a second create, but a cross-tab/stale race can still hit the // already prevents a second create, but a cross-tab/stale race can still hit the
// server's guard (409) — recover by adopting the existing Concept instead of // server's guard (409) — recover by adopting the existing Concept instead of
// erroring. Only recover when one actually exists; otherwise surface the failure. // erroring. Only recover when one actually exists; otherwise surface the failure.
@@ -3,7 +3,7 @@ import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aa
/** /**
* Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs * Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs
* before it can start writing (RB-21 / CQ-001). Free functions that take the adapter * before it can start writing (CQ-001). Free functions that take the adapter
* as a parameter, not `inject()`, so they get a direct spec without Angular TestBed. * as a parameter, not `inject()`, so they get a direct spec without Angular TestBed.
* `createDraftSync` keeps the closure state (`id`, `resumeGate`) and the write path; * `createDraftSync` keeps the closure state (`id`, `resumeGate`) and the write path;
* these two functions only read. * these two functions only read.
@@ -10,8 +10,8 @@
*/ */
export type AanvraagType = 'registratie' | 'herregistratie' | 'intake'; export type AanvraagType = 'registratie' | 'herregistratie' | 'intake';
// Ingediend/MeerInfoGevraagd (ADR-0002/WP-63) are widened into the union so the parse // Ingediend/MeerInfoGevraagd (ADR-0002) are widened into the union so the parse
// boundary + renderers are ready, but no backend path emits them yet — that's WP-65's // boundary + renderers are ready, but no backend path emits them yet — that's the
// behandelaar-facing transition endpoint. // behandelaar-facing transition endpoint.
export type AanvraagStatus = export type AanvraagStatus =
| { tag: 'Concept'; stepIndex: number; stepCount: number } | { tag: 'Concept'; stepIndex: number; stepCount: number }
@@ -29,7 +29,7 @@ export interface Aanvraag {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
submittedAt?: string; submittedAt?: string;
/** The case owner (a BSN). Only populated by the admin cross-owner list (WP-36); /** The case owner (a BSN). Only populated by the admin cross-owner list;
the user's own list leaves it undefined. */ the user's own list leaves it undefined. */
owner?: string; owner?: string;
} }
@@ -5,7 +5,7 @@ import {
} from '@registratie/domain/value-objects/telefoonnummer'; } from '@registratie/domain/value-objects/telefoonnummer';
/** What the user is typing (raw, possibly invalid). The BRP address is NOT part of /** What the user is typing (raw, possibly invalid). The BRP address is NOT part of
the form — it is authoritative and shown read-only (WP-34); only the phone number the form — it is authoritative and shown read-only; only the phone number
is editable here. */ is editable here. */
export interface Draft { export interface Draft {
telefoon: string; telefoon: string;
@@ -17,6 +17,7 @@ import {
setField, setField,
prefillAdres, prefillAdres,
submit, submit,
primary,
resolve, resolve,
reduce, reduce,
} from './registratie-wizard.machine'; } from './registratie-wizard.machine';
@@ -248,6 +249,26 @@ describe('submit', () => {
}); });
}); });
describe('primary', () => {
it('Primary advances to the next step from a non-final step', () => {
const s = toBeroepStepWithDiploma(); // Invullen, beroep step — not the final step
expect(currentStep(expectTag(s, 'Invullen'))).toBe('beroep');
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Next' }));
expect(currentStep(expectTag(primary(s), 'Invullen'))).toBe('controle');
});
it('Primary submits from the final step', () => {
const s = toControleStep(); // Invullen, controle step — the final step
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Submit' }));
expect(primary(s).tag).toBe('Indienen');
});
it('Primary is a no-op from a non-editing state', () => {
const indienen = toIndienen();
expect(primary(indienen)).toBe(indienen);
});
});
describe('reduce (message-driven happy path)', () => { describe('reduce (message-driven happy path)', () => {
it('adres and correspondentie set, Next advances from adres to beroep', () => { it('adres and correspondentie set, Next advances from adres to beroep', () => {
// Given the initial wizard. // Given the initial wizard.
@@ -9,6 +9,7 @@ import {
reduceUpload, reduceUpload,
requiredCategoriesSatisfied, requiredCategoriesSatisfied,
deliveryRefs, deliveryRefs,
digitalDocumentIds,
} from '@shared/domain/upload.machine'; } from '@shared/domain/upload.machine';
/** /**
@@ -66,7 +67,7 @@ export type DraftField = 'straat' | 'postcode' | 'woonplaats' | 'email';
/** Per-field error map. `antwoorden` holds per-policy-question errors, keyed by /** Per-field error map. `antwoorden` holds per-policy-question errors, keyed by
question id (a step can show several questions). */ question id (a step can show several questions). */
export interface Errors { export type Errors = {
straat?: string; straat?: string;
postcode?: string; postcode?: string;
woonplaats?: string; woonplaats?: string;
@@ -75,7 +76,7 @@ export interface Errors {
diploma?: string; diploma?: string;
documenten?: string; documenten?: string;
antwoorden?: Record<string, string>; antwoorden?: Record<string, string>;
} };
export type RegistratieState = export type RegistratieState =
| { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors; upload: UploadState } | { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors; upload: UploadState }
@@ -109,7 +110,7 @@ export function hasProgress(s: Extract<RegistratieState, { tag: 'Invullen' }>):
!!d.email || !!d.email ||
!!d.diplomaId || !!d.diplomaId ||
!!d.beroep || !!d.beroep ||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId) digitalDocumentIds(s.upload).length > 0
); );
} }
@@ -287,6 +288,13 @@ export function submit(s: RegistratieState): RegistratieState {
return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error }; return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error };
} }
/** The primary button's action: advance, or submit from the controle step.
No-op outside Invullen — this is the one decision the old component-side handler used to make. */
export function primary(s: RegistratieState): RegistratieState {
if (s.tag !== 'Invullen') return s;
return currentStep(s) === 'controle' ? submit(s) : next(s);
}
/** Route an upload sub-message through the pure upload reducer (Invullen only). */ /** Route an upload sub-message through the pure upload reducer (Invullen only). */
export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState { export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState {
if (s.tag !== 'Invullen') return s; if (s.tag !== 'Invullen') return s;
@@ -312,6 +320,7 @@ export type RegistratieMsg =
| { tag: 'Back' } | { tag: 'Back' }
| { tag: 'GaNaarStap'; cursor: number } | { tag: 'GaNaarStap'; cursor: number }
| { tag: 'Submit' } | { tag: 'Submit' }
| { tag: 'Primary' }
| { tag: 'Retry' } | { tag: 'Retry' }
| { tag: 'SubmitConfirmed'; referentie: string } | { tag: 'SubmitConfirmed'; referentie: string }
| { tag: 'SubmitFailed'; error: string } | { tag: 'SubmitFailed'; error: string }
@@ -342,6 +351,8 @@ export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState
return gaNaarStap(s, m.cursor); return gaNaarStap(s, m.cursor);
case 'Submit': case 'Submit':
return submit(s); return submit(s);
case 'Primary':
return primary(s);
case 'Retry': case 'Retry':
return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s; return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s;
case 'SubmitConfirmed': case 'SubmitConfirmed':
@@ -32,12 +32,12 @@ export class AanvragenAdapter {
return this.client.aanvragenAll(); return this.client.aanvragenAll();
} }
/** Admin: every case across all owners (WP-36; `cases:manage`). Parsed at the boundary. */ /** Admin: every case across all owners (`cases:manage`). Parsed at the boundary. */
listAll(): Promise<AanvraagSummaryDto[]> { listAll(): Promise<AanvraagSummaryDto[]> {
return this.client.casesAll(); return this.client.casesAll();
} }
/** Admin: delete ANY case (any owner, submitted or not — WP-36). */ /** Admin: delete ANY case (any owner, submitted or not). */
deleteAny(id: string): Promise<void> { deleteAny(id: string): Promise<void> {
return this.client.cases(id); return this.client.cases(id);
} }
@@ -117,7 +117,7 @@ function parseCommon(dto: AanvraagSummaryDto): Result<string, Aanvraag> {
createdAt: dto.createdAt, createdAt: dto.createdAt,
updatedAt: dto.updatedAt, updatedAt: dto.updatedAt,
submittedAt: dto.submittedAt, submittedAt: dto.submittedAt,
owner: dto.owner, // only present on the admin cross-owner list (WP-36) owner: dto.owner, // only present on the admin cross-owner list
}); });
} }
@@ -6,7 +6,7 @@ import { Valid } from '@registratie/domain/change-request.machine';
* Infrastructure adapter for the telefoonwijziging POST (`/api/v1/change-requests`) — * Infrastructure adapter for the telefoonwijziging POST (`/api/v1/change-requests`) —
* the single place the network client lives for contact changes, so the command * the single place the network client lives for contact changes, so the command
* and the UI never touch `ApiClient`. The BRP address is authoritative and not * and the UI never touch `ApiClient`. The BRP address is authoritative and not
* submitted (WP-34); only the phone number is. Returns the server reference; the * submitted; only the phone number is. Returns the server reference; the
* server re-validates and is the authority. * server re-validates and is the authority.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -1,7 +1,7 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { formatDatumNl } from '@shared/kernel/datum'; import { formatDatumNl } from '@shared/kernel/datum';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { Aanvraag } from '@registratie/domain/aanvraag'; import { Aanvraag } from '@registratie/domain/aanvraag';
import { TYPE_LABELS } from '@registratie/domain/aanvraag-view'; import { TYPE_LABELS } from '@registratie/domain/aanvraag-view';
import { blockActions } from '@registratie/domain/block-actions'; import { blockActions } from '@registratie/domain/block-actions';
@@ -2,11 +2,11 @@ import { Component, computed, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data'; import { successOf } from '@shared/application/remote-data';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AanvragenStore } from '@registratie/application/aanvragen.store'; import { AanvragenStore } from '@registratie/application/aanvragen.store';
import { Aanvraag } from '@registratie/domain/aanvraag'; import { Aanvraag } from '@registratie/domain/aanvraag';
import { detailRows } from '@registratie/domain/aanvraag-view'; import { detailRows } from '@registratie/domain/aanvraag-view';
@@ -1,7 +1,7 @@
import { Component, input, output } from '@angular/core'; import { Component, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
export interface AdresValue { export interface AdresValue {
straat: string; straat: string;
@@ -1,18 +1,19 @@
import { Component, computed, effect, inject } from '@angular/core'; import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store'; import { AccessStore } from '@shared/application/access.store';
import { successOr } from '@shared/application/remote-data';
import { formatDatumNl } from '@shared/kernel/datum'; import { formatDatumNl } from '@shared/kernel/datum';
import { Aanvraag } from '@registratie/domain/aanvraag'; import { Aanvraag } from '@registratie/domain/aanvraag';
import { TYPE_LABELS, statusLabel, referentie } from '@registratie/domain/aanvraag-view'; import { TYPE_LABELS, statusLabel, referentie } from '@registratie/domain/aanvraag-view';
import { AdminCasesStore } from '@registratie/application/admin-cases.store'; import { AdminCasesStore } from '@registratie/application/admin-cases.store';
/** /**
* Admin page: every case across all owners, with an admin delete (WP-36). Lives in * Admin page: every case across all owners, with an admin delete. Lives in
* `registratie` (which owns the Aanvraag aggregate) — the back-office counterpart of the * `registratie` (which owns the Aanvraag aggregate) — the back-office counterpart of the
* user's dashboard, reusing the same view labels + trust-boundary parse. Deny-by-default * user's dashboard, reusing the same view labels + trust-boundary parse. Deny-by-default
* capability gate (`cases:manage`): a denial alert for non-admins, the list for admins. * capability gate (`cases:manage`): a denial alert for non-admins, the list for admins.
@@ -78,10 +79,7 @@ export class AdminCasesPage {
protected access = inject(AccessStore); protected access = inject(AccessStore);
protected canManage = computed(() => this.access.can('cases:manage')); protected canManage = computed(() => this.access.can('cases:manage'));
protected cases = computed(() => { protected cases = computed(() => successOr(this.store.cases(), []));
const rd = this.store.cases();
return rd.tag === 'Success' ? rd.value : [];
});
protected heading = $localize`:@@adminCases.heading:Aanvragen beheren`; protected heading = $localize`:@@adminCases.heading:Aanvragen beheren`;
protected intro = $localize`:@@adminCases.intro:Alle aanvragen in het register. Een aanvraag verwijderen kan niet ongedaan worden gemaakt.`; protected intro = $localize`:@@adminCases.intro:Alle aanvragen in het register. Een aanvraag verwijderen kan niet ongedaan worden gemaakt.`;
@@ -111,7 +109,7 @@ export class AdminCasesPage {
private loadRequested = false; private loadRequested = false;
constructor() { constructor() {
// Load once the capability resolves to allowed (a 403 GET would be wasted otherwise). // Load once the capability resolves to allowed (a 403 GET would be wasted otherwise).
// Depends only on canManage() + a plain flag — never the store model (WP-26 loop lesson). // Depends only on canManage() + a plain flag — never the store model (the loop lesson).
effect(() => { effect(() => {
if (this.canManage() && !this.loadRequested) { if (this.canManage() && !this.loadRequested) {
this.loadRequested = true; this.loadRequested = true;
@@ -1,12 +1,12 @@
import { Component, computed, input } from '@angular/core'; import { Component, computed, input } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { Adres } from '@registratie/domain/person'; import { Adres } from '@registratie/domain/person';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp'; import { whenTag } from '@shared/kernel/fp';
@@ -20,7 +20,7 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang
/** /**
* Organism: contact-change (telefoonwijziging) form. The BRP address is authoritative * Organism: contact-change (telefoonwijziging) form. The BRP address is authoritative
* and shown READ-ONLY (WP-34) — you change your address at the gemeente, not here — so * and shown READ-ONLY — you change your address at the gemeente, not here — so
* only the phone number is editable. Uses the SAME idiom as the wizards: all state in * only the phone number is editable. Uses the SAME idiom as the wizards: all state in
* one signal driven by the pure `reduce` (change-request.machine.ts), submitted via a * one signal driven by the pure `reduce` (change-request.machine.ts), submitted via a
* `submit-*` command returning `Result`. The server re-validates. * `submit-*` command returning `Result`. The server re-validates.
@@ -65,6 +65,26 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang
>Nieuwe wijziging doorgeven</app-button >Nieuwe wijziging doorgeven</app-button
> >
</div> </div>
} @else if (state().tag === 'Failed') {
<app-heading [level]="2" i18n="@@changeRequest.heading">Contactgegevens wijzigen</app-heading>
<app-alert type="error"
><ng-container i18n="@@changeRequest.failed">Het indienen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
<app-data-block class="app-section" [ariaLabel]="telefoonLabelText">
<div app-data-row [key]="telefoonLabelText" [value]="telefoon()"></div>
</app-data-block>
<div class="app-section">
<app-button
variant="secondary"
(click)="dispatch({ tag: 'Retry' })"
i18n="@@wizard.opnieuwProberen"
>Opnieuw proberen</app-button
>
</div>
} @else { } @else {
<app-heading [level]="2" i18n="@@changeRequest.heading">Contactgegevens wijzigen</app-heading> <app-heading [level]="2" i18n="@@changeRequest.heading">Contactgegevens wijzigen</app-heading>
@@ -108,13 +128,6 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang
</app-form-field> </app-form-field>
</fieldset> </fieldset>
@if (failedError()) {
<app-alert type="error"
><ng-container i18n="@@changeRequest.failed">Het indienen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
}
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'"> <app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
{{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }} {{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}
</app-button> </app-button>
@@ -127,7 +140,15 @@ export class ChangeRequestFormComponent {
// adapter); the UI holds only this bound command. Field initializer = injection // adapter); the UI holds only this bound command. Field initializer = injection
// context, like createStore below. // context, like createStore below.
private submit = createSubmitChangeRequest(); private submit = createSubmitChangeRequest();
private store = createStore<ChangeRequestState, ChangeRequestMsg>(initial, reduce); // Effect fires once, on Editing -> Submitting (RD-05's tag-transition rule; `Seed` is
// exempt, so a story mounting straight into `Submitting` does not call the network).
private store = createStore<ChangeRequestState, ChangeRequestMsg>(initial, reduce, {
Submitting: async (s, store) => {
const r = await this.submit(s.data);
if (r.ok) store.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
else store.dispatch({ tag: 'SubmitFailed', error: r.error });
},
});
/** BRP address, shown read-only. Undefined until the profile loads. */ /** BRP address, shown read-only. Undefined until the profile loads. */
brpAdres = input<Adres | undefined>(undefined); brpAdres = input<Adres | undefined>(undefined);
@@ -148,6 +169,10 @@ export class ChangeRequestFormComponent {
protected readonly postcodeLabel = $localize`:@@address.postcode:Postcode`; protected readonly postcodeLabel = $localize`:@@address.postcode:Postcode`;
protected readonly woonplaatsLabel = $localize`:@@address.woonplaats:Woonplaats`; protected readonly woonplaatsLabel = $localize`:@@address.woonplaats:Woonplaats`;
// Same id as the telefoon form-field's label above, reused for the Failed
// data-block's row key.
protected readonly telefoonLabelText = $localize`:@@changeRequest.telefoonLabel:Telefoonnummer`;
private editing = computed(() => whenTag(this.state(), 'Editing')); private editing = computed(() => whenTag(this.state(), 'Editing'));
protected errors = computed(() => this.editing()?.errors ?? {}); protected errors = computed(() => this.editing()?.errors ?? {});
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
@@ -168,15 +193,5 @@ export class ChangeRequestFormComponent {
onSubmit() { onSubmit() {
this.dispatch({ tag: 'Submit' }); this.dispatch({ tag: 'Submit' });
this.runIfSubmitting();
}
/** Effect: when we entered Submitting, call the command, then dispatch the outcome. */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
const r = await this.submit(s.data);
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
} }
} }
@@ -34,7 +34,7 @@ function storeStub(aanvragen: RemoteData<Error | undefined, Aanvraag[]>, lastErr
} }
const meta: Meta<MijnAanvragenSection> = { const meta: Meta<MijnAanvragenSection> = {
title: 'Domein/Registratie/Dashboard/Mijn Aanvragen', title: 'Domein/Registratie/Mijn Aanvragen',
component: MijnAanvragenSection, component: MijnAanvragenSection,
decorators: [applicationConfig({ providers: [provideRouter([])] })], decorators: [applicationConfig({ providers: [provideRouter([])] })],
}; };
@@ -1,11 +1,12 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component'; import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { successOr } from '@shared/application/remote-data';
import { AanvragenStore } from '@registratie/application/aanvragen.store'; import { AanvragenStore } from '@registratie/application/aanvragen.store';
import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag'; import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag';
import { import {
@@ -88,10 +89,9 @@ export class MijnAanvragenSection {
protected submittedRow = submittedRow; protected submittedRow = submittedRow;
protected aanvragen = computed<Aanvraag[]>(() => { protected aanvragen = computed<Aanvraag[]>(() =>
const rd = this.store.aanvragen(); sortForDashboard(successOr(this.store.aanvragen(), [])),
return rd.tag === 'Success' ? sortForDashboard(rd.value) : []; );
});
protected concepten_ = computed(() => concepten(this.aanvragen())); protected concepten_ = computed(() => concepten(this.aanvragen()));
protected ingediend_ = computed(() => ingediend(this.aanvragen())); protected ingediend_ = computed(() => ingediend(this.aanvragen()));
@@ -28,7 +28,7 @@ function storeStub(profileRd: RemoteData<Error | undefined, BigProfile>) {
} }
const meta: Meta<MijnRegistratieSection> = { const meta: Meta<MijnRegistratieSection> = {
title: 'Domein/Registratie/Dashboard/Mijn Registratie', title: 'Domein/Registratie/Mijn Registratie',
component: MijnRegistratieSection, component: MijnRegistratieSection,
}; };
export default meta; export default meta;
@@ -1,10 +1,10 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data'; import { successOf } from '@shared/application/remote-data';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component'; import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
@@ -17,7 +17,7 @@ function storeStub(aantekeningen: RemoteData<Error | undefined, Aantekening[]>)
} }
const meta: Meta<SpecialismenSection> = { const meta: Meta<SpecialismenSection> = {
title: 'Domein/Registratie/Dashboard/Specialismen', title: 'Domein/Registratie/Specialismen',
component: SpecialismenSection, component: SpecialismenSection,
}; };
export default meta; export default meta;
@@ -1,8 +1,8 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data'; import { successOf } from '@shared/application/remote-data';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component'; import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';
@@ -0,0 +1,109 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { WatMoetIkRegelenSection } from './wat-moet-ik-regelen.section';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { BigProfile } from '@registratie/domain/big-profile';
import { HerregistratieDecisions } from '@registratie/domain/registration';
import { RemoteData } from '@shared/application/remote-data';
import { loading, success } from '@shared/testing/remote-data';
const profile: BigProfile = {
registration: {
bigNummer: '19012345601',
naam: 'Dr. A. (Anna) de Vries',
beroep: 'Arts',
registratiedatum: '2012-09-01',
geboortedatum: '1985-03-14',
status: { tag: 'Geregistreerd', herregistratieDatum: '2027-09-01' },
},
person: {
naam: 'Dr. A. (Anna) de Vries',
geboortedatum: '1985-03-14',
adres: { straat: 'Rijksweg 1', postcode: '2514 EA', woonplaats: 'Den Haag' },
},
};
/** Minimal store stand-in — only the members the section's template and class read. */
function storeStub(
profileRd: RemoteData<Error | undefined, BigProfile>,
decisionsRd: RemoteData<Error | undefined, HerregistratieDecisions>,
pendingHerregistratie: boolean,
) {
return {
profile: () => profileRd,
decisions: () => decisionsRd,
pendingHerregistratie: () => pendingHerregistratie,
reloadProfile: () => {},
};
}
const meta: Meta<WatMoetIkRegelenSection> = {
title: 'Domein/Registratie/Wat Moet Ik Regelen',
component: WatMoetIkRegelenSection,
};
export default meta;
type Story = StoryObj<WatMoetIkRegelenSection>;
export const Loading: Story = {
decorators: [
applicationConfig({
providers: [{ provide: BigProfileStore, useValue: storeStub(loading(), loading(), false) }],
}),
],
};
export const MetTaken: Story = {
decorators: [
applicationConfig({
providers: [
{
provide: BigProfileStore,
useValue: storeStub(
success(profile),
success({ eligibleForHerregistratie: true }),
false,
),
},
],
}),
],
parameters: {
// Structural: app-choice-link's host sits between the keuzelijst <ul> and its <li>
// — axe's list/listitem rule needs them adjacent regardless of `display:contents`.
// Same pre-existing gap as task-list.stories.ts and choice-list.stories.ts. WP-11
// (CIBG markup fidelity) reworks this markup; see
// docs/project/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
};
export const NietsOpenstaand: Story = {
decorators: [
applicationConfig({
providers: [
{
provide: BigProfileStore,
useValue: storeStub(
success(profile),
success({ eligibleForHerregistratie: false }),
false,
),
},
],
}),
],
};
export const InBehandeling: Story = {
decorators: [
applicationConfig({
providers: [
{
provide: BigProfileStore,
useValue: storeStub(
success(profile),
success({ eligibleForHerregistratie: false }),
true,
),
},
],
}),
],
};

Some files were not shown because too many files have changed in this diff Show More