Add registratie wizard, BFF dashboard-view, contracts/value-objects, and architecture docs

Checkpoint of in-progress work: the registration wizard (address prefill,
DUO diploma lookup, policy questions), decision-DTO contracts, parse-don't-
validate value objects, infrastructure adapters, plus CLAUDE.md and the
architecture/ADR docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 17:23:52 +02:00
parent 8a8a2f0f29
commit 64385999eb
58 changed files with 7271 additions and 556 deletions

View File

@@ -14,6 +14,11 @@ const preview: Preview = {
],
parameters: {
layout: 'padded',
// Accessibility (axe) via @storybook/addon-a11y. Runs WCAG 2.0/2.1 A+AA rule
// sets on every story; flag violations in the a11y panel.
a11y: {
config: { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] } },
},
controls: {
matchers: {
color: /(background|color)$/i,

123
CLAUDE.md Normal file
View File

@@ -0,0 +1,123 @@
# CLAUDE.md
Agent guide for this repo. The *why* lives in `docs/ARCHITECTURE.md` and
`docs/architecture/0001-bff-lite-decision-dtos.md`; this file is the *rules*.
When a decision below and those docs disagree, the docs win — update this file.
POC of a Dutch BIG-register self-service portal (healthcare professionals log in,
view their registration, apply for re-registration). Angular 22, standalone,
signals. No real backend/auth — static mock JSON + fake timers.
## Commands
```bash
npm start # ng serve → http://localhost:4200
npm test # vitest
npm run build # ng build (must stay green)
npm run storybook # component library by atomic layer
```
`.npmrc` sets `legacy-peer-deps=true` (Storybook's peer range lags Angular 22).
Do not run `npm audit fix --force` — it downgrades Angular 22→21. Dev-only
advisories are pinned via `package.json` `overrides`; the shipped bundle audits clean.
## The decisions (non-negotiable working agreements)
### 1. DDD: contexts then layers, dependencies point inward
`src/app/<context>/<layer>/`. Contexts: `shared`, `auth`, `registratie`,
`herregistratie`, `showcase` (teaching page, not a feature).
| Layer | Job | Angular allowed? |
|---|---|---|
| `domain/` | business rules + data types | **No — pure TS.** Has `.spec.ts` |
| `application/` | coordinate state/tasks (stores, commands) | yes (signals) |
| `infrastructure/` | where data comes from (HTTP adapters) | yes (HTTP) |
| `contracts/` | wire DTOs (the FE⇄BE seam) | no |
| `ui/` | how it looks (components, pages) | yes |
**Dependencies only point inward**: `ui → application → domain`; everyone may use
`shared`; never the reverse. Cross-context only `herregistratie → registratie → shared`,
`auth → shared`. Imports use aliases as direction statements: `@shared/* @auth/*
@registratie/* @herregistratie/*`. `domain/` imports nothing from Angular.
### 2. Atomic design: folder = layer
`shared/ui` atoms → molecules → organisms; `shared/layout` templates (`shell`,
`page-shell`); context `ui/` pages. Each level only uses levels below. A new page
should be **composition of existing blocks** — adding building blocks is the
exception, not the default. Atoms are thin wrappers over Utrecht/RHC CSS classes;
we own only a small typed `input()` API, the design system does the visuals.
### 3. State: make illegal states unrepresentable
Default reflex — **if you're about to add a second/third boolean to track state,
model a discriminated union instead.** Three tools, all in `shared/application`:
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`.
Combine sources with `map`/`map2`/`map3`/`andThen` (Failure > Loading > Success).
Render it via the `<app-async>` molecule (`shared/ui/async`) — one of four
templates, mutually exclusive by construction. Default loading spinner/skeleton
is delay-gated (~250ms) so fast connections don't flash.
- **Elm-style store** (`store.ts``createStore(initial, reduce)`) — all state in
one Model; change only by `dispatch(msg)`**pure** `reduce(model, msg)`. Models
are tagged unions (see `herregistratie.machine.ts`, `intake.machine.ts`). Templates
send messages, never mutate.
- **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a
branded type only via a parser returning `Result` (`registratie/domain/value-objects/`:
`Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it.
**Derive, don't store** what you can compute — e.g. the wizard's visible steps are
`visibleSteps(answers)`, not a stored field (`intake.machine.ts`).
**Side effects stay out of the reducer.** A *command* (`application/submit-*.ts`)
does the HTTP, then dispatches a message describing the outcome. Reducer = "what the
new state is"; command = "go do it, then say what happened."
**Shared cross-page state = one root singleton.** Stores are `providedIn: 'root'`
(`BigProfileStore`, `SessionStore`). That single instance is the shared state — no
NgRx, no extra lib. Optimistic update pattern: `begin*` (flip pending) →
`confirm*` (clear + `resource.reload()`) / `rollback*` (undo).
### 4. BFF-lite + decision DTOs (ADR-0001)
`infrastructure/` is the **only** layer that touches the network — the
anti-corruption boundary. Each screen gets one screen-shaped endpoint returning a
decision-enriched DTO; **the FE renders decisions, it does not recompute business
rules.** Per rule, pick: *decision flag* (server computes the boolean — e.g.
herregistratie eligibility) or *config value* (server sends threshold, FE applies
for instant feedback, server re-validates as authority — e.g. scholing threshold).
FE keeps only **format** validation, never as authority.
DTO lives in `contracts/`; a hand-written `parse*`/`toDomain` in `infrastructure/`
validates the untrusted shape and maps DTO → domain. Wiring a real .NET backend
touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-owned
rules stay in `domain/*.policy.ts` as reference impl + unit test, marked server-owned,
but the FE doesn't call them.
### 5. Testing
Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters).
Test the pure function directly — no Angular TestBed for domain. UI is exercised via
Storybook stories (`*.stories.ts` co-located, titled `Layer/Name`, a11y addon on),
not heavy component tests.
## Conventions
- Standalone components only; no NgModules. Signal inputs (`input()`), `inject()` over
constructor DI (constructor only for `effect()`/template-ref injection).
- Angular-native control flow `@if/@for`; native `httpResource` for fetching;
`withViewTransitions()` for page transitions (header/footer have stable
`view-transition-name`, excluded from the fade).
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate:
[authGuard]` on protected routes (`app.routes.ts`).
- Theming is one import — `src/styles.scss` pulls the RHC palette; no hand-written theme.
- Scenario toggle: `?scenario=slow|loading|empty|error` on data pages
(`scenario.interceptor.ts`) to see every async state.
- Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`,
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
## Adding a feature (recipe)
Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter:
`httpResource` or command returning `Result`) → application (store if shared state;
union + pure reduce) → UI last (compose `shared/ui` atoms, wrap async in `<app-async>`,
dispatch messages). Worked example: the intake wizard (`herregistratie/`).
## Out of scope (POC, don't build unprompted)
Real auth/DigiD, real backend, i18n, NgRx, licensed Rijkshuisstijl font/logo,
runtime DTO validation on every endpoint, multi-tab session sync, OpenAPI codegen.

View File

@@ -0,0 +1,122 @@
# ADR 0001 — Frontend⇄backend: BFF-lite endpoints + decision DTOs
Status: Accepted · Date: 2026-06-26
## Problem
The frontend makes many separate calls and aggregates them itself, and business
rules are hardwired in the client. Two concrete symptoms:
- The dashboard stitched **three** independent `httpResource`s (BIG-register
registration, BRP person, notes) together client-side. Each could be
loading/erroring independently → inconsistent snapshots ("state out of sync").
- Policy was duplicated on the client: the scholing threshold (`1000`) and the
herregistratie eligibility window (`12` months) lived in frontend code. If the
backend changes a rule, the UI silently diverges — bad for governance.
Goal: **unify FE/BE policy, cut the number of calls, and make the rules
transparent/auditable — without coupling the two sides too tightly.** We own the
backend team.
## Options considered
| Option | Fewer calls? | Unifies policy? | Cost |
|---|---|---|---|
| 1. Status quo (client calls upstreams, aggregates, owns rules) | No | No | — |
| 2. Unified client API layer (one facade in the FE) | No — still N round-trips | No — rules still on client | Low, but misses the goal |
| **3. Screen-shaped endpoints on our own backend ("BFF-lite")** | **Yes** — 1 call/screen | **Yes** — server computes decisions | Lowmedium |
| 4. Separately-deployed BFF service | Yes | Yes | Medium — another deployable |
| 5. GraphQL gateway | Yes (client picks fields) | **No, not by itself** — still need resolvers to own rules | Mediumhigh; new infra |
GraphQL solves over/under-fetching but does not, on its own, move rules
server-side — and our problem is policy unification + drift, not field-selection
flexibility. Option 4 is option 3 with a deployment boundary added.
## Decision
**Screen-shaped ("BFF-lite") endpoints that return decision-enriched DTOs, defined
by a single shared contract. The frontend renders decisions; it does not recompute
them.** Keep it minimal: implement BFF-shaped endpoints on the backend we already
own. Promote to a separately-deployed BFF service only when a second consumer
(mobile/partner) or a team boundary demands it — not before.
### Why DTOs *decouple* rather than couple
The coupling people fear comes from **not** having DTOs — i.e. serializing internal
DB/domain entities straight onto the wire, so every schema change ripples to the
client. A DTO is the decoupling seam:
```
DB entity / domain model → DTO (the wire contract) → FE view model
(backend's own) (the agreed contract) (frontend's own)
```
Each side keeps its own internal model and refactors freely; only the DTO is a
deliberate, versioned change. The one coupling that remains — both sides agreeing
on the contract — is the *wanted*, reviewable seam. Manage it with **one source of
truth** (OpenAPI or TypeSpec) that **generates types for both sides**. That spec is
the governance/transparency artifact.
### Two shapes of "policy over the wire" — pick per rule
- **Config value** — for simple thresholds. Server sends the value; the FE applies
it for instant feedback; **the backend re-validates on submit as the authority.**
Example here: the scholing threshold.
- **Decision flag** — for anything non-trivial/sensitive. Server computes the
boolean (optionally with a `reason`); the FE just renders it. Example here:
herregistratie eligibility.
The frontend keeps only **format** validation (postcode shape, integer parsing) for
instant feedback — never as the authority.
## Worked example in this POC
This POC has no real backend (static mock JSON + fake submit timers), so the
"BFF output" is a static file; the `decisions` block stands in for what the backend
would compute. Two slices were implemented to demonstrate **both** policy shapes:
**A. Dashboard profile → one aggregated, decision-enriched call (decision-flag).**
- Contract: `src/app/registratie/contracts/dashboard-view.dto.ts`
(`DashboardViewDto` = registration + person + `decisions`).
- Endpoint: `public/mock/dashboard-view.json` (one call replaces three).
- Boundary parse: `parseDashboardView()` in
`src/app/registratie/infrastructure/dashboard-view.adapter.ts` validates the
untrusted shape and maps DTO → domain (hand-written; no schema lib for one
contract).
- `BigProfileStore` now derives `profile` and `decisions` from the single
validated view (was a 3-resource `map2`). One request → one consistent snapshot.
- `herregistratie.page.ts` reads `decisions.eligibleForHerregistratie` instead of
calling `isHerregistratieEligible()`. That rule is now marked server-owned in
`registration.policy.ts` (kept as reference impl + unit test; FE no longer calls it).
- The unused upstream adapters/mocks (`brp.adapter.ts`, `registration.json`,
`brp.json`) were deleted — those calls live behind the BFF now.
**B. Intake scholing threshold → config value.**
- Contract: `src/app/herregistratie/contracts/intake-policy.dto.ts`.
- Endpoint: `public/mock/intake-policy.json` (`{ "scholingThreshold": 1000 }`).
- `intake.machine.ts`: the hardcoded `LAGE_UREN_DREMPEL` constant is gone;
`lageUren(a, scholingThreshold)` and validation take the value, which lives in
machine state and is set via a `SetPolicy` message. A `SCHOLING_THRESHOLD_DEFAULT`
remains only as the offline fallback.
- `intake-wizard.component.ts` fetches the policy and dispatches `SetPolicy`.
## Migration sequence (for the real app)
1. Define the contract in OpenAPI/TypeSpec; generate types for FE and BE.
2. Stand up screen-shaped endpoints on the existing backend that aggregate the
upstreams and compute `decisions`.
3. Point each screen at its single endpoint; delete client-side aggregation.
4. Move each hardwired rule server-side; expose as decision flag or config value.
5. Reduce the FE to format-validation + rendering.
## Out of scope here (next steps, not built in the worked example)
- Runtime DTO validation on **every** endpoint (only the dashboard view has it).
- Optimistic-update race fix in `BigProfileStore`
(`beginHerregistratie`/`rollbackHerregistratie` can leave `pending` wrong under
concurrent submits).
- Session persistence / multi-tab sync (`SessionStore` is in-memory).
- Real OpenAPI/TypeSpec codegen toolchain.
ponytail: build the pattern once on one slice; copy it across screens when the real
backend lands, rather than scaffolding all of it up front.

112
docs/ui-ux-audit.md Normal file
View File

@@ -0,0 +1,112 @@
# UI/UX audit — BIG-register wizard (Rijkshuisstijl alignment)
Phase A deliverable. Audits the registration wizard and the shared atoms/chrome it
relies on against the project's design system (`@rijkshuisstijl-community`, the NL Design
System Rijkshuisstijl theme — the canonical system here; CIBG's own DS is not installed)
and WCAG 2.1 AA. Each finding maps to the token / component / pattern that resolves it.
This is an **alignment, not a redesign**: fixes go through the token/theme layer and
existing components; no new libraries, no restricted Rijksoverheid assets.
Scope: registratie wizard + shared atoms/chrome (intake/herregistratie inherit the shared
fixes). Their own step copy/inline styles are out of scope.
Priority: **H** = blocks "professional/accessible" bar (a11y or broken token); **M** =
visible inconsistency; **L** = polish.
---
## 1. Tokens vs. hardcoded values
The app imports RHC design tokens but the wizard/chrome were written with **raw inline
values** (≈48 in wizard+chrome, ≈16 in atoms). RHC exposes a full token set we should map
onto: spacing `--rhc-space-max-{sm..5xl}`, type `--rhc-text-font-size-*` /
`--rhc-text-font-weight-*`, color `--rhc-color-foreground-{default,subtle}` /
`--rhc-color-{lintblauw,donkerblauw,cool-grey}-*` / `--rhc-color-wit`, radius
`--rhc-border-radius-*`.
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 1.1 | **H** | `var(--rhc-color-grijs-700)` is referenced in 3 wizards but **`--rhc-color-grijs-*` does not exist** in the package → the "Stap X van Y" text falls back to the default color, not grey. | `--rhc-color-foreground-subtle` |
| 1.2 | **H** | `site-header`/`site-footer` set bg to `var(--rhc-color-lintblauw-700,#154273)` / `…-900,#01689b``lintblauw-900` doesn't exist (only 50700), so the **hex fallback renders**. | header `--rhc-color-lintblauw-700`; footer `--rhc-color-donkerblauw-700`; text `--rhc-color-wit` |
| 1.3 | M | Raw spacing everywhere: `0.25/0.5/0.75/1/1.5/2/3rem` as `gap`/`margin`/`padding` (≈30 occurrences). | `--rhc-space-max-{sm,md,lg,xl,2xl,3xl,5xl}` |
| 1.4 | M | Inconsistent form/content widths: `28rem`, `30rem`, `32rem`, `64rem` as raw `max-width`. | app width tokens in `styles.scss` (`--app-form-max`, `--app-content-max`) mapped once |
| 1.5 | M | `spinner` & `skeleton` hardcode greys/accent (`#cad0d6`, `#e8ebee`, `#f3f5f6`) and the spinner accent via bogus `--rhc-color-lintblauw-700,#154273` fallback. | `--rhc-color-cool-grey-{200,300}`, `--rhc-color-lintblauw-700` |
| 1.6 | L | `site-header` hardcodes `font-weight:700/400`, `font-size:0.9rem`, `opacity:0.85`. | `--rhc-text-font-weight-{bold,regular}`, `--rhc-text-font-size-sm` |
## 2. Typography & heading hierarchy
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 2.1 | **H** | Each page has one `<h1>` (page-shell) but the **wizard steps have no `<h2>`** — step title is a plain `<p>`. Screen-reader users get no step heading to navigate to. | per-step `<h2>` via `app-heading [level]="2"` |
| 2.2 | M | Type scale not applied to bespoke text (header wordmark uses raw sizes). | `--rhc-text-font-size-*`, `app-heading` |
## 3. Color & contrast
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 3.1 | **H** | Because of 1.1/1.2 the actual rendered colors are partly accidental (default text color, hex fallbacks) — contrast is unverified. | map to real tokens, then verify AA |
| 3.2 | M | Palette discipline: confirm a single primary blue (lintblauw) + neutrals + status-only accents (`alert` types ok/info/warning/error already map to Utrecht alert variants). | keep alert variants; route blues to lintblauw/donkerblauw tokens |
## 4. Spacing & layout
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 4.1 | M | Repeated inline idioms: `display:flex;gap:0.5rem;margin-top:1rem` (button rows, 6×), `max-width:30rem` (forms, 3×), `margin:1rem 0` (summaries). | utility classes `.app-button-row`, `.app-form`, `.app-stack` in `styles.scss` |
| 4.2 | L | `shell` uses a custom `--app-content-max:64rem` defined inline. | promote to the `styles.scss` token layer |
## 5. Component reuse
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 5.1 | L | `intake-wizard` review step uses bespoke `<div><dt><dd>` instead of `app-data-row` (out of scope here; note for later). | `app-data-row` |
| 5.2 | M | Button rows / form containers are ad-hoc inline layout rather than a shared idiom. | utility classes (4.1) |
## 6. Form, validation & status patterns
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 6.1 | **H** | `form-field` renders the error with `role="alert"` but **no `id`**, and the projected input has **no `aria-describedby`** → error not programmatically linked. | error `id="${fieldId}-error"`; input `aria-describedby` |
| 6.2 | **H** | `text-input` never sets `aria-invalid` even when `invalid()` is true. | `[attr.aria-invalid]` |
| 6.3 | **H** | `radio-group` has `role="radiogroup"` but **no accessible name** and no invalid state. | `aria-labelledby="${name}-label"`, add `invalid` input → `aria-invalid`/`aria-describedby` |
| 6.4 | M | Async states (`app-async`) render but aren't announced (no live region) — SR users miss loading→loaded/empty/error. | wrap in `aria-live="polite"` + `aria-busy` |
| 6.5 | L | No error-summary pattern; per-field inline errors only. Acceptable for short steps; revisit if steps grow. | (defer) |
## 7. Page chrome
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 7.1 | M | No breadcrumb / location context in the portal chrome. | new `app-breadcrumb` (`.rhc-breadcrumb-nav`), wired via `page-shell` |
| 7.2 | M | Step progress is non-semantic text. | new `app-stepper` (`<ol>`, `aria-current="step"`) |
| 7.3 | L | Skip-link uses `left:-999px` (works) and landmarks (`header/main/footer`) are correct. | keep; tokenize offset |
## 8. Copy & tone
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 8.1 | M | Microcopy is functional but informal/uneven (labels, helper text, button text, the manual-diploma warning, the controle summary). | formal, plain official Dutch; consistent with domain terms (registratie wizard + chrome only) |
## 9. Accessibility (WCAG 2.1 AA) — consolidated
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 9.1 | **H** | No focus management: after Next/Back focus stays on the button; SR/keyboard users aren't moved to the new step. | move focus to the step `<h2>` (`tabindex="-1"`) on `cursor` change |
| 9.2 | **H** | Error/label/invalid association missing (6.16.3). | as above |
| 9.3 | M | Step changes & async states not announced (6.4). | `aria-live` |
| 9.4 | M | `skeleton` placeholders are announced as content. | `aria-hidden="true"` |
| 9.5 | L | No automated a11y in CI; only Storybook `addon-a11y` (axe) exists. | add a11y `parameters` in `preview.ts`; keep manual keyboard/SR pass; no new deps |
## 10. Responsive
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 10.1 | M | Fixed `rem` widths and inline flex rows; verify reflow + ≥24px targets at narrow widths. | token widths + `flex-wrap` on button rows; manual check |
---
## Tooling note
No eslint/stylelint/axe-core in the repo, and the prime directive forbids adding
dependencies. Token compliance is enforced by a **no-dep `check:tokens` npm script**
(greps touched templates for raw `#hex` and bare `rem/px` outside `var(...)`);
accessibility uses the already-installed **Storybook `addon-a11y`** plus a manual
keyboard/screen-reader pass.

File diff suppressed because one or more lines are too long

View File

@@ -8,7 +8,8 @@
"watch": "ng build --watch --configuration development",
"test": "ng test",
"storybook": "ng run atomic-design-poc:storybook",
"build-storybook": "ng run atomic-design-poc:build-storybook"
"build-storybook": "ng run atomic-design-poc:build-storybook",
"check:tokens": "if grep -rnE '#[0-9a-fA-F]{3,6}' src/app/registratie/ui src/app/shared/ui src/app/shared/layout --include='*.component.ts'; then echo 'FAIL: hardcoded hex colors found (use design tokens)'; exit 1; else echo 'OK: no hardcoded hex colors in wizard/atoms/chrome'; fi"
},
"private": true,
"packageManager": "npm@11.12.1",

View File

@@ -1,6 +1,5 @@
{
"naam": "Dr. A. (Anna) de Vries",
"geboortedatum": "1985-03-14",
"gevonden": true,
"adres": {
"straat": "Lange Voorhout 9",
"postcode": "2514 EA",

View File

@@ -0,0 +1,23 @@
{
"registration": {
"bigNummer": "19012345601",
"naam": "Dr. A. (Anna) de Vries",
"beroep": "Arts",
"registratiedatum": "2012-09-01",
"geboortedatum": "1985-03-14",
"status": { "tag": "Geregistreerd", "herregistratieDatum": "2027-03-01" }
},
"person": {
"naam": "Dr. A. (Anna) de Vries",
"geboortedatum": "1985-03-14",
"adres": {
"straat": "Lange Voorhout 9",
"postcode": "2514 EA",
"woonplaats": "Den Haag"
}
},
"decisions": {
"eligibleForHerregistratie": true,
"herregistratieReason": "Registratie verloopt binnen 12 maanden (2027-03-01)."
}
}

View File

@@ -0,0 +1,38 @@
{
"diplomas": [
{
"id": "d1",
"naam": "Geneeskunde",
"instelling": "Universiteit Leiden",
"jaar": 2011,
"beroep": "Arts",
"policyQuestions": []
},
{
"id": "d2",
"naam": "Medicine (MBChB)",
"instelling": "University of Edinburgh",
"jaar": 2013,
"beroep": "Arts",
"policyQuestions": [
{ "id": "nl-taalvaardigheid", "vraag": "Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?", "type": "ja-nee" }
]
},
{
"id": "d3",
"naam": "HBO-Verpleegkunde",
"instelling": "Hogeschool Utrecht",
"jaar": 2016,
"beroep": "Verpleegkundige",
"policyQuestions": []
}
],
"handmatig": {
"beroepen": ["Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts"],
"policyQuestions": [
{ "id": "nl-taalvaardigheid", "vraag": "Beheerst u de Nederlandse taal op het vereiste niveau (B2)?", "type": "ja-nee" },
{ "id": "diploma-erkend", "vraag": "Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?", "type": "ja-nee" },
{ "id": "toelichting", "vraag": "Geef een korte toelichting op uw diploma en opleiding.", "type": "tekst" }
]
}
}

View File

@@ -0,0 +1 @@
{ "scholingThreshold": 1000 }

View File

@@ -1,8 +0,0 @@
{
"bigNummer": "19012345601",
"naam": "Dr. A. (Anna) de Vries",
"beroep": "Arts",
"registratiedatum": "2012-09-01",
"geboortedatum": "1985-03-14",
"status": { "tag": "Geregistreerd", "herregistratieDatum": "2027-03-01" }
}

View File

@@ -11,6 +11,7 @@ export const routes: Routes = [
{ path: 'login', loadComponent: () => import('@auth/ui/login.page').then(m => m.LoginPage) },
{ path: 'dashboard', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/dashboard.page').then(m => m.DashboardPage) },
{ path: 'registratie', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registration-detail.page').then(m => m.RegistrationDetailPage) },
{ path: 'registreren', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registratie.page').then(m => m.RegistratiePage) },
{ path: 'herregistratie', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage) },
{ path: 'intake', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/intake.page').then(m => m.IntakePage) },
{ path: 'concepts', loadComponent: () => import('./showcase/concepts.page').then(m => m.ConceptsPage) },

View File

@@ -1,22 +1,45 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { Session } from '../domain/session';
import { DigidAdapter } from '../infrastructure/digid.adapter';
const STORAGE_KEY = 'session-v1';
/** Restore a persisted session (best-effort; corrupt entry → logged out). */
function restore(): Session | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as Session) : null;
} catch {
return null;
}
}
/**
* Holds the current session for the whole app. Because it is providedIn:'root'
* there is exactly one instance — every component that injects it sees the same
* session signal, so logging in is instantly visible everywhere (the guard, the
* header, etc.). ponytail: in-memory only; a refresh logs you out.
* header, etc.). The session is mirrored to sessionStorage so a refresh or a
* deep-link to a protected route keeps you logged in; it clears when the tab
* closes. ponytail: sessionStorage, not localStorage — no cross-tab sync, which
* matches a single-session portal.
*/
@Injectable({ providedIn: 'root' })
export class SessionStore {
private digid = inject(DigidAdapter);
private _session = signal<Session | null>(null);
private _session = signal<Session | null>(restore());
readonly session = this._session.asReadonly();
readonly isAuthenticated = computed(() => this._session() !== null);
constructor() {
effect(() => {
const s = this._session();
if (s) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
else sessionStorage.removeItem(STORAGE_KEY);
});
}
/** Effectful command: authenticate, then store the session on success. */
async login(bsn: string): Promise<Result<string, Session>> {
const r = await this.digid.authenticate(bsn);

View File

@@ -0,0 +1,12 @@
/**
* WIRE CONTRACT for intake policy values owned by the backend.
*
* This is the "config value" shape of moving policy off the client: instead of
* hardcoding the scholing threshold in the frontend, the backend ships the value
* and the UI applies it for instant feedback. The backend remains the AUTHORITY
* — it re-validates on submit. See docs/architecture/0001-bff-lite-decision-dtos.md.
*/
export interface IntakePolicyDto {
/** Below this many NL-hours the scholing question is required. */
scholingThreshold: number;
}

View File

@@ -3,7 +3,8 @@ import { ok, err } from '@shared/kernel/fp';
import {
Answers,
initial,
visibleSteps,
STEPS,
lageUren,
currentStep,
next,
back,
@@ -13,21 +14,34 @@ import {
IntakeState,
} from './intake.machine';
const answering = (answers: Answers, cursor = 0): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {} });
const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {}, scholingThreshold });
describe('visibleSteps (the branching)', () => {
it('asks only buitenland/uren/punten/review by default', () => {
expect(visibleSteps({})).toEqual(['buitenland', 'uren', 'punten', 'review']);
describe('STEPS (fixed) and inline questions', () => {
it('always has the same three steps', () => {
expect(STEPS).toEqual(['buitenland', 'werk', 'review']);
});
it('adds the buitenlandDetails step when worked abroad', () => {
expect(visibleSteps({ buitenlandGewerkt: 'ja' })).toContain('buitenlandDetails');
expect(visibleSteps({ buitenlandGewerkt: 'nee' })).not.toContain('buitenlandDetails');
it('reveals the buitenland detail questions inline only when worked abroad', () => {
// No new step; instead these fields become required within the buitenland step.
expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked
expect((next(answering({ buitenlandGewerkt: 'ja' })) as any).errors.land).toBeTruthy();
expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves)
expect((next(answering({ buitenlandGewerkt: 'nee' })) as any).cursor).toBe(1);
});
it('adds the scholing step only when NL-hours are below the threshold', () => {
expect(visibleSteps({ uren: '500' })).toContain('scholing');
expect(visibleSteps({ uren: '4160' })).not.toContain('scholing');
it('reveals the scholing question only when NL-hours are below the threshold', () => {
expect(lageUren({ uren: '500' })).toBe(true);
expect(lageUren({ uren: '4160' })).toBe(false);
});
it('uses the (server-owned) threshold passed in, not a hardcoded constant', () => {
// Same hours, different threshold → different visibility. Proves de-hardcoding.
expect(lageUren({ uren: '1500' }, 1000)).toBe(false);
expect(lageUren({ uren: '1500' }, 2000)).toBe(true);
// And the threshold from state flows through submit:
const lowThreshold = submit(answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000));
expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked
expect((lowThreshold as any).errors.scholingGevolgd).toBeTruthy();
});
});
@@ -36,20 +50,18 @@ describe('navigation', () => {
const s = next(initial); // buitenland unanswered
expect(s.tag).toBe('Answering');
expect((s as any).cursor).toBe(0);
expect((s as any).errors.buitenland).toBeTruthy();
expect((s as any).errors.buitenlandGewerkt).toBeTruthy();
});
it('Next advances once the step is valid', () => {
const s = next(answering({ buitenlandGewerkt: 'nee' }));
expect((s as any).cursor).toBe(1);
expect(currentStep(s as any)).toBe('uren');
expect(currentStep(s as any)).toBe('werk');
});
it('keeps the cursor in range when an answer collapses a branch', () => {
// Worked abroad, cursor sitting on the extra detail step (index 1)...
const collapsed = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
// ...the detail step is gone; cursor must not point past the new shorter list.
expect((collapsed as any).cursor).toBeLessThan(visibleSteps((collapsed as any).answers).length);
it('editing an answer leaves the cursor fixed (steps never collapse)', () => {
const edited = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
expect((edited as any).cursor).toBe(1); // cursor untouched; only inline questions change
});
it('Back never goes below the first step', () => {
@@ -58,21 +70,34 @@ describe('navigation', () => {
});
describe('submit', () => {
const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160', punten: '200' };
// High hours: no scholing question, so no punten is asked or collected.
const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160' };
it('reaches Submitting ONLY with valid answers', () => {
expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: 'x' })).tag).toBe('Answering');
// Bad punten only blocks when scholing was followed (otherwise punten is ignored).
expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' })).tag).toBe('Answering');
const good = submit(answering(complete));
expect(good.tag).toBe('Submitting');
expect((good as any).data.uren).toBe(4160);
expect((good as any).data.punten).toBeUndefined(); // not collected without scholing
});
it('punten is required only when aanvullende scholing was gevolgd', () => {
// scholing = ja but punten missing -> blocked on punten.
const missing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' }));
expect(missing.tag).toBe('Answering');
expect((missing as any).errors.punten).toBeTruthy();
// scholing = nee -> punten not required, submits without it.
expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag).toBe('Submitting');
});
it('low hours requires the scholing answer before submit', () => {
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', punten: '200' }));
expect(noScholing.tag).toBe('Answering'); // scholing step is required, unanswered
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' }));
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
const withScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }));
expect(withScholing.tag).toBe('Submitting');
expect((withScholing as any).data.aanvullendeScholing).toBe(true);
expect((withScholing as any).data.punten).toBe(200);
});
it('resolve maps Submitting to Submitted / Failed', () => {
@@ -85,16 +110,14 @@ describe('submit', () => {
describe('reduce (message-driven happy path)', () => {
it('drives abroad branch end to end', () => {
let s: IntakeState = initial;
// Step 1: buitenland — the country/hours questions reveal inline (same step).
s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('buitenlandDetails');
s = reduce(s, { tag: 'SetAnswer', key: 'land', value: 'België' });
s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandseUren', value: '800' });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('uren');
expect(currentStep(s as any)).toBe('werk');
// Step 2: werk — uren + punten (no inline scholing, hours are high).
s = reduce(s, { tag: 'SetAnswer', key: 'uren', value: '4160' });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('punten');
s = reduce(s, { tag: 'SetAnswer', key: 'punten', value: '200' });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('review');

View File

@@ -2,18 +2,19 @@ import { Result, ok, err, assertNever } from '@shared/kernel/fp';
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
/**
* A BRANCHING wizard. Unlike herregistratie.machine (fixed 2 steps), the set of
* steps here is NOT stored — it's *derived* from the answers by `visibleSteps`.
* Answer "buiten Nederland gewerkt? → ja" and two extra steps appear; report few
* hours and a scholing-question appears. The progress bar's denominator changes
* as you type. "Which question comes next" is a pure function, so it's trivial
* to test and impossible to get out of sync with the data.
* A FIXED 3-step wizard with progressive disclosure. The steps never change in
* number (always `STEPS`); instead, follow-up questions appear *inline within a
* step* depending on earlier answers — answer "buiten Nederland gewerkt? → ja"
* and the country/hours questions reveal in the same step; report few hours and
* the scholing-question reveals inside the 'werk' step. "Is this field required
* right now" is a pure function (`validateStep`/`lageUren`), so it's trivial to
* test and impossible to get out of sync with the data.
*/
export type JaNee = 'ja' | 'nee';
/** Every possible question, as a union — never a bare string. */
export type StepId = 'buitenland' | 'buitenlandDetails' | 'uren' | 'scholing' | 'punten' | 'review';
/** The three fixed steps. Each step groups one or more questions. */
export type StepId = 'buitenland' | 'werk' | 'review';
/** One record carried across every step (and persisted). All optional: the user
fills it in gradually, and branches may never ask some fields. */
@@ -33,111 +34,115 @@ export interface ValidIntake {
buitenlandseUren?: Uren;
uren: Uren;
aanvullendeScholing?: boolean;
punten: Uren;
punten?: Uren; // only collected when aanvullende scholing is gevolgd (scholingGevolgd === 'ja')
}
/** Below this many NL-hours we ask whether extra scholing was followed. */
const LAGE_UREN_DREMPEL = 1000;
/** Demo fallback only — the real threshold is a SERVER-OWNED policy value fetched
at runtime (see IntakePolicyDto / SetPolicy). ponytail: default is the offline
fallback; the server value wins. */
export const SCHOLING_THRESHOLD_DEFAULT = 1000;
function lageUren(a: Answers): boolean {
/** True when NL-hours are low enough that the scholing question must be answered.
The threshold is passed in (server-owned), not hardcoded. */
export function lageUren(a: Answers, scholingThreshold = SCHOLING_THRESHOLD_DEFAULT): boolean {
const r = parseUren(a.uren ?? '');
return r.ok && r.value < LAGE_UREN_DREMPEL;
return r.ok && r.value < scholingThreshold;
}
/**
* THE branching, as one pure function. The step list is recomputed on every
* transition, so changing an earlier answer immediately adds/removes later steps.
*/
export function visibleSteps(a: Answers): StepId[] {
const steps: StepId[] = ['buitenland'];
if (a.buitenlandGewerkt === 'ja') steps.push('buitenlandDetails');
steps.push('uren');
if (lageUren(a)) steps.push('scholing');
steps.push('punten', 'review');
return steps;
}
/** The fixed step list. Number of steps never changes; questions reveal inline. */
export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
/** Per-field error map: one message per question, since a step holds several. */
type Errors = Partial<Record<keyof Answers, string>>;
export type IntakeState =
| { tag: 'Answering'; answers: Answers; cursor: number; errors: Partial<Record<StepId, string>> }
| { tag: 'Answering'; answers: Answers; cursor: number; errors: Errors; scholingThreshold: number }
| { tag: 'Submitting'; data: ValidIntake }
| { tag: 'Submitted'; data: ValidIntake }
| { tag: 'Failed'; data: ValidIntake; error: string };
export const initial: IntakeState = { tag: 'Answering', answers: {}, cursor: 0, errors: {} };
export const initial: IntakeState = { tag: 'Answering', answers: {}, cursor: 0, errors: {}, scholingThreshold: SCHOLING_THRESHOLD_DEFAULT };
/** Which step the cursor currently points at (clamped to the live step list). */
/** Which step the cursor currently points at (clamped to the fixed list). */
export function currentStep(s: Extract<IntakeState, { tag: 'Answering' }>): StepId {
const steps = visibleSteps(s.answers);
return steps[Math.min(s.cursor, steps.length - 1)];
return STEPS[Math.min(s.cursor, STEPS.length - 1)];
}
/** Validate ONE step's fields. Returns the per-field error keyed by StepId. */
function validateStep(step: StepId, a: Answers): Result<Partial<Record<StepId, string>>, void> {
/** Validate every question currently visible in ONE step. Errors keyed per field. */
function validateStep(step: StepId, a: Answers, scholingThreshold: number): Result<Errors, void> {
const errors: Errors = {};
switch (step) {
case 'buitenland':
return a.buitenlandGewerkt ? ok(undefined) : err({ buitenland: 'Maak een keuze.' });
case 'buitenlandDetails': {
if (!a.land || a.land.trim() === '') return err({ buitenlandDetails: 'Vul een land in.' });
if (!a.buitenlandGewerkt) errors.buitenlandGewerkt = 'Maak een keuze.';
else if (a.buitenlandGewerkt === 'ja') {
if (!a.land || a.land.trim() === '') errors.land = 'Vul een land in.';
const u = parseUren(a.buitenlandseUren ?? '');
return u.ok ? ok(undefined) : err({ buitenlandDetails: u.error });
if (!u.ok) errors.buitenlandseUren = u.error;
}
case 'uren': {
break;
case 'werk': {
const u = parseUren(a.uren ?? '');
return u.ok ? ok(undefined) : err({ uren: u.error });
}
case 'scholing':
return a.scholingGevolgd ? ok(undefined) : err({ scholing: 'Maak een keuze.' });
case 'punten': {
if (!u.ok) errors.uren = u.error;
if (lageUren(a, scholingThreshold) && !a.scholingGevolgd) errors.scholingGevolgd = 'Maak een keuze.';
// Nascholingspunten are only asked (and required) when scholing was followed.
if (a.scholingGevolgd === 'ja') {
const p = parseUren(a.punten ?? '');
return p.ok ? ok(undefined) : err({ punten: p.error });
if (!p.ok) errors.punten = p.error;
}
break;
}
case 'review':
return ok(undefined); // review shows a summary; no own fields
break; // review shows a summary; no own fields
default:
return assertNever(step);
}
return Object.keys(errors).length > 0 ? err(errors) : ok(undefined);
}
/** Parse the whole questionnaire into a ValidIntake (called on submit). */
function validateAll(a: Answers): Result<Partial<Record<StepId, string>>, ValidIntake> {
const errors: Partial<Record<StepId, string>> = {};
for (const step of visibleSteps(a)) {
const r = validateStep(step, a);
function validateAll(a: Answers, scholingThreshold: number): Result<Errors, ValidIntake> {
const errors: Errors = {};
for (const step of STEPS) {
const r = validateStep(step, a, scholingThreshold);
if (!r.ok) Object.assign(errors, r.error);
}
if (Object.keys(errors).length > 0) return err(errors);
const uren = parseUren(a.uren ?? '');
const punten = parseUren(a.punten ?? '');
// visibleSteps guaranteed these parse, but keep the compiler happy.
if (!uren.ok || !punten.ok) return err(errors);
// validateStep guaranteed uren parses, but keep the compiler happy.
if (!uren.ok) return err(errors);
const werktBuitenland = a.buitenlandGewerkt === 'ja';
const buitenland = parseUren(a.buitenlandseUren ?? '');
// Punten are only collected when aanvullende scholing was gevolgd.
const punten = a.scholingGevolgd === 'ja' ? parseUren(a.punten ?? '') : undefined;
return ok({
werktBuitenland,
land: werktBuitenland ? a.land : undefined,
buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,
uren: uren.value,
aanvullendeScholing: lageUren(a) ? a.scholingGevolgd === 'ja' : undefined,
punten: punten.value,
aanvullendeScholing: lageUren(a, scholingThreshold) ? a.scholingGevolgd === 'ja' : undefined,
punten: punten?.ok ? punten.value : undefined,
});
}
export function setAnswer(s: IntakeState, key: keyof Answers, value: string): IntakeState {
if (s.tag !== 'Answering') return s;
const answers = { ...s.answers, [key]: value };
// Editing an earlier answer can shrink the step list; clamp the cursor.
const cursor = Math.min(s.cursor, visibleSteps(answers).length - 1);
return { ...s, answers, cursor };
// Steps are fixed, so editing an answer never moves the cursor — it only
// reveals/hides inline questions within the current step.
return { ...s, answers: { ...s.answers, [key]: value } };
}
export function next(s: IntakeState): IntakeState {
if (s.tag !== 'Answering') return s;
const r = validateStep(currentStep(s), s.answers);
const r = validateStep(currentStep(s), s.answers, s.scholingThreshold);
if (!r.ok) return { ...s, errors: r.error };
const last = visibleSteps(s.answers).length - 1;
return { ...s, cursor: Math.min(s.cursor + 1, last), errors: {} };
return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };
}
/** Apply a server-owned policy value (e.g. the scholing threshold). */
export function setPolicy(s: IntakeState, scholingThreshold: number): IntakeState {
return s.tag === 'Answering' ? { ...s, scholingThreshold } : s;
}
export function back(s: IntakeState): IntakeState {
@@ -147,7 +152,7 @@ export function back(s: IntakeState): IntakeState {
export function submit(s: IntakeState): IntakeState {
if (s.tag !== 'Answering') return s;
const r = validateAll(s.answers);
const r = validateAll(s.answers, s.scholingThreshold);
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
}
@@ -164,6 +169,7 @@ export type IntakeMsg =
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'SetPolicy'; scholingThreshold: number }
| { tag: 'Seed'; state: IntakeState };
export function reduce(s: IntakeState, m: IntakeMsg): IntakeState {
@@ -182,6 +188,8 @@ export function reduce(s: IntakeState, m: IntakeMsg): IntakeState {
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'SetPolicy':
return setPolicy(s, m.scholingThreshold);
case 'Seed':
return m.state;
default:

View File

@@ -33,7 +33,10 @@ import { submitHerregistratie } from '@herregistratie/application/submit-herregi
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
name="jaren" [invalid]="!!errJaren()" placeholder="bijv. 5" />
</app-form-field>
<div style="display:flex;gap:0.5rem">
<app-button type="submit" variant="primary">Volgende</app-button>
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
</div>
} @else {
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="errPunten()">
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
@@ -42,6 +45,7 @@ import { submitHerregistratie } from '@herregistratie/application/submit-herregi
<div style="display:flex;gap:0.5rem">
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
<app-button type="submit" variant="primary">Herregistratie aanvragen</app-button>
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
</div>
}
</form>
@@ -95,6 +99,11 @@ export class HerregistratieWizardComponent {
this.runIfSubmitting();
}
/** Reset the wizard to a fresh, empty start. */
restart() {
this.dispatch({ tag: 'Seed', state: initial });
}
/** The effect: when we entered Submitting, call the backend command, flip the
optimistic cross-page flag, then dispatch the result (and commit/rollback). */
private async runIfSubmitting() {

View File

@@ -4,11 +4,11 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { map } from '@shared/application/remote-data';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { isHerregistratieEligible } from '@registratie/domain/registration.policy';
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
/** A whole new page built from existing building blocks. Eligibility is a pure
domain rule (registration.policy) read from the shared profile state. */
/** A whole new page built from existing building blocks. Eligibility is a
SERVER-computed decision read from the aggregated view — the frontend renders
it, it does not recompute the rule. */
@Component({
selector: 'app-herregistratie-page',
imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent],
@@ -35,8 +35,9 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie
})
export class HerregistratiePage {
private store = inject(BigProfileStore);
// Derive a boolean RemoteData from the combined profile via map (pure).
// The eligibility decision comes from the server (decisions block), not a
// client-side rule. The UI just reads the boolean.
protected eligibility = computed(() =>
map(this.store.profile(), (p) => isHerregistratieEligible(p.registration, new Date())),
map(this.store.decisions(), (d) => d.eligibleForHerregistratie),
);
}

View File

@@ -1,4 +1,5 @@
import { Component, computed, effect, inject, input } from '@angular/core';
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
@@ -15,11 +16,14 @@ import {
StepId,
initial,
reduce,
visibleSteps,
STEPS,
lageUren,
SCHOLING_THRESHOLD_DEFAULT,
} from '@herregistratie/domain/intake.machine';
import { IntakePolicyDto } from '@herregistratie/contracts/intake-policy.dto';
import { submitIntake } from '@herregistratie/application/submit-intake';
const STORAGE_KEY = 'intake-v1'; // ponytail: bump the suffix if the Answers shape changes; no migration.
const STORAGE_KEY = 'intake-v3'; // ponytail: bump the suffix if the persisted state shape changes; no migration.
const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
@@ -33,39 +37,39 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
template: `
@switch (state().tag) {
@case ('Answering') {
<p class="rhc-paragraph" style="color:var(--rhc-color-grijs-700)">Stap {{ cursor() + 1 }} van {{ steps().length }}</p>
<p class="rhc-paragraph" style="color:var(--rhc-color-grijs-700)">Stap {{ cursor() + 1 }} van {{ steps.length }}</p>
<form (ngSubmit)="onPrimary()" style="max-width:30rem">
@switch (step()) {
@case ('buitenland') {
<app-form-field label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" fieldId="buitenland" [error]="err('buitenland')">
<app-form-field label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" fieldId="buitenland" [error]="err('buitenlandGewerkt')">
<app-radio-group name="buitenland" [options]="jaNee"
[ngModel]="answers().buitenlandGewerkt ?? ''" (ngModelChange)="set('buitenlandGewerkt', $event)" />
</app-form-field>
}
@case ('buitenlandDetails') {
<app-form-field label="In welk land?" fieldId="land" [error]="err('buitenlandDetails')">
@if (answers().buitenlandGewerkt === 'ja') {
<app-form-field label="In welk land?" fieldId="land" [error]="err('land')">
<app-text-input inputId="land" [ngModel]="answers().land ?? ''" (ngModelChange)="set('land', $event)" name="land" placeholder="bijv. België" />
</app-form-field>
<app-form-field label="Hoeveel uur heeft u daar gewerkt?" fieldId="buitenlandseUren">
<app-form-field label="Hoeveel uur heeft u daar gewerkt?" fieldId="buitenlandseUren" [error]="err('buitenlandseUren')">
<app-text-input inputId="buitenlandseUren" [ngModel]="answers().buitenlandseUren ?? ''" (ngModelChange)="set('buitenlandseUren', $event)" name="buitenlandseUren" placeholder="bijv. 800" />
</app-form-field>
}
@case ('uren') {
}
@case ('werk') {
<app-form-field label="Gewerkte uren in Nederland (afgelopen 5 jaar)" fieldId="uren" [error]="err('uren')">
<app-text-input inputId="uren" [ngModel]="answers().uren ?? ''" (ngModelChange)="set('uren', $event)" name="uren" placeholder="bijv. 4160" />
</app-form-field>
}
@case ('scholing') {
<app-form-field label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholing" [error]="err('scholing')">
@if (scholingZichtbaar()) {
<app-form-field label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholing" [error]="err('scholingGevolgd')">
<app-radio-group name="scholing" [options]="jaNee"
[ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" />
</app-form-field>
}
@case ('punten') {
@if (answers().scholingGevolgd === 'ja') {
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="err('punten')">
<app-text-input inputId="punten" [ngModel]="answers().punten ?? ''" (ngModelChange)="set('punten', $event)" name="punten" placeholder="bijv. 200" />
</app-form-field>
}
}
@case ('review') {
<app-alert type="info">Controleer uw antwoorden en dien de aanvraag in.</app-alert>
<dl class="rhc-data-summary rhc-data-summary--row" style="margin:1rem 0">
@@ -75,10 +79,12 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
<div><dt>Buitenlandse uren</dt><dd>{{ answers().buitenlandseUren }}</dd></div>
}
<div><dt>Uren NL</dt><dd>{{ answers().uren }}</dd></div>
@if (steps().includes('scholing')) {
@if (scholingZichtbaar()) {
<div><dt>Aanvullende scholing</dt><dd>{{ answers().scholingGevolgd }}</dd></div>
}
@if (answers().scholingGevolgd === 'ja') {
<div><dt>Nascholingspunten</dt><dd>{{ answers().punten }}</dd></div>
}
</dl>
}
}
@@ -87,6 +93,7 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
}
<app-button type="submit" variant="primary">{{ step() === 'review' ? 'Aanvraag indienen' : 'Volgende' }}</app-button>
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
</div>
</form>
}
@@ -112,6 +119,12 @@ export class IntakeWizardComponent {
private profile = inject(BigProfileStore);
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
// Server-owned policy: the scholing threshold is fetched, not hardcoded. The
// backend stays the authority and re-validates on submit.
private policyRes = httpResource<IntakePolicyDto>(() => 'mock/intake-policy.json', {
defaultValue: { scholingThreshold: SCHOLING_THRESHOLD_DEFAULT },
});
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<IntakeState>(initial);
@@ -120,14 +133,18 @@ export class IntakeWizardComponent {
readonly dispatch = this.store.dispatch;
private answering = computed(() => (this.state().tag === 'Answering' ? (this.state() as Extract<IntakeState, { tag: 'Answering' }>) : null));
/** Public so the showcase can render the live step list next to the wizard. */
readonly steps = computed<StepId[]>(() => visibleSteps(this.answering()?.answers ?? {}));
/** 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>(() => this.steps()[Math.min(this.cursor(), this.steps().length - 1)]);
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(() => (this.state().tag === 'Failed' ? (this.state() as Extract<IntakeState, { tag: 'Failed' }>).error : ''));
protected err = (k: StepId) => this.answering()?.errors[k] ?? '';
protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value });
constructor() {
@@ -140,6 +157,13 @@ export class IntakeWizardComponent {
if (s.tag === 'Answering') localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
else localStorage.removeItem(STORAGE_KEY);
});
// Apply the server-owned threshold into machine state as it arrives. Track
// only the policy value; untrack the dispatch (it reads the state signal
// internally, which would otherwise make this effect loop on its own write).
effect(() => {
const scholingThreshold = this.policyRes.value().scholingThreshold;
untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));
});
}
private restore(): IntakeState | null {

View File

@@ -16,12 +16,14 @@ const meta: Meta<IntakeWizardComponent> = {
export default meta;
type Story = StoryObj<IntakeWizardComponent>;
const answering = (answers: Answers, cursor = 0): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {} });
const answering = (answers: Answers, cursor = 0): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {}, scholingThreshold: 1000 });
export const Start: Story = { args: { seed: answering({}) } };
export const AbroadBranch: Story = { args: { seed: answering({ buitenlandGewerkt: 'ja' }, 1) } };
export const LowHoursScholing: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '500' }, 2) } };
export const Review: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: '200' }, 3) } };
// Inline reveal: country/hours appear within the buitenland step (cursor 0).
export const AbroadBranch: Story = { args: { seed: answering({ buitenlandGewerkt: 'ja' }, 0) } };
// Inline reveal: the scholing question appears within the werk step (cursor 1).
export const LowHoursScholing: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '500' }, 1) } };
export const Review: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: '200' }, 2) } };
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } };

View File

@@ -1,9 +1,10 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { RemoteData, fromResource, map2 } from '@shared/application/remote-data';
import { RemoteData, fromResource, map } from '@shared/application/remote-data';
import { Aantekening } from '../domain/registration';
import { BigProfile } from '../domain/big-profile';
import { HerregistratieDecisions, DashboardView } from '../contracts/dashboard-view.dto';
import { BigRegisterAdapter } from '../infrastructure/big-register.adapter';
import { BrpAdapter } from '../infrastructure/brp.adapter';
import { DashboardViewAdapter, parseDashboardView } from '../infrastructure/dashboard-view.adapter';
type Err = Error | undefined;
@@ -13,29 +14,32 @@ type Err = Error | undefined;
* (created here, in the required injection context) and exposes them as
* RemoteData signals.
*
* The headline trick: `profile` combines TWO independent services — the
* BIG-register and the BRP — into ONE RemoteData via map2. A page renders a
* single state (loading / error / loaded), never juggling three.
* The dashboard data now comes from ONE screen-shaped ("BFF-lite") call that
* returns registration + person + server-computed `decisions`. One request → one
* consistent snapshot, instead of stitching three independently loading/erroring
* resources together client-side. See docs/architecture/0001-bff-lite-decision-dtos.md.
*/
@Injectable({ providedIn: 'root' })
export class BigProfileStore {
private big = inject(BigRegisterAdapter);
private brp = inject(BrpAdapter);
private viewAdapter = inject(DashboardViewAdapter);
private registrationRes = this.big.registrationResource();
private viewRes = this.viewAdapter.dashboardViewResource();
private aantekeningenRes = this.big.aantekeningenResource();
private personRes = this.brp.personResource();
/** BIG-register + BRP folded into one state. */
readonly profile = computed<RemoteData<Err, BigProfile>>(() =>
map2(
fromResource(this.registrationRes),
fromResource(this.personRes),
// httpResource types value as T | undefined; in the Success branch it is
// always present, so narrowing here is safe.
(registration, person) => ({ registration: registration!, person: person! }),
),
);
/** The aggregated view, validated at the trust boundary (DTO → domain). */
private view = computed<RemoteData<Err, DashboardView>>(() => {
const rd = fromResource(this.viewRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseDashboardView(rd.value);
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
});
/** Registration + person, from the single aggregated call. */
readonly profile = computed<RemoteData<Err, BigProfile>>(() => map(this.view(), (v) => v.profile));
/** Server-computed decisions (e.g. herregistratie eligibility) — rendered, not recomputed. */
readonly decisions = computed<RemoteData<Err, HerregistratieDecisions>>(() => map(this.view(), (v) => v.decisions));
/** Specialisms/notes stay a separate stream (they have their own empty state). */
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() =>
@@ -52,7 +56,7 @@ export class BigProfileStore {
}
confirmHerregistratie() {
this.pending.set(false);
this.registrationRes.reload(); // invalidate: re-fetch the now-updated registration
this.viewRes.reload(); // invalidate: re-fetch the now-updated view (registration + decisions)
}
rollbackHerregistratie() {
this.pending.set(false); // submission failed — undo the optimistic flag

View File

@@ -0,0 +1,19 @@
import { Result, ok, err } from '@shared/kernel/fp';
import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
/**
* Command: send the completed registration to the backend, which invokes the
* domain command to create/finalize the registration aggregate. Returns a Result
* so the caller branches on success/failure without try/catch; on success it
* yields the confirmation reference (PRD §9). ponytail: faked with a timer; swap
* for a real POST when there's an API. The "manually entered diploma" rule lets
* the demo show the failure path.
*/
export async function submitRegistratie(data: ValidRegistratie): Promise<Result<string, string>> {
await new Promise((r) => setTimeout(r, 800));
if (data.diplomaHerkomst === 'handmatig') {
return err('Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling.');
}
const referentie = 'BIG-2026-' + Math.floor(100000 + Math.random() * 900000);
return ok(referentie);
}

View File

@@ -0,0 +1,15 @@
/**
* WIRE CONTRACT for the BRP address lookup ("BFF-lite" — one screen-shaped call).
*
* In production this is GENERATED from the OpenAPI/TypeSpec spec and served by our
* own backend, which talks to the BRP behind an adapter. The frontend never sees
* the BRP's own wire format. See docs/architecture/0001-bff-lite-decision-dtos.md.
*
* "Geen adres bekend" is a first-class outcome (`gevonden: false`), not an error —
* the wizard falls back to manual entry (PRD §7). Slice 1 ships only the happy
* path (gevonden: true).
*/
export interface BrpAddressDto {
gevonden: boolean;
adres?: { straat: string; postcode: string; woonplaats: string };
}

View File

@@ -0,0 +1,36 @@
import { Registration } from '@registratie/domain/registration';
import { Person } from '@registratie/domain/person';
import { BigProfile } from '@registratie/domain/big-profile';
/**
* WIRE CONTRACT for the dashboard screen — the "BFF-lite" response.
*
* In production this type is GENERATED from the OpenAPI/TypeSpec spec (one source
* of truth for both sides), and the `decisions` block is computed BY THE BACKEND
* — never recomputed on the client. The frontend renders decisions; it does not
* own the rules. See docs/architecture/0001-bff-lite-decision-dtos.md.
*
* One screen-shaped call replaces the previous three (BIG-register + BRP + …),
* so the page always sees one consistent snapshot instead of three independently
* loading/erroring resources.
*/
export interface DashboardViewDto {
registration: Registration;
person: Person;
decisions: HerregistratieDecisions;
}
/** Server-computed decisions. The eligibility rule lives on the backend; the
optional reason lets the UI explain itself without knowing the rule. */
export interface HerregistratieDecisions {
eligibleForHerregistratie: boolean;
herregistratieReason?: string;
}
/** The parsed, frontend-side view (DTO mapped onto our own domain model). Keeping
this distinct from DashboardViewDto is the decoupling seam: the wire shape can
change without the FE domain following, and vice-versa. */
export interface DashboardView {
profile: BigProfile;
decisions: HerregistratieDecisions;
}

View File

@@ -0,0 +1,38 @@
/**
* WIRE CONTRACT for the DUO diploma lookup ("BFF-lite" — one screen-shaped call
* returning everything the beroep step needs).
*
* Each diploma carries its server-computed `beroep` (the profession it maps to)
* and the `policyQuestions` (geldigheidsvragen) that apply to it. These are
* DECISIONS computed by the backend from the diploma's attributes — the frontend
* renders them, it does not derive them (decision-DTO pattern, ADR-0001). E.g. an
* English-language diploma carries the Dutch-proficiency question.
*
* `handmatig` is the fallback when the diploma is not in the DUO list: the
* professions the user may declare and the MAXIMAL policy-question set that then
* applies (a manual diploma is unverified, so the strictest set is used).
*/
export interface DuoLookupDto {
diplomas: DuoDiplomaDto[];
handmatig: ManualDiplomaPolicyDto;
}
export interface DuoDiplomaDto {
id: string;
naam: string;
instelling: string;
jaar: number;
beroep: string; // server-derived profession
policyQuestions: PolicyQuestionDto[]; // server-decided geldigheidsvragen
}
export interface ManualDiplomaPolicyDto {
beroepen: string[]; // professions the user may declare for a manual diploma
policyQuestions: PolicyQuestionDto[]; // maximal set applied to a manual diploma
}
export interface PolicyQuestionDto {
id: string;
vraag: string;
type: 'ja-nee' | 'tekst';
}

View File

@@ -0,0 +1,206 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import {
Draft,
RegistratieState,
STEPS,
initial,
currentStep,
next,
back,
gaNaarStap,
kiesDiploma,
kiesHandmatig,
declareerBeroep,
setAntwoord,
setField,
prefillAdres,
submit,
resolve,
reduce,
} from './registratie-wizard.machine';
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({
tag: 'Invullen',
draft: { antwoorden: {}, ...draft },
cursor,
errors: {},
});
const validAdres = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', correspondentie: 'post' as const, adresHerkomst: 'brp' as const };
const validDraft: Partial<Draft> = { ...validAdres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo' };
describe('STEPS (fixed)', () => {
it('always has the same three steps', () => {
expect(STEPS).toEqual(['adres', 'beroep', 'controle']);
});
});
describe('navigation', () => {
it('Next is a no-op (sets errors) when the adres step is invalid', () => {
const s = next(initial);
expect(s.tag).toBe('Invullen');
expect((s as any).cursor).toBe(0);
expect((s as any).errors.straat).toBeTruthy();
expect((s as any).errors.correspondentie).toBeTruthy();
});
it('Next advances once the adres step is valid', () => {
const s = next(invullen(validAdres));
expect((s as any).cursor).toBe(1);
expect(currentStep(s as any)).toBe('beroep');
});
it('requires a valid e-mail only when the channel is email', () => {
const bad = next(invullen({ ...validAdres, correspondentie: 'email' }));
expect((bad as any).errors.email).toBeTruthy();
const good = next(invullen({ ...validAdres, correspondentie: 'email', email: 'a@b.nl' }));
expect((good as any).cursor).toBe(1);
});
it('beroep step requires a chosen diploma', () => {
const noDiploma = next(invullen(validAdres, 1));
expect((noDiploma as any).cursor).toBe(1);
expect((noDiploma as any).errors.diploma).toBeTruthy();
const withDiploma = next(invullen(validDraft, 1));
expect((withDiploma as any).cursor).toBe(2);
});
it('Back never goes below the first step and preserves the draft', () => {
expect(back(initial)).toBe(initial);
const s = back(invullen(validDraft, 2));
expect((s as any).cursor).toBe(1);
expect((s as any).draft.beroep).toBe('Arts');
});
it('GaNaarStap only jumps backwards', () => {
expect((gaNaarStap(invullen(validDraft, 2), 0) as any).cursor).toBe(0);
expect((gaNaarStap(invullen(validDraft, 1), 2) as any).cursor).toBe(1); // forward jump rejected
});
});
describe('adres origin (BRP vs handmatig)', () => {
it('prefillAdres flags origin brp', () => {
const s = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
expect((s as any).draft.adresHerkomst).toBe('brp');
expect((s as any).draft.straat).toBe('Lange Voorhout 9');
});
it('editing a prefilled address field flips origin to handmatig', () => {
const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
const edited = setField(prefilled, 'woonplaats', 'Rotterdam');
expect((edited as any).draft.adresHerkomst).toBe('handmatig');
});
it('typing an address with no BRP prefill yields handmatig', () => {
const s = setField(invullen({}), 'straat', 'Kerkstraat 1');
expect((s as any).draft.adresHerkomst).toBe('handmatig');
});
it('editing the e-mail field does not change the address origin', () => {
const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
const edited = setField(prefilled, 'email', 'a@b.nl');
expect((edited as any).draft.adresHerkomst).toBe('brp');
});
it('a manually entered address still submits (only manual diploma is gated)', () => {
const s = submit(invullen({ straat: 'Kerkstraat 1', postcode: '1234 AB', woonplaats: 'Utrecht', correspondentie: 'post', adresHerkomst: 'handmatig', diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo' }));
expect(s.tag).toBe('Indienen');
expect((s as any).data.adresHerkomst).toBe('handmatig');
});
});
describe('kiesDiploma', () => {
it('derives the beroep from the chosen diploma and flags origin duo', () => {
const s = kiesDiploma(invullen({}), 'd9', 'Verpleegkundige', []);
expect((s as any).draft.diplomaId).toBe('d9');
expect((s as any).draft.beroep).toBe('Verpleegkundige');
expect((s as any).draft.diplomaHerkomst).toBe('duo');
});
});
describe('policy questions (geldigheidsvragen)', () => {
it('a diploma with questions blocks Next until they are answered', () => {
let s = kiesDiploma(invullen(validAdres, 1), 'd2', 'Arts', ['nl-taalvaardigheid']);
const blocked = next(s);
expect((blocked as any).cursor).toBe(1);
expect((blocked as any).errors.antwoorden['nl-taalvaardigheid']).toBeTruthy();
s = setAntwoord(s, 'nl-taalvaardigheid', 'ja');
expect((next(s) as any).cursor).toBe(2);
});
it('validateAll keeps only the answers to the questions that applied', () => {
let s = kiesDiploma(invullen(validAdres, 2), 'd2', 'Arts', ['nl-taalvaardigheid']);
s = setAntwoord(s, 'nl-taalvaardigheid', 'ja');
s = setAntwoord(s, 'stale', 'x'); // not in vraagIds
const done = submit(s);
expect(done.tag).toBe('Indienen');
expect((done as any).data.antwoorden).toEqual({ 'nl-taalvaardigheid': 'ja' });
});
});
describe('manual diploma fallback', () => {
const maxIds = ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'];
it('KiesHandmatig flags handmatig with the maximal question set and no beroep yet', () => {
const s = kiesHandmatig(invullen(validAdres, 1), maxIds);
expect((s as any).draft.diplomaHerkomst).toBe('handmatig');
expect((s as any).draft.beroep).toBeUndefined();
expect((s as any).draft.vraagIds).toEqual(maxIds);
});
it('requires a declared beroep + all maximal questions before submit', () => {
let s = kiesHandmatig(invullen(validAdres, 2), maxIds);
expect(submit(s).tag).toBe('Invullen'); // no beroep declared
s = declareerBeroep(s, 'Fysiotherapeut');
expect(submit(s).tag).toBe('Invullen'); // questions unanswered
for (const id of maxIds) s = setAntwoord(s, id, 'ja');
const done = submit(s);
expect(done.tag).toBe('Indienen');
expect((done as any).data.diplomaHerkomst).toBe('handmatig');
expect((done as any).data.beroep).toBe('Fysiotherapeut');
});
});
describe('submit', () => {
it('reaches Indienen ONLY with a complete, valid draft', () => {
expect(submit(invullen(validAdres)).tag).toBe('Invullen'); // no diploma
const good = submit(invullen(validDraft));
expect(good.tag).toBe('Indienen');
expect((good as any).data.beroep).toBe('Arts');
expect((good as any).data.adres.postcode).toBe('2514 EA');
expect((good as any).data.adresHerkomst).toBe('brp');
});
it('resolve maps Indienen to Ingediend (with referentie) / Mislukt', () => {
const indienen = submit(invullen(validDraft));
expect(resolve(indienen, ok('BIG-2026-001')).tag).toBe('Ingediend');
expect((resolve(indienen, ok('BIG-2026-001')) as any).referentie).toBe('BIG-2026-001');
expect(resolve(indienen, err('boom')).tag).toBe('Mislukt');
});
});
describe('reduce (message-driven happy path)', () => {
it('drives the full flow via messages', () => {
let s: RegistratieState = initial;
s = reduce(s, { tag: 'PrefillAdres', straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' });
s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('beroep');
s = reduce(s, { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('controle');
s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Indienen');
s = reduce(s, { tag: 'SubmitConfirmed', referentie: 'BIG-2026-001' });
expect(s.tag).toBe('Ingediend');
});
it('SubmitFailed then Retry returns to Indienen with the same data', () => {
let s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' });
expect(s.tag).toBe('Mislukt');
s = reduce(s, { tag: 'Retry' });
expect(s.tag).toBe('Indienen');
expect((s as any).data.beroep).toBe('Arts');
});
});

View File

@@ -0,0 +1,280 @@
import { Result, ok, err, assertNever } from '@shared/kernel/fp';
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
import { Email, parseEmail } from '@registratie/domain/value-objects/email';
/**
* A FIXED 3-step registration wizard. The steps never change in number (always
* `STEPS`): (1) adres + correspondentievoorkeur, (2) beroep o.b.v. diploma,
* (3) controle & indienen. Follow-up questions appear *inline within a step*
* (e.g. choosing 'email' reveals the e-mail field). "Is this field required
* right now" is a pure function (`validateStep`), so it is trivial to test and
* impossible to get out of sync with the data. Invariants live here, not in the
* UI: the wizard reaches `Indienen` only when a complete `ValidRegistratie` parses.
*/
export type StepId = 'adres' | 'beroep' | 'controle';
/** The fixed step list. Number of steps never changes; questions reveal inline. */
export const STEPS: StepId[] = ['adres', 'beroep', 'controle'];
/** Where a piece of data came from — recorded on the aggregate (PRD §5). */
export type AdresHerkomst = 'brp' | 'handmatig';
export type DiplomaHerkomst = 'duo' | 'handmatig';
export type Correspondentie = 'email' | 'post';
/** One record carried across every step (and persisted). All optional: the user
fills it in gradually. Adres fields are kept flat so one `SetField` message
serves them all (mirrors the intake machine). */
export interface Draft {
straat?: string;
postcode?: string;
woonplaats?: string;
adresHerkomst?: AdresHerkomst;
correspondentie?: Correspondentie;
email?: string;
diplomaId?: string;
diplomaHerkomst?: DiplomaHerkomst;
beroep?: string; // DERIVED from the chosen DUO diploma (or declared for a manual one)
vraagIds?: string[]; // ids of the policy questions that apply to the chosen diploma
antwoorden: Record<string, string>; // geldigheidsantwoorden, keyed by question id
}
/** What we have after the controle step parses — guaranteed valid/typed. */
export interface ValidRegistratie {
adres: { straat: string; postcode: Postcode; woonplaats: string };
adresHerkomst: AdresHerkomst;
correspondentie: Correspondentie;
email?: Email; // only when correspondentie === 'email'
diplomaId: string;
diplomaHerkomst: DiplomaHerkomst;
beroep: string;
antwoorden: Record<string, string>;
}
/** Text fields settable via SetField. */
export type DraftField = 'straat' | 'postcode' | 'woonplaats' | 'email';
/** Per-field error map. `antwoorden` holds per-policy-question errors, keyed by
question id (a step can show several questions). */
export interface Errors {
straat?: string;
postcode?: string;
woonplaats?: string;
email?: string;
correspondentie?: string;
diploma?: string;
antwoorden?: Record<string, string>;
}
export type RegistratieState =
| { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors }
| { tag: 'Indienen'; data: ValidRegistratie }
| { tag: 'Ingediend'; data: ValidRegistratie; referentie: string }
| { tag: 'Mislukt'; data: ValidRegistratie; error: string };
const emptyDraft: Draft = { antwoorden: {} };
export const initial: RegistratieState = { tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {} };
/** Which step the cursor currently points at (clamped to the fixed list). */
export function currentStep(s: Extract<RegistratieState, { tag: 'Invullen' }>): StepId {
return STEPS[Math.min(s.cursor, STEPS.length - 1)];
}
/** Validate every question currently visible in ONE step. Errors keyed per field. */
function validateStep(step: StepId, d: Draft): Result<Errors, void> {
const errors: Errors = {};
switch (step) {
case 'adres': {
if (!d.straat || d.straat.trim() === '') errors.straat = 'Vul een straat en huisnummer in.';
const pc = parsePostcode(d.postcode ?? '');
if (!pc.ok) errors.postcode = pc.error;
if (!d.woonplaats || d.woonplaats.trim() === '') errors.woonplaats = 'Vul een woonplaats in.';
if (!d.correspondentie) errors.correspondentie = 'Maak een keuze.';
// E-mail is only required when 'email' is the chosen channel.
if (d.correspondentie === 'email') {
const e = parseEmail(d.email ?? '');
if (!e.ok) errors.email = e.error;
}
break;
}
case 'beroep': {
// A diploma must be chosen (or declared manually); its beroep is then known.
if (!d.diplomaId || !d.beroep) {
errors.diploma = 'Kies het diploma waarmee u zich wilt registreren, of voer het handmatig in.';
break;
}
// Every policy question the chosen diploma raised must be answered. Which
// questions apply is server-decided (carried in `vraagIds`); we only check
// they're answered.
const open: Record<string, string> = {};
for (const id of d.vraagIds ?? []) {
if (!(d.antwoorden[id] ?? '').trim()) open[id] = 'Beantwoord deze vraag.';
}
if (Object.keys(open).length > 0) errors.antwoorden = open;
break;
}
case 'controle':
break; // controle shows a summary; no own fields
default:
return assertNever(step);
}
return Object.keys(errors).length > 0 ? err(errors) : ok(undefined);
}
/** Parse the whole wizard into a ValidRegistratie (called on submit). */
function validateAll(d: Draft): Result<Errors, ValidRegistratie> {
const errors: Errors = {};
for (const step of STEPS) {
const r = validateStep(step, d);
if (!r.ok) Object.assign(errors, r.error);
}
if (Object.keys(errors).length > 0) return err(errors);
const pc = parsePostcode(d.postcode ?? '');
// validateStep guaranteed these parse, but keep the compiler happy.
if (!pc.ok || !d.diplomaId || !d.beroep || !d.correspondentie) return err(errors);
const email = d.correspondentie === 'email' ? parseEmail(d.email ?? '') : undefined;
// Keep only the answers to the questions that actually applied.
const vraagIds = d.vraagIds ?? [];
const antwoorden = Object.fromEntries(vraagIds.map((id) => [id, d.antwoorden[id] ?? '']));
return ok({
adres: { straat: d.straat!, postcode: pc.value, woonplaats: d.woonplaats! },
adresHerkomst: d.adresHerkomst ?? 'handmatig',
correspondentie: d.correspondentie,
email: email?.ok ? email.value : undefined,
diplomaId: d.diplomaId,
diplomaHerkomst: d.diplomaHerkomst ?? 'handmatig',
beroep: d.beroep,
antwoorden,
});
}
export function setField(s: RegistratieState, key: DraftField, value: string): RegistratieState {
if (s.tag !== 'Invullen') return s;
const draft: Draft = { ...s.draft, [key]: value };
// Editing an address field means the user owns it now — not the BRP copy.
if (key === 'straat' || key === 'postcode' || key === 'woonplaats') draft.adresHerkomst = 'handmatig';
return { ...s, draft };
}
export function setCorrespondentie(s: RegistratieState, value: Correspondentie): RegistratieState {
if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, correspondentie: value } };
}
/** Prefill the address from a BRP lookup and flag its origin (PRD §7). */
export function prefillAdres(s: RegistratieState, straat: string, postcode: string, woonplaats: string): RegistratieState {
if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, straat, postcode, woonplaats, adresHerkomst: 'brp' } };
}
/** Pick a DUO diploma; the beroep is derived from it and the applicable policy
questions (`vraagIds`) come with it (both server-computed, passed in). */
export function kiesDiploma(s: RegistratieState, diplomaId: string, beroep: string, vraagIds: string[]): RegistratieState {
if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, diplomaId, beroep, vraagIds, diplomaHerkomst: 'duo' }, errors: {} };
}
/** Switch to manual diploma entry: the diploma isn't in DUO, so the MAXIMAL
policy-question set applies and the entry is flagged handmatig/unverified. The
beroep is declared separately (declareerBeroep). */
export function kiesHandmatig(s: RegistratieState, vraagIds: string[]): RegistratieState {
if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, diplomaId: 'handmatig', beroep: undefined, vraagIds, diplomaHerkomst: 'handmatig' }, errors: {} };
}
/** Declare the beroep for a manually-entered diploma (chosen from a fixed list). */
export function declareerBeroep(s: RegistratieState, beroep: string): RegistratieState {
if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, beroep } };
}
export function setAntwoord(s: RegistratieState, vraagId: string, value: string): RegistratieState {
if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, antwoorden: { ...s.draft.antwoorden, [vraagId]: value } } };
}
export function next(s: RegistratieState): RegistratieState {
if (s.tag !== 'Invullen') return s;
const r = validateStep(currentStep(s), s.draft);
if (!r.ok) return { ...s, errors: r.error };
return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };
}
export function back(s: RegistratieState): RegistratieState {
if (s.tag !== 'Invullen' || s.cursor === 0) return s;
return { ...s, cursor: s.cursor - 1, errors: {} };
}
/** Jump back to an earlier step to correct data (controle → step N). Forward
jumps are not allowed (would skip validation). Preserves the draft. */
export function gaNaarStap(s: RegistratieState, cursor: number): RegistratieState {
if (s.tag !== 'Invullen' || cursor < 0 || cursor >= s.cursor) return s;
return { ...s, cursor, errors: {} };
}
export function submit(s: RegistratieState): RegistratieState {
if (s.tag !== 'Invullen') return s;
const r = validateAll(s.draft);
return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error };
}
export function resolve(s: RegistratieState, r: Result<string, string>): RegistratieState {
if (s.tag !== 'Indienen') return s;
return r.ok ? { tag: 'Ingediend', data: s.data, referentie: r.value } : { tag: 'Mislukt', data: s.data, error: r.error };
}
export type RegistratieMsg =
| { tag: 'SetField'; key: DraftField; value: string }
| { tag: 'SetCorrespondentie'; value: Correspondentie }
| { tag: 'PrefillAdres'; straat: string; postcode: string; woonplaats: string }
| { tag: 'KiesDiploma'; diplomaId: string; beroep: string; vraagIds: string[] }
| { tag: 'KiesHandmatig'; vraagIds: string[] }
| { tag: 'DeclareerBeroep'; beroep: string }
| { tag: 'SetAntwoord'; vraagId: string; value: string }
| { tag: 'Next' }
| { tag: 'Back' }
| { tag: 'GaNaarStap'; cursor: number }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed'; referentie: string }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Seed'; state: RegistratieState };
export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState {
switch (m.tag) {
case 'SetField':
return setField(s, m.key, m.value);
case 'SetCorrespondentie':
return setCorrespondentie(s, m.value);
case 'PrefillAdres':
return prefillAdres(s, m.straat, m.postcode, m.woonplaats);
case 'KiesDiploma':
return kiesDiploma(s, m.diplomaId, m.beroep, m.vraagIds);
case 'KiesHandmatig':
return kiesHandmatig(s, m.vraagIds);
case 'DeclareerBeroep':
return declareerBeroep(s, m.beroep);
case 'SetAntwoord':
return setAntwoord(s, m.vraagId, m.value);
case 'Next':
return next(s);
case 'Back':
return back(s);
case 'GaNaarStap':
return gaNaarStap(s, m.cursor);
case 'Submit':
return submit(s);
case 'Retry':
return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s;
case 'SubmitConfirmed':
return s.tag === 'Indienen' ? { tag: 'Ingediend', data: s.data, referentie: m.referentie } : s;
case 'SubmitFailed':
return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s;
case 'Seed':
return m.state;
default:
return assertNever(m);
}
}

View File

@@ -34,7 +34,10 @@ export function herregistratieDeadline(reg: Registration): Date | null {
}
/** A registration may apply for herregistratie only while active and within the
window before its deadline. A struck-off or suspended registration may not. */
window before its deadline. A struck-off or suspended registration may not.
SERVER-OWNED RULE: this now runs on the backend (BFF), which ships the result
as `decisions.eligibleForHerregistratie` in the dashboard view. Kept here as
the reference implementation + unit test; the frontend no longer calls it. */
export function isHerregistratieEligible(reg: Registration, today: Date, windowMonths = 12): boolean {
const deadline = herregistratieDeadline(reg);
if (!deadline) return false;

View File

@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { parseEmail } from './email';
describe('parseEmail', () => {
it('accepts a well-formed address and trims it', () => {
const r = parseEmail(' naam@voorbeeld.nl ');
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe('naam@voorbeeld.nl');
});
it('rejects malformed addresses', () => {
expect(parseEmail('').ok).toBe(false);
expect(parseEmail('naam').ok).toBe(false);
expect(parseEmail('naam@voorbeeld').ok).toBe(false);
expect(parseEmail('naam @voorbeeld.nl').ok).toBe(false);
});
});

View File

@@ -0,0 +1,19 @@
import { Brand, Result, ok, err } from '@shared/kernel/fp';
/**
* Value object: an e-mail address. "Parse, don't validate" — an Email is a
* distinct type from a raw string, mintable only via parseEmail, so holding one
* is proof it is well-formed. Format-only check (the FE keeps format validation
* for instant feedback; the backend stays the authority — see ADR-0001).
*/
export type Email = Brand<string, 'Email'>;
export function parseEmail(raw: string): Result<string, Email> {
const t = raw.trim();
// Deliberately lax: a single @ with non-empty, dot-bearing parts. Good enough
// for instant feedback; the server re-validates.
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) {
return err('Voer een geldig e-mailadres in, bijv. naam@voorbeeld.nl.');
}
return ok(t as Email);
}

View File

@@ -1,19 +1,18 @@
import { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Registration, Aantekening } from '../domain/registration';
import { Aantekening } from '../domain/registration';
/**
* Infrastructure adapter for the BIG-register source. Exposes signal-based
* resources (Angular's httpResource); each returns a Resource with
* status()/value()/error()/reload(). Call from an injection context
* (a field initializer in the store).
*
* Note: registration + person are now served via the aggregated dashboard-view
* endpoint (see DashboardViewAdapter). Only the notes stream remains separate.
*/
@Injectable({ providedIn: 'root' })
export class BigRegisterAdapter {
registrationResource() {
return httpResource<Registration>(() => 'mock/registration.json');
}
aantekeningenResource() {
return httpResource<Aantekening[]>(() => 'mock/notes.json', { defaultValue: [] });
}

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { parseBrpAddress } from './brp.adapter';
describe('parseBrpAddress (trust boundary)', () => {
it('accepts a found address', () => {
const r = parseBrpAddress({ gevonden: true, adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' } });
expect(r.ok).toBe(true);
if (r.ok) expect(r.value.adres?.postcode).toBe('2514 EA');
});
it('accepts "geen adres" (gevonden: false) as a valid outcome', () => {
const r = parseBrpAddress({ gevonden: false });
expect(r.ok).toBe(true);
});
it('rejects malformed responses', () => {
expect(parseBrpAddress(null).ok).toBe(false);
expect(parseBrpAddress({}).ok).toBe(false); // missing gevonden
expect(parseBrpAddress({ gevonden: true }).ok).toBe(false); // found but no adres
expect(parseBrpAddress({ gevonden: true, adres: { straat: 'x' } }).ok).toBe(false);
});
});

View File

@@ -1,11 +1,34 @@
import { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Person } from '../domain/person';
import { Result, ok, err } from '@shared/kernel/fp';
import { BrpAddressDto } from '@registratie/contracts/brp-address.dto';
/** Infrastructure adapter for the BRP (Basisregistratie Personen) source. */
/**
* Infrastructure adapter for the BRP address lookup, reached only through our own
* ("BFF-lite") endpoint — the anti-corruption boundary. In this POC the endpoint
* is a static mock; pointing at a real backend touches only this file + the DTO.
*/
@Injectable({ providedIn: 'root' })
export class BrpAdapter {
personResource() {
return httpResource<Person>(() => 'mock/brp.json');
// Typed as the DTO for ergonomics, but the value is untrusted JSON until
// parseBrpAddress validates it.
adresResource() {
return httpResource<BrpAddressDto>(() => 'mock/brp-address.json');
}
}
/** Trust-boundary parse: validate the untrusted response shape. "Geen adres" is a
valid outcome (gevonden: false), not a malformed response. ponytail: hand-written;
reach for a schema lib once the contract count grows. */
export function parseBrpAddress(json: unknown): Result<string, BrpAddressDto> {
if (typeof json !== 'object' || json === null) return err('brp-address: not an object');
const dto = json as Partial<BrpAddressDto>;
if (typeof dto.gevonden !== 'boolean') return err('brp-address: missing/invalid gevonden');
if (dto.gevonden) {
const a = dto.adres;
if (!a || typeof a.straat !== 'string' || typeof a.postcode !== 'string' || typeof a.woonplaats !== 'string') {
return err('brp-address: missing/invalid adres');
}
}
return ok({ gevonden: dto.gevonden, adres: dto.adres });
}

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { parseDashboardView } from './dashboard-view.adapter';
const valid = {
registration: {
bigNummer: '19012345601',
naam: 'Dr. A. de Vries',
beroep: 'Arts',
registratiedatum: '2012-09-01',
geboortedatum: '1985-03-14',
status: { tag: 'Geregistreerd', herregistratieDatum: '2027-03-01' },
},
person: { naam: 'Dr. A. de Vries', geboortedatum: '1985-03-14', adres: { straat: 'X 1', postcode: '2514 EA', woonplaats: 'Den Haag' } },
decisions: { eligibleForHerregistratie: true, herregistratieReason: 'within window' },
};
describe('parseDashboardView (trust boundary)', () => {
it('maps a valid response into a DashboardView', () => {
const r = parseDashboardView(valid);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.value.profile.registration.bigNummer).toBe('19012345601');
expect(r.value.decisions.eligibleForHerregistratie).toBe(true);
}
});
it('rejects malformed responses instead of trusting them', () => {
expect(parseDashboardView(null).ok).toBe(false);
expect(parseDashboardView({ ...valid, registration: undefined }).ok).toBe(false);
expect(parseDashboardView({ ...valid, decisions: { eligibleForHerregistratie: 'yes' } }).ok).toBe(false);
});
});

View File

@@ -0,0 +1,47 @@
import { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Result, ok, err } from '@shared/kernel/fp';
import { DashboardViewDto, DashboardView } from '@registratie/contracts/dashboard-view.dto';
/**
* Infrastructure adapter for the screen-shaped ("BFF-lite") dashboard endpoint.
* ONE call returns registration + person + server-computed decisions.
* (In this POC the endpoint is a static mock; the decisions are precomputed to
* stand in for what the backend would compute.)
*/
@Injectable({ providedIn: 'root' })
export class DashboardViewAdapter {
// Typed as the DTO for ergonomics, but the value is still untrusted JSON —
// parseDashboardView validates it at the boundary before the app uses it.
dashboardViewResource() {
return httpResource<DashboardViewDto>(() => 'mock/dashboard-view.json');
}
}
/**
* Trust-boundary parse: validate the untrusted response shape and map the DTO
* onto our own domain model. Hand-written on purpose — no Zod for a single
* contract. ponytail: reach for a schema lib once the contract count grows.
*/
export function parseDashboardView(json: unknown): Result<string, DashboardView> {
if (typeof json !== 'object' || json === null) return err('dashboard-view: not an object');
const dto = json as Partial<DashboardViewDto>;
const reg = dto.registration;
if (!reg || typeof reg.bigNummer !== 'string' || !reg.status || typeof reg.status.tag !== 'string') {
return err('dashboard-view: missing/invalid registration');
}
const person = dto.person;
if (!person || !person.adres || typeof person.adres.postcode !== 'string') {
return err('dashboard-view: missing/invalid person');
}
const d = dto.decisions;
if (!d || typeof d.eligibleForHerregistratie !== 'boolean') {
return err('dashboard-view: missing/invalid decisions');
}
return ok({
profile: { registration: reg, person },
decisions: { eligibleForHerregistratie: d.eligibleForHerregistratie, herregistratieReason: d.herregistratieReason },
});
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { parseDuoLookup } from './duo.adapter';
const valid = {
diplomas: [
{ id: 'd1', naam: 'Geneeskunde', instelling: 'Universiteit Leiden', jaar: 2011, beroep: 'Arts', policyQuestions: [] },
{ id: 'd2', naam: 'Medicine', instelling: 'University of Edinburgh', jaar: 2013, beroep: 'Arts', policyQuestions: [{ id: 'nl-taal', vraag: 'Toon taalvaardigheid', type: 'ja-nee' }] },
],
handmatig: {
beroepen: ['Arts', 'Verpleegkundige'],
policyQuestions: [{ id: 'toelichting', vraag: 'Toelichting', type: 'tekst' }],
},
};
describe('parseDuoLookup (trust boundary)', () => {
it('maps a valid lookup (diplomas + manual fallback)', () => {
const r = parseDuoLookup(valid);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.value.diplomas).toHaveLength(2);
expect(r.value.diplomas[1].policyQuestions[0].id).toBe('nl-taal');
expect(r.value.handmatig.beroepen).toContain('Verpleegkundige');
expect(r.value.handmatig.policyQuestions[0].type).toBe('tekst');
}
});
it('accepts an empty diploma list (forces manual entry)', () => {
const r = parseDuoLookup({ diplomas: [], handmatig: valid.handmatig });
expect(r.ok).toBe(true);
});
it('rejects malformed responses', () => {
expect(parseDuoLookup(null).ok).toBe(false);
expect(parseDuoLookup({}).ok).toBe(false); // no diplomas
expect(parseDuoLookup({ diplomas: [] }).ok).toBe(false); // no handmatig
expect(parseDuoLookup({ diplomas: [{ id: 'd1' }], handmatig: valid.handmatig }).ok).toBe(false); // bad diploma
expect(parseDuoLookup({ diplomas: [], handmatig: { beroepen: ['Arts'], policyQuestions: [{ id: 'x', vraag: 'y', type: 'bogus' }] } }).ok).toBe(false); // bad question type
});
});

View File

@@ -0,0 +1,60 @@
import { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Result, ok, err } from '@shared/kernel/fp';
import { DuoLookupDto, DuoDiplomaDto, PolicyQuestionDto, ManualDiplomaPolicyDto } from '@registratie/contracts/duo-diplomas.dto';
const EMPTY: DuoLookupDto = { diplomas: [], handmatig: { beroepen: [], policyQuestions: [] } };
/**
* Infrastructure adapter for the DUO diploma lookup, reached only through our own
* ("BFF-lite") endpoint — the anti-corruption boundary. The response carries the
* user's diplomas (each with its server-computed beroep + policy questions) and
* the manual-entry fallback policy. The frontend renders; it does not derive. In
* this POC the endpoint is a static mock.
*/
@Injectable({ providedIn: 'root' })
export class DuoAdapter {
diplomasResource() {
return httpResource<DuoLookupDto>(() => 'mock/duo-diplomas.json', { defaultValue: EMPTY });
}
}
function parseQuestions(json: unknown): PolicyQuestionDto[] | null {
if (!Array.isArray(json)) return null;
const out: PolicyQuestionDto[] = [];
for (const q of json) {
if (typeof q !== 'object' || q === null) return null;
const p = q as Partial<PolicyQuestionDto>;
if (typeof p.id !== 'string' || typeof p.vraag !== 'string' || (p.type !== 'ja-nee' && p.type !== 'tekst')) return null;
out.push({ id: p.id, vraag: p.vraag, type: p.type });
}
return out;
}
/** Trust-boundary parse: validate the untrusted response shape (diplomas, each
with derived beroep + policy questions, plus the manual-entry fallback). */
export function parseDuoLookup(json: unknown): Result<string, DuoLookupDto> {
if (typeof json !== 'object' || json === null) return err('duo-lookup: not an object');
const dto = json as Partial<DuoLookupDto>;
if (!Array.isArray(dto.diplomas)) return err('duo-lookup: missing diplomas');
const diplomas: DuoDiplomaDto[] = [];
for (const item of dto.diplomas) {
if (typeof item !== 'object' || item === null) return err('duo-lookup: invalid diploma');
const d = item as Partial<DuoDiplomaDto>;
const vragen = parseQuestions(d.policyQuestions);
if (typeof d.id !== 'string' || typeof d.naam !== 'string' || typeof d.beroep !== 'string' || vragen === null) {
return err('duo-lookup: missing/invalid diploma fields');
}
diplomas.push({ id: d.id, naam: d.naam, instelling: d.instelling ?? '', jaar: typeof d.jaar === 'number' ? d.jaar : 0, beroep: d.beroep, policyQuestions: vragen });
}
const hm = dto.handmatig;
const hmVragen = hm ? parseQuestions(hm.policyQuestions) : null;
if (!hm || !Array.isArray(hm.beroepen) || hmVragen === null) {
return err('duo-lookup: missing/invalid handmatig fallback');
}
const handmatig: ManualDiplomaPolicyDto = { beroepen: hm.beroepen, policyQuestions: hmVragen };
return ok({ diplomas, handmatig });
}

View File

@@ -56,6 +56,9 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
</div>
<p style="margin-top:2rem">
<app-link to="/registreren">Inschrijven in het BIG-register (registratiewizard) →</app-link>
</p>
<p>
<app-link to="/registratie">Gegevens bekijken of een wijziging doorgeven →</app-link>
</p>
<p>

View File

@@ -0,0 +1,360 @@
import { Component, ElementRef, computed, effect, inject, input, untracked, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { RadioGroupComponent } 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 { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { createStore } from '@shared/application/store';
import { RemoteData, fromResource } from '@shared/application/remote-data';
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
import {
RegistratieState,
RegistratieMsg,
Draft,
DraftField,
Correspondentie,
StepId,
initial,
reduce,
STEPS,
} from '@registratie/domain/registratie-wizard.machine';
import { submitRegistratie } from '@registratie/application/submit-registratie';
const STORAGE_KEY = 'registratie-v1'; // ponytail: bump the suffix if the persisted shape changes; no migration.
const KANALEN = [{ value: 'email', label: 'E-mail' }, { value: 'post', label: 'Post' }];
const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
/** Organism: the BIG-registration wizard. All state lives in one signal driven by
the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the
draft via an effect; the DUO diploma list renders through <app-async>; choosing
a diploma reveals its server-derived beroep. The draft is persisted to
localStorage so a reload keeps the user's progress. Built entirely from existing
atoms/molecules. */
@Component({
selector: 'app-registratie-wizard',
imports: [
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,
AlertComponent, SpinnerComponent, SkeletonComponent, DataRowComponent, StepperComponent, ...ASYNC,
],
template: `
@switch (state().tag) {
@case ('Invullen') {
<app-stepper class="app-section" [steps]="stepLabels" [current]="cursor()" />
<h2 #stepHeading tabindex="-1" class="rhc-heading nl-heading--level-2 app-section">{{ stepTitle() }}</h2>
<form (ngSubmit)="onPrimary()" class="app-form">
@switch (step()) {
@case ('adres') {
@if (adresStatus() === 'laden') {
<app-skeleton height="2.5rem" [count]="4" />
} @else {
@switch (adresStatus()) {
@case ('gevonden') {
<app-alert type="info">Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert>
}
@case ('geen') {
<app-alert type="warning">We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert>
}
@case ('fout') {
<app-alert type="warning">We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in.</app-alert>
}
}
<app-form-field label="Straat en huisnummer" fieldId="straat" [error]="err('straat')">
<app-text-input inputId="straat" [invalid]="!!err('straat')" [ngModel]="draft().straat ?? ''" (ngModelChange)="set('straat', $event)" name="straat" />
</app-form-field>
<app-form-field label="Postcode" fieldId="postcode" [error]="err('postcode')">
<app-text-input inputId="postcode" [invalid]="!!err('postcode')" [ngModel]="draft().postcode ?? ''" (ngModelChange)="set('postcode', $event)" name="postcode" placeholder="1234 AB" />
</app-form-field>
<app-form-field label="Woonplaats" fieldId="woonplaats" [error]="err('woonplaats')">
<app-text-input inputId="woonplaats" [invalid]="!!err('woonplaats')" [ngModel]="draft().woonplaats ?? ''" (ngModelChange)="set('woonplaats', $event)" name="woonplaats" />
</app-form-field>
<app-form-field label="Hoe wilt u correspondentie ontvangen?" fieldId="correspondentie" [error]="err('correspondentie')">
<app-radio-group name="correspondentie" [options]="kanalen" [invalid]="!!err('correspondentie')"
[ngModel]="draft().correspondentie ?? ''" (ngModelChange)="setKanaal($event)" />
</app-form-field>
@if (draft().correspondentie === 'email') {
<app-form-field label="E-mailadres" fieldId="email" [error]="err('email')">
<app-text-input inputId="email" type="email" [invalid]="!!err('email')" [ngModel]="draft().email ?? ''" (ngModelChange)="set('email', $event)" name="email" placeholder="naam@voorbeeld.nl" />
</app-form-field>
}
}
}
@case ('beroep') {
<app-async [data]="lookupRd()">
<ng-template appAsyncLoaded let-data>
<app-form-field label="Kies het diploma waarmee u zich wilt registreren" fieldId="diploma" [error]="err('diploma')">
<app-radio-group name="diploma" [options]="diplomaOptions($any(data))" [invalid]="!!err('diploma')"
[ngModel]="diplomaKeuze()" (ngModelChange)="onDiplomaKeuze($any(data), $event)" />
</app-form-field>
@if (handmatigActief()) {
<app-alert type="warning">Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig beoordeeld.</app-alert>
<app-form-field label="Voor welk beroep wilt u zich registreren?" fieldId="hm-beroep" [error]="err('diploma')">
<app-radio-group name="hm-beroep" [options]="beroepOptions($any(data))" [invalid]="!!err('diploma')"
[ngModel]="draft().beroep ?? ''" (ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })" />
</app-form-field>
} @else if (draft().beroep) {
<dl class="rhc-data-summary rhc-data-summary--row app-section">
<app-data-row key="Beroep (afgeleid uit diploma)" [value]="draft().beroep ?? ''" />
</dl>
}
@for (q of actieveVragen($any(data)); track q.id) {
<app-form-field [label]="q.vraag" [fieldId]="'vraag-' + q.id" [error]="vraagErr(q.id)">
@if (q.type === 'ja-nee') {
<app-radio-group [name]="'vraag-' + q.id" [options]="jaNee" [invalid]="!!vraagErr(q.id)"
[ngModel]="draft().antwoorden[q.id] ?? ''" (ngModelChange)="dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })" [ngModelOptions]="{ standalone: true }" />
} @else {
<app-text-input [inputId]="'vraag-' + q.id" [invalid]="!!vraagErr(q.id)" [ngModel]="draft().antwoorden[q.id] ?? ''"
(ngModelChange)="dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })" [ngModelOptions]="{ standalone: true }" />
}
</app-form-field>
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
</app-async>
}
@case ('controle') {
<app-alert type="info">Controleer uw gegevens en dien de registratie in.</app-alert>
<dl class="rhc-data-summary rhc-data-summary--row app-section">
<app-data-row key="Adres" [value]="adresSamenvatting()" />
<app-data-row key="Herkomst adres" [value]="adresHerkomstLabel()" />
<app-data-row key="Correspondentie" [value]="correspondentieLabel()" />
@if (draft().correspondentie === 'email') {
<app-data-row key="E-mailadres" [value]="draft().email ?? ''" />
}
<app-data-row key="Beroep" [value]="draft().beroep ?? ''" />
<app-data-row key="Herkomst diploma" [value]="diplomaHerkomstLabel()" />
@for (item of samenvattingVragen(); track item.vraag) {
<app-data-row [key]="item.vraag" [value]="item.antwoord" />
}
</dl>
<div class="app-button-row app-section">
<app-button type="button" variant="subtle" (click)="dispatch({ tag: 'GaNaarStap', cursor: 0 })">Adres wijzigen</app-button>
<app-button type="button" variant="subtle" (click)="dispatch({ tag: 'GaNaarStap', cursor: 1 })">Diploma wijzigen</app-button>
</div>
}
}
<div class="app-button-row app-button-row--spaced">
@if (cursor() > 0) {
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
}
<app-button type="submit" variant="primary">{{ step() === 'controle' ? 'Registratie indienen' : 'Volgende' }}</app-button>
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
</div>
</form>
}
@case ('Indienen') {
<app-spinner /> <span>Uw registratie wordt verwerkt…</span>
}
@case ('Ingediend') {
<app-alert type="ok">Uw registratie is ontvangen. Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.</app-alert>
<div class="app-section">
<app-button variant="secondary" (click)="restart()">Nieuwe registratie starten</app-button>
</div>
}
@case ('Mislukt') {
<app-alert type="error">Het indienen is niet gelukt: {{ failedError() }}</app-alert>
<div class="app-section">
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
</div>
}
}
`,
})
export class RegistratieWizardComponent {
private brp = inject(BrpAdapter);
private duo = inject(DuoAdapter);
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
protected adresRes = this.brp.adresResource();
private diplomasRes = this.duo.diplomasResource();
/** Optional seed so Storybook / tests can mount any state directly. */
seed = input<RegistratieState>(initial);
readonly kanalen = KANALEN;
readonly stepLabels = ['Adres', 'Beroep', 'Controle']; // short labels for the stepper
private stepTitles = ['Adres en correspondentievoorkeur', 'Beroep op basis van uw diploma', 'Controleren en indienen'];
readonly state = this.store.model;
readonly dispatch = this.store.dispatch;
/** Focus target: the step heading receives focus on step change (a11y). */
private stepHeading = viewChild<ElementRef<HTMLElement>>('stepHeading');
private invullen = computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract<RegistratieState, { tag: 'Invullen' }>) : null));
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]);
protected referentie = computed(() => (this.state().tag === 'Ingediend' ? (this.state() as Extract<RegistratieState, { tag: 'Ingediend' }>).referentie : ''));
protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract<RegistratieState, { tag: 'Mislukt' }>).error : ''));
protected adresSamenvatting = computed(() => {
const d = this.draft();
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')].filter(Boolean).join(', ');
});
// Readable labels for the controle summary (instead of raw enum values).
protected adresHerkomstLabel = computed(() => ({ brp: 'Automatisch uit de BRP', handmatig: 'Handmatig ingevoerd' }[this.draft().adresHerkomst ?? 'handmatig']));
protected correspondentieLabel = computed(() => ({ email: 'Per e-mail', post: 'Per post' }[this.draft().correspondentie ?? 'post']));
protected diplomaHerkomstLabel = computed(() => ({ duo: 'Geverifieerd via DUO', handmatig: 'Handmatig ingevoerd (wordt beoordeeld)' }[this.draft().diplomaHerkomst ?? 'handmatig']));
/** BRP lookup outcome. A failure or "geen adres" never blocks the wizard — both
fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a
malformed response; 'fout' is an unreachable BRP. */
protected adresStatus = computed<'laden' | 'gevonden' | 'geen' | 'fout'>(() => {
const st = this.adresRes.status();
if (st === 'loading' || st === 'reloading') return 'laden';
if (st === 'error') return 'fout';
const json = this.adresRes.value();
const parsed = json !== undefined ? parseBrpAddress(json) : null;
return parsed && parsed.ok && parsed.value.gevonden ? 'gevonden' : 'geen';
});
/** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */
protected lookupRd = computed<RemoteData<Error | undefined, DuoLookupDto>>(() => {
const rd = fromResource(this.diplomasRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseDuoLookup(rd.value);
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
});
/** Parsed lookup as a plain value (or null) — used outside the beroep step (the
controle summary) where the <app-async> template variable isn't in scope. */
private duoData = computed<DuoLookupDto | null>(() => {
const rd = this.lookupRd();
return rd.tag === 'Success' ? rd.value : null;
});
readonly jaNee = JA_NEE;
protected err = (k: DraftField | 'correspondentie' | 'diploma') => this.invullen()?.errors[k] ?? '';
protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? '';
protected set = (key: DraftField, value: string) => this.dispatch({ tag: 'SetField', key, value });
protected setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
/** True while the user is entering a diploma manually (not in the DUO list). */
protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig');
/** The radio selection: a diploma id, or the "not listed" sentinel in manual mode. */
protected diplomaKeuze = computed(() => (this.handmatigActief() ? HANDMATIG : this.draft().diplomaId ?? ''));
protected diplomaOptions = (data: DuoLookupDto) => [
...data.diplomas.map((d) => ({ value: d.id, label: `${d.naam}${d.instelling} (${d.jaar})` })),
{ value: HANDMATIG, label: 'Mijn diploma staat er niet bij' },
];
protected beroepOptions = (data: DuoLookupDto) => data.handmatig.beroepen.map((b) => ({ value: b, label: b }));
/** The policy questions that apply to the current choice (server-decided). */
protected actieveVragen = (data: DuoLookupDto): PolicyQuestionDto[] => {
if (this.handmatigActief()) return data.handmatig.policyQuestions;
return data.diplomas.find((d) => d.id === this.draft().diplomaId)?.policyQuestions ?? [];
};
/** Answered policy questions for the controle summary (question text + answer). */
protected samenvattingVragen = computed(() => {
const data = this.duoData();
const d = this.draft();
if (!data) return [] as { vraag: string; antwoord: string }[];
const alle = [...data.diplomas.flatMap((x) => x.policyQuestions), ...data.handmatig.policyQuestions];
return (d.vraagIds ?? []).map((id) => ({ vraag: alle.find((q) => q.id === id)?.vraag ?? id, antwoord: d.antwoorden[id] ?? '' }));
});
protected onDiplomaKeuze(data: DuoLookupDto, id: string) {
if (id === HANDMATIG) {
this.dispatch({ tag: 'KiesHandmatig', vraagIds: data.handmatig.policyQuestions.map((q) => q.id) });
return;
}
const d = data.diplomas.find((x) => x.id === id);
if (d) this.dispatch({ tag: 'KiesDiploma', diplomaId: d.id, beroep: d.beroep, vraagIds: d.policyQuestions.map((q) => q.id) });
}
constructor() {
// An explicit seed (stories) wins; otherwise resume from localStorage.
const seeded = this.seed();
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) }));
// Persist only while filling in; clear once the flow is done.
effect(() => {
const s = this.state();
if (s.tag === 'Invullen') localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
else localStorage.removeItem(STORAGE_KEY);
});
// Prefill the address from the BRP lookup as it arrives. Track only the resource
// value; untrack the dispatch (it reads the state signal, which would otherwise
// make this effect loop on its own write). Don't clobber edits/restored data.
effect(() => {
const json = this.adresRes.value();
if (!json) return;
const parsed = parseBrpAddress(json);
untracked(() => {
const s = this.state();
if (s.tag !== 'Invullen' || s.draft.straat) return;
// ponytail: slice 2 handles gevonden === false -> manual entry stays handmatig.
if (parsed.ok && parsed.value.gevonden && parsed.value.adres) {
const a = parsed.value.adres;
this.dispatch({ tag: 'PrefillAdres', straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats });
}
});
});
// A11y: move focus to the step heading when the STEP changes (depends only on
// cursor(), which is value-stable across keystrokes — so typing never steals
// focus). Skip the first run so we don't grab focus on initial load.
let firstStep = true;
effect(() => {
this.cursor();
if (firstStep) { firstStep = false; return; }
untracked(() => {
if (this.state().tag !== 'Invullen') return;
queueMicrotask(() => this.stepHeading()?.nativeElement.focus());
});
});
}
private restore(): RegistratieState | null {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as RegistratieState;
} catch {
return null; // ponytail: corrupt entry -> start fresh, no migration.
}
}
onPrimary() {
const s = this.state();
if (s.tag !== 'Invullen') return;
this.dispatch(this.step() === 'controle' ? { tag: 'Submit' } : { tag: 'Next' });
this.runIfIndienen();
}
onRetry() {
this.dispatch({ tag: 'Retry' });
this.runIfIndienen();
}
/** Reset the wizard to a fresh start. Reload the BRP lookup so the address
re-prefills, keeping the form and the "vooraf ingevuld" note consistent. */
restart() {
this.dispatch({ tag: 'Seed', state: initial });
this.adresRes.reload();
}
/** The effect: when we enter Indienen, call the backend, then dispatch the
outcome (success carries the confirmation reference). */
private async runIfIndienen() {
const s = this.state();
if (s.tag !== 'Indienen') return;
const r = await submitRegistratie(s.data);
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
}
}

View File

@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { RegistratieWizardComponent } from './registratie-wizard.component';
import { Draft, RegistratieState, ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
import { Postcode } from '@registratie/domain/value-objects/postcode';
const adres: Partial<Draft> = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', adresHerkomst: 'brp', correspondentie: 'post' };
const filled: Partial<Draft> = { ...adres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo', vraagIds: [] };
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({ tag: 'Invullen', draft: { antwoorden: {}, ...draft }, cursor, errors: {} });
const validData: ValidRegistratie = {
adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' },
adresHerkomst: 'brp',
correspondentie: 'post',
diplomaId: 'd1',
diplomaHerkomst: 'duo',
beroep: 'Arts',
antwoorden: {},
};
const meta: Meta<RegistratieWizardComponent> = {
title: 'Registratie/RegistratieWizard',
component: RegistratieWizardComponent,
decorators: [applicationConfig({ providers: [provideHttpClient()] })],
};
export default meta;
type Story = StoryObj<RegistratieWizardComponent>;
export const Adres: Story = { args: { seed: invullen(adres, 0) } };
export const Beroep: Story = { args: { seed: invullen(filled, 1) } };
/** English-language diploma → the Dutch-proficiency policy question appears. */
export const BeroepEngelstalig: Story = { args: { seed: invullen({ ...adres, diplomaId: 'd2', beroep: 'Arts', diplomaHerkomst: 'duo', vraagIds: ['nl-taalvaardigheid'] }, 1) } };
/** Diploma not in DUO → declare beroep + the maximal policy-question set. */
export const BeroepHandmatig: Story = { args: { seed: invullen({ ...adres, diplomaId: 'handmatig', diplomaHerkomst: 'handmatig', vraagIds: ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'] }, 1) } };
export const Controle: Story = { args: { seed: invullen(filled, 2) } };
export const Indienen: Story = { args: { seed: { tag: 'Indienen', data: validData } } };
export const Ingediend: Story = { args: { seed: { tag: 'Ingediend', data: validData, referentie: 'BIG-2026-123456' } } };
export const Mislukt: Story = { args: { seed: { tag: 'Mislukt', data: validData, error: 'Netwerkfout' } } };

View File

@@ -0,0 +1,27 @@
import { Component } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/registratie-wizard.component';
/** Page: register in the BIG-register. Built entirely from existing building
blocks (page shell + alert + the registratie-wizard organism). */
@Component({
selector: 'app-registratie-page',
imports: [PageShellComponent, AlertComponent, RegistratieWizardComponent],
template: `
<app-page-shell
heading="Inschrijven in het BIG-register"
backLink="/dashboard"
[breadcrumb]="[{ label: 'Mijn omgeving', link: '/dashboard' }, { label: 'Inschrijven in het BIG-register' }]">
<app-alert type="info">
In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het
diploma waarmee u zich registreert, en een controle. Uw gegevens blijven bewaard
als u de pagina herlaadt.
</app-alert>
<div class="app-section">
<app-registratie-wizard />
</div>
</app-page-shell>
`,
})
export class RegistratiePage {}

View File

@@ -0,0 +1,39 @@
import { Component, input } from '@angular/core';
import { RouterLink } from '@angular/router';
export interface BreadcrumbItem {
label: string;
link?: string; // omit on the current (last) page
}
/** Chrome: breadcrumb navigation. Renders the RHC/Utrecht breadcrumb pattern; the
last item is the current page (no link, aria-current). Domain-free — the caller
supplies the trail. */
@Component({
selector: 'app-breadcrumb',
imports: [RouterLink],
styles: [`
:host{display:block}
.list{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;list-style:none;margin:0;padding:0}
.sep{color:var(--rhc-color-foreground-subtle)}
`],
template: `
<nav class="rhc-breadcrumb-nav utrecht-breadcrumb-nav" aria-label="Kruimelpad">
<ol class="utrecht-breadcrumb-nav__list list">
@for (item of items(); track item.label; let last = $last) {
<li class="utrecht-breadcrumb-nav__item">
@if (item.link && !last) {
<a class="utrecht-breadcrumb-nav__link rhc-link nl-link" [routerLink]="item.link">{{ item.label }}</a>
<span class="sep" aria-hidden="true">/</span>
} @else {
<span class="utrecht-breadcrumb-nav__link rhc-breadcrumb-nav__link--current" aria-current="page">{{ item.label }}</span>
}
</li>
}
</ol>
</nav>
`,
})
export class BreadcrumbComponent {
items = input.required<BreadcrumbItem[]>();
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { BreadcrumbComponent } from './breadcrumb.component';
const meta: Meta<BreadcrumbComponent> = {
title: 'Layout/Breadcrumb',
component: BreadcrumbComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({
props: args,
template: `<app-breadcrumb [items]="items" />`,
}),
};
export default meta;
type Story = StoryObj<BreadcrumbComponent>;
export const TweeNiveaus: Story = {
args: { items: [{ label: 'Mijn omgeving', link: '/dashboard' }, { label: 'Inschrijven in het BIG-register' }] },
};
export const DrieNiveaus: Story = {
args: { items: [{ label: 'Mijn omgeving', link: '/dashboard' }, { label: 'Registratie', link: '/registratie' }, { label: 'Inschrijven' }] },
};

View File

@@ -1,22 +1,31 @@
import { Component, input } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { LinkComponent } from '@shared/ui/link/link.component';
import { BreadcrumbComponent, BreadcrumbItem } from '@shared/layout/breadcrumb/breadcrumb.component';
/** Template: standard page body — optional back-link, a heading, optional intro,
and projected content. Rendered inside the persistent ShellComponent via the
router outlet, so it owns only the content (not the header/footer chrome). */
/** Template: standard page body — optional breadcrumb, optional back-link, a
heading, optional intro, and projected content. Rendered inside the persistent
ShellComponent via the router outlet, so it owns only the content (not chrome). */
@Component({
selector: 'app-page-shell',
imports: [HeadingComponent, LinkComponent],
styles: [':host{display:block}'],
imports: [HeadingComponent, LinkComponent, BreadcrumbComponent],
styles: [`
:host{display:block}
.body--narrow{max-inline-size:var(--app-form-narrow)}
.crumb{margin-block-end:var(--rhc-space-max-xl)}
.intro{margin-block-end:var(--rhc-space-max-2xl)}
`],
template: `
<div [style.max-width]="width() === 'narrow' ? '32rem' : null">
<div [class.body--narrow]="width() === 'narrow'">
@if (breadcrumb()) {
<app-breadcrumb class="crumb" [items]="breadcrumb()!" />
}
@if (backLink()) {
<p><app-link [to]="backLink()!">← {{ backLabel() }}</app-link></p>
}
<app-heading [level]="1">{{ heading() }}</app-heading>
@if (intro()) {
<p class="rhc-paragraph" style="margin-bottom:1.5rem">{{ intro() }}</p>
<p class="rhc-paragraph intro">{{ intro() }}</p>
}
<ng-content />
</div>
@@ -28,4 +37,5 @@ export class PageShellComponent {
backLink = input<string>();
backLabel = input('Terug naar overzicht');
width = input<'default' | 'narrow'>('default');
breadcrumb = input<BreadcrumbItem[]>();
}

View File

@@ -8,13 +8,19 @@ import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.comp
@Component({
selector: 'app-shell',
imports: [RouterOutlet, SiteHeaderComponent, SiteFooterComponent],
styles: [':host{display:block}'],
styles: [`
:host{display:block}
.skip{position:absolute;left:var(--app-skip-link-offset)}
.layout{min-block-size:100vh;align-items:stretch}
.main{flex:1;inline-size:100%;box-sizing:border-box}
.content{max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-3xl) var(--rhc-space-max-2xl);box-sizing:border-box}
`],
template: `
<a href="#main" class="rhc-skip-link" style="position:absolute;left:-999px">Naar de inhoud</a>
<div class="utrecht-page-layout utrecht-page-layout--stretch" style="--app-content-max:64rem;min-height:100vh;align-items:stretch">
<a href="#main" class="rhc-skip-link skip">Naar de inhoud</a>
<div class="utrecht-page-layout utrecht-page-layout--stretch layout">
<app-site-header />
<main id="main" class="utrecht-page-content" style="flex:1;width:100%;box-sizing:border-box">
<div style="max-width:var(--app-content-max);margin:0 auto;padding:2rem 1.5rem;box-sizing:border-box">
<main id="main" class="utrecht-page-content main">
<div class="content">
<router-outlet />
</div>
</main>

View File

@@ -3,13 +3,18 @@ import { Component } from '@angular/core';
/** Organism: site footer. */
@Component({
selector: 'app-site-footer',
styles: [':host{display:block}'],
styles: [`
:host{display:block}
.bar{background:var(--rhc-color-donkerblauw-700);color:var(--rhc-color-wit);margin-block-start:var(--rhc-space-max-5xl);inline-size:100%}
.inner{max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-2xl);display:flex;gap:var(--rhc-space-max-3xl);flex-wrap:wrap;box-sizing:border-box}
.end{margin-inline-start:auto}
`],
template: `
<footer class="utrecht-page-footer" style="background:var(--rhc-color-lintblauw-900,#01689b);color:#fff;margin-top:3rem;width:100%">
<div style="max-width:var(--app-content-max);margin:0 auto;padding:1.5rem;display:flex;gap:2rem;flex-wrap:wrap;box-sizing:border-box">
<footer class="utrecht-page-footer bar">
<div class="inner">
<span>BIG-register</span>
<span>CIBG — Ministerie van Volksgezondheid, Welzijn en Sport</span>
<span style="margin-left:auto;opacity:0.85">Demo / POC — geen echte gegevens</span>
<span class="end">Demo / POC — geen echte gegevens</span>
</div>
</footer>
`,

View File

@@ -8,17 +8,26 @@ import { RouterLink } from '@angular/router';
imports: [RouterLink],
// :host display:block so the full-bleed bar fills the flex column (custom
// elements default to display:inline, which collapsed the bar to its content).
styles: [':host{display:block}'],
styles: [`
:host{display:block}
.bar{background:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit);inline-size:100%}
.inner{display:flex;align-items:center;gap:var(--rhc-space-max-xl);max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-xl) var(--rhc-space-max-2xl);box-sizing:border-box}
.brand{display:flex;align-items:center;gap:var(--rhc-space-max-lg);color:inherit;text-decoration:none}
.mark{display:inline-block;inline-size:var(--rhc-space-max-md);block-size:var(--rhc-space-max-4xl);background:var(--rhc-color-wit)}
.name{font-weight:var(--rhc-text-font-weight-bold);line-height:var(--rhc-text-line-height-sm)}
.sub{font-weight:var(--rhc-text-font-weight-regular);font-size:var(--rhc-text-font-size-sm)}
.portal{margin-inline-start:auto;font-weight:var(--rhc-text-font-weight-semi-bold)}
`],
template: `
<header class="utrecht-page-header" style="background:var(--rhc-color-lintblauw-700,#154273);color:#fff;width:100%">
<div style="display:flex;align-items:center;gap:1rem;max-width:var(--app-content-max);margin:0 auto;padding:1rem 1.5rem;box-sizing:border-box">
<a routerLink="/dashboard" style="display:flex;align-items:center;gap:0.75rem;color:inherit;text-decoration:none">
<span aria-hidden="true" style="display:inline-block;width:0.5rem;height:2.25rem;background:#fff"></span>
<span style="font-weight:700;line-height:1.1">
Rijksoverheid<br><span style="font-weight:400;font-size:0.9rem">BIG-register</span>
<header class="utrecht-page-header bar">
<div class="inner">
<a routerLink="/dashboard" class="brand">
<span aria-hidden="true" class="mark"></span>
<span class="name">
Rijksoverheid<br><span class="sub">BIG-register</span>
</span>
</a>
<span style="margin-left:auto;font-weight:500">{{ subtitle() }}</span>
<span class="portal">{{ subtitle() }}</span>
</div>
</header>
`,

View File

@@ -35,6 +35,7 @@ export class AsyncErrorDirective {
selector: 'app-async',
imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],
template: `
<div aria-live="polite" [attr.aria-busy]="rd().tag === 'Loading' ? 'true' : null">
@switch (rd().tag) {
@case ('Loading') {
@if (loadingTpl()) { <ng-container [ngTemplateOutlet]="loadingTpl()!.tpl" /> }
@@ -60,6 +61,7 @@ export class AsyncErrorDirective {
[ngTemplateOutletContext]="{ $implicit: value() }" />
}
}
</div>
`,
})
export class AsyncComponent<T> {

View File

@@ -6,13 +6,13 @@ import { Component, input } from '@angular/core';
selector: 'app-form-field',
template: `
<div class="rhc-form-field utrecht-form-field" [class.utrecht-form-field--invalid]="!!error()">
<label class="utrecht-form-label" [for]="fieldId()">{{ label() }}</label>
<label class="utrecht-form-label" [id]="fieldId() + '-label'" [for]="fieldId()">{{ label() }}</label>
@if (description()) {
<div class="utrecht-form-field-description">{{ description() }}</div>
<div class="utrecht-form-field-description" [id]="fieldId() + '-desc'">{{ description() }}</div>
}
<ng-content />
@if (error()) {
<div class="utrecht-form-field-error-message" role="alert">{{ error() }}</div>
<div class="utrecht-form-field-error-message" [id]="fieldId() + '-error'" role="alert">{{ error() }}</div>
}
</div>
`,

View File

@@ -10,10 +10,23 @@ export interface RadioOption {
form control so it works with ngModel just like the text-input atom. */
@Component({
selector: 'app-radio-group',
styles: [`
.rhc-radio-option {
display: flex;
align-items: center;
gap: var(--rhc-space-max-md);
padding-block: var(--rhc-space-max-sm);
}
`],
template: `
<div class="utrecht-form-field-radio-group" role="radiogroup">
<div
class="utrecht-form-field-radio-group"
role="radiogroup"
[attr.aria-labelledby]="name() + '-label'"
[attr.aria-invalid]="invalid() ? 'true' : null"
[attr.aria-describedby]="invalid() ? name() + '-error' : null">
@for (opt of options(); track opt.value) {
<label class="utrecht-form-label utrecht-form-label--radio-button" style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0">
<label class="utrecht-form-label utrecht-form-label--radio-button rhc-radio-option">
<input
class="utrecht-radio-button"
[class.utrecht-radio-button--checked]="value === opt.value"
@@ -34,6 +47,7 @@ export interface RadioOption {
export class RadioGroupComponent implements ControlValueAccessor {
options = input.required<RadioOption[]>();
name = input.required<string>();
invalid = input(false);
value = '';
disabled = false;

View File

@@ -6,15 +6,15 @@ import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/
selector: 'app-skeleton',
styles: [`
:host{display:block}
.sk{background:linear-gradient(90deg,#e8ebee 25%,#f3f5f6 37%,#e8ebee 63%);
background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:4px;
margin-block-end:0.6rem}
.sk{background:linear-gradient(90deg,var(--rhc-color-cool-grey-200) 25%,var(--rhc-color-cool-grey-100) 37%,var(--rhc-color-cool-grey-200) 63%);
background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:var(--rhc-border-radius-md);
margin-block-end:var(--rhc-space-max-lg)}
@keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}
`],
template: `
@if (visible()) {
@for (l of lines(); track $index) {
<div class="sk" [style.width]="width()" [style.height]="height()"></div>
<div class="sk" aria-hidden="true" [style.width]="width()" [style.height]="height()"></div>
}
}
`,

View File

@@ -6,10 +6,10 @@ import { Component, OnDestroy, OnInit, input, signal } from '@angular/core';
selector: 'app-spinner',
styles: [`
:host{display:block}
.sp{width:2rem;height:2rem;border-radius:50%;
border:3px solid var(--rhc-color-grijs-300,#cad0d6);
border-block-start-color:var(--rhc-color-lintblauw-700,#154273);
animation:sp 0.8s linear infinite;margin:1.5rem auto}
.sp{inline-size:var(--rhc-space-max-3xl);block-size:var(--rhc-space-max-3xl);border-radius:var(--rhc-border-radius-round);
border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-300);
border-block-start-color:var(--rhc-color-lintblauw-700);
animation:sp 0.8s linear infinite;margin:var(--rhc-space-max-2xl) auto}
@keyframes sp{to{transform:rotate(360deg)}}
`],
template: `

View File

@@ -5,8 +5,9 @@ import { Component, input } from '@angular/core';
This keeps the shared UI kernel free of any domain knowledge. */
@Component({
selector: 'app-status-badge',
styles: [`.badge{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md)}`],
template: `
<span style="display:inline-flex;align-items:center;gap:0.5rem">
<span class="badge">
<span class="rhc-dot-badge" [style.background-color]="color()" aria-hidden="true"></span>
<span>{{ label() }}</span>
</span>

View File

@@ -0,0 +1,46 @@
import { Component, input } from '@angular/core';
/** Molecule: a horizontal step indicator for a multi-step flow. Visual progress
plus accessible semantics — an ordered list with `aria-current="step"` on the
active step and a visually-hidden "Stap X van Y" summary. Domain-free. */
@Component({
selector: 'app-stepper',
styles: [`
:host{display:block}
.sr-only{position:absolute;inline-size:1px;block-size:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.steps{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-xl);list-style:none;margin:0;padding:0}
.step{display:flex;align-items:center;gap:var(--rhc-space-max-md);color:var(--rhc-color-foreground-subtle)}
.num{
display:grid;place-items:center;
inline-size:var(--rhc-space-max-4xl);block-size:var(--rhc-space-max-4xl);
border-radius:var(--rhc-border-radius-round);
border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-400);
font-weight:var(--rhc-text-font-weight-bold);
}
.step--done .num{background:var(--rhc-color-lintblauw-500);border-color:var(--rhc-color-lintblauw-500);color:var(--rhc-color-wit)}
.step--current{color:var(--rhc-color-foreground-default)}
.step--current .num{background:var(--rhc-color-lintblauw-700);border-color:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit)}
.step--current .label{font-weight:var(--rhc-text-font-weight-semi-bold)}
`],
template: `
<nav aria-label="Voortgang">
<p class="sr-only">Stap {{ current() + 1 }} van {{ steps().length }}: {{ steps()[current()] }}</p>
<ol class="steps">
@for (label of steps(); track label; let i = $index) {
<li
class="step"
[class.step--current]="i === current()"
[class.step--done]="i < current()"
[attr.aria-current]="i === current() ? 'step' : null">
<span class="num" aria-hidden="true">{{ i + 1 }}</span>
<span class="label">{{ label }}</span>
</li>
}
</ol>
</nav>
`,
})
export class StepperComponent {
steps = input.required<string[]>();
current = input.required<number>();
}

View File

@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { StepperComponent } from './stepper.component';
const meta: Meta<StepperComponent> = {
title: 'Molecules/Stepper',
component: StepperComponent,
render: (args) => ({
props: args,
template: `<app-stepper [steps]="steps" [current]="current" />`,
}),
};
export default meta;
type Story = StoryObj<StepperComponent>;
const steps = ['Adres', 'Beroep', 'Controle'];
export const Eerste: Story = { args: { steps, current: 0 } };
export const Midden: Story = { args: { steps, current: 1 } };
export const Laatste: Story = { args: { steps, current: 2 } };

View File

@@ -10,6 +10,8 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
[class.utrecht-textbox--invalid]="invalid()"
[type]="type()"
[id]="inputId()"
[attr.aria-invalid]="invalid() ? 'true' : null"
[attr.aria-describedby]="invalid() && inputId() ? inputId() + '-error' : null"
[placeholder]="placeholder()"
[disabled]="disabled"
[value]="value"

View File

@@ -142,24 +142,24 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
</div>
</section>
<!-- 5. Branching: steps derived from answers -->
<!-- 5. Fixed steps, questions revealed inline -->
<section class="section">
<app-heading [level]="2">5 · Vertakkende vragenlijst — "afleiden, niet opslaan"</app-heading>
<app-heading [level]="2">5 · Vragenlijst met vaste stappen — "vragen tonen, niet stappen toevoegen"</app-heading>
<p class="lead">
Welke stappen bestaan is geen opgeslagen toestand maar een <em>pure functie</em> van de antwoorden
(<code>visibleSteps(answers)</code>). Antwoord "ja" op buitenland of vul weinig uren in, en de
lijst hieronder groeit mee.
Het aantal stappen ligt vast (<code>STEPS</code>); vervolgvragen verschijnen <em>binnen</em> een stap
op basis van eerdere antwoorden. Antwoord "ja" op buitenland of vul weinig uren in, en er komt een
extra vraag bij in dezelfde stap — de voortgang "van N" blijft gelijk.
</p>
<div class="cols">
<div class="card card--good">
<p class="tag good">Live afgeleide stappen</p>
<p class="tag good">Vaste stappen</p>
<div class="steplist">
@for (s of iw.steps(); track s; let last = $last) {
<span class="pill" [class.extra]="s === 'buitenlandDetails' || s === 'scholing'">{{ s }}</span>
@for (s of iw.steps; track s; let last = $last) {
<span class="pill">{{ s }}</span>
@if (!last) { <span class="arrow">→</span> }
}
</div>
<p class="note">De gestippelde stappen verschijnen alleen door eerdere antwoorden. De voortgang "van N" verandert mee.</p>
<p class="note">De stappen zijn altijd dezelfde; alleen de vragen <em>binnen</em> een stap verschijnen of verdwijnen.</p>
</div>
<div class="card card--good">
<p class="tag good">De wizard</p>

View File

@@ -7,6 +7,31 @@
html, body { margin: 0; min-height: 100%; }
/* App theme layer: a few app-specific measures RHC has no token for (content/form
widths), defined ONCE here and mapped onto RHC where possible. Components reference
these tokens instead of raw values. */
:root {
--app-content-max: 64rem; /* readable page column */
--app-form-max: 30rem; /* default wizard form width */
--app-form-narrow: 32rem; /* page-shell narrow variant */
--app-skip-link-offset: -999px; /* off-screen skip link */
}
/* App utility classes: centralise the repeated inline layout idioms so components stay
token-based and free of raw values. */
.app-form { max-inline-size: var(--app-form-max); }
.app-button-row {
display: flex;
flex-wrap: wrap;
gap: var(--rhc-space-max-md);
align-items: center;
}
.app-button-row--spaced { margin-block-start: var(--rhc-space-max-xl); }
/* vertical rhythm between stacked blocks */
.app-stack > * + * { margin-block-start: var(--rhc-space-max-xl); }
.app-section { margin-block-start: var(--rhc-space-max-2xl); }
.app-text-subtle { color: var(--rhc-color-foreground-subtle); }
/* Route transitions (withViewTransitions): cross-fade the routed CONTENT only.
The chrome gets its own stable view-transition-name so it's lifted out of the
`root` snapshot and stays put while the content fades. */