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: { parameters: {
layout: 'padded', 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: { controls: {
matchers: { matchers: {
color: /(background|color)$/i, 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", "watch": "ng build --watch --configuration development",
"test": "ng test", "test": "ng test",
"storybook": "ng run atomic-design-poc:storybook", "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, "private": true,
"packageManager": "npm@11.12.1", "packageManager": "npm@11.12.1",

View File

@@ -1,6 +1,5 @@
{ {
"naam": "Dr. A. (Anna) de Vries", "gevonden": true,
"geboortedatum": "1985-03-14",
"adres": { "adres": {
"straat": "Lange Voorhout 9", "straat": "Lange Voorhout 9",
"postcode": "2514 EA", "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: '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: '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: '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: '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: 'intake', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/intake.page').then(m => m.IntakePage) },
{ path: 'concepts', loadComponent: () => import('./showcase/concepts.page').then(m => m.ConceptsPage) }, { 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 { Result } from '@shared/kernel/fp';
import { Session } from '../domain/session'; import { Session } from '../domain/session';
import { DigidAdapter } from '../infrastructure/digid.adapter'; 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' * 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 * 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 * 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' }) @Injectable({ providedIn: 'root' })
export class SessionStore { export class SessionStore {
private digid = inject(DigidAdapter); private digid = inject(DigidAdapter);
private _session = signal<Session | null>(null); private _session = signal<Session | null>(restore());
readonly session = this._session.asReadonly(); readonly session = this._session.asReadonly();
readonly isAuthenticated = computed(() => this._session() !== null); 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. */ /** Effectful command: authenticate, then store the session on success. */
async login(bsn: string): Promise<Result<string, Session>> { async login(bsn: string): Promise<Result<string, Session>> {
const r = await this.digid.authenticate(bsn); 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 { import {
Answers, Answers,
initial, initial,
visibleSteps, STEPS,
lageUren,
currentStep, currentStep,
next, next,
back, back,
@@ -13,21 +14,34 @@ import {
IntakeState, IntakeState,
} from './intake.machine'; } 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)', () => { describe('STEPS (fixed) and inline questions', () => {
it('asks only buitenland/uren/punten/review by default', () => { it('always has the same three steps', () => {
expect(visibleSteps({})).toEqual(['buitenland', 'uren', 'punten', 'review']); expect(STEPS).toEqual(['buitenland', 'werk', 'review']);
}); });
it('adds the buitenlandDetails step when worked abroad', () => { it('reveals the buitenland detail questions inline only when worked abroad', () => {
expect(visibleSteps({ buitenlandGewerkt: 'ja' })).toContain('buitenlandDetails'); // No new step; instead these fields become required within the buitenland step.
expect(visibleSteps({ buitenlandGewerkt: 'nee' })).not.toContain('buitenlandDetails'); 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', () => { it('reveals the scholing question only when NL-hours are below the threshold', () => {
expect(visibleSteps({ uren: '500' })).toContain('scholing'); expect(lageUren({ uren: '500' })).toBe(true);
expect(visibleSteps({ uren: '4160' })).not.toContain('scholing'); 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 const s = next(initial); // buitenland unanswered
expect(s.tag).toBe('Answering'); expect(s.tag).toBe('Answering');
expect((s as any).cursor).toBe(0); 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', () => { it('Next advances once the step is valid', () => {
const s = next(answering({ buitenlandGewerkt: 'nee' })); const s = next(answering({ buitenlandGewerkt: 'nee' }));
expect((s as any).cursor).toBe(1); 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', () => { it('editing an answer leaves the cursor fixed (steps never collapse)', () => {
// Worked abroad, cursor sitting on the extra detail step (index 1)... const edited = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
const collapsed = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }); expect((edited as any).cursor).toBe(1); // cursor untouched; only inline questions change
// ...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('Back never goes below the first step', () => { it('Back never goes below the first step', () => {
@@ -58,21 +70,34 @@ describe('navigation', () => {
}); });
describe('submit', () => { 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', () => { 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)); const good = submit(answering(complete));
expect(good.tag).toBe('Submitting'); expect(good.tag).toBe('Submitting');
expect((good as any).data.uren).toBe(4160); 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', () => { it('low hours requires the scholing answer before submit', () => {
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', punten: '200' })); const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' }));
expect(noScholing.tag).toBe('Answering'); // scholing step is required, unanswered expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
const withScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' })); const withScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }));
expect(withScholing.tag).toBe('Submitting'); expect(withScholing.tag).toBe('Submitting');
expect((withScholing as any).data.aanvullendeScholing).toBe(true); expect((withScholing as any).data.aanvullendeScholing).toBe(true);
expect((withScholing as any).data.punten).toBe(200);
}); });
it('resolve maps Submitting to Submitted / Failed', () => { it('resolve maps Submitting to Submitted / Failed', () => {
@@ -85,16 +110,14 @@ describe('submit', () => {
describe('reduce (message-driven happy path)', () => { describe('reduce (message-driven happy path)', () => {
it('drives abroad branch end to end', () => { it('drives abroad branch end to end', () => {
let s: IntakeState = initial; let s: IntakeState = initial;
// Step 1: buitenland — the country/hours questions reveal inline (same step).
s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' }); 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: 'land', value: 'België' });
s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandseUren', value: '800' }); s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandseUren', value: '800' });
s = reduce(s, { tag: 'Next' }); 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: '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: 'SetAnswer', key: 'punten', value: '200' });
s = reduce(s, { tag: 'Next' }); s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('review'); 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'; import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
/** /**
* A BRANCHING wizard. Unlike herregistratie.machine (fixed 2 steps), the set of * A FIXED 3-step wizard with progressive disclosure. The steps never change in
* steps here is NOT stored — it's *derived* from the answers by `visibleSteps`. * number (always `STEPS`); instead, follow-up questions appear *inline within a
* Answer "buiten Nederland gewerkt? → ja" and two extra steps appear; report few * step* depending on earlier answers — answer "buiten Nederland gewerkt? → ja"
* hours and a scholing-question appears. The progress bar's denominator changes * and the country/hours questions reveal in the same step; report few hours and
* as you type. "Which question comes next" is a pure function, so it's trivial * the scholing-question reveals inside the 'werk' step. "Is this field required
* to test and impossible to get out of sync with the data. * 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'; export type JaNee = 'ja' | 'nee';
/** Every possible question, as a union — never a bare string. */ /** The three fixed steps. Each step groups one or more questions. */
export type StepId = 'buitenland' | 'buitenlandDetails' | 'uren' | 'scholing' | 'punten' | 'review'; export type StepId = 'buitenland' | 'werk' | 'review';
/** One record carried across every step (and persisted). All optional: the user /** One record carried across every step (and persisted). All optional: the user
fills it in gradually, and branches may never ask some fields. */ fills it in gradually, and branches may never ask some fields. */
@@ -33,111 +34,115 @@ export interface ValidIntake {
buitenlandseUren?: Uren; buitenlandseUren?: Uren;
uren: Uren; uren: Uren;
aanvullendeScholing?: boolean; 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. */ /** Demo fallback only — the real threshold is a SERVER-OWNED policy value fetched
const LAGE_UREN_DREMPEL = 1000; 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 ?? ''); const r = parseUren(a.uren ?? '');
return r.ok && r.value < LAGE_UREN_DREMPEL; return r.ok && r.value < scholingThreshold;
} }
/** /** The fixed step list. Number of steps never changes; questions reveal inline. */
* THE branching, as one pure function. The step list is recomputed on every export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
* transition, so changing an earlier answer immediately adds/removes later steps.
*/ /** Per-field error map: one message per question, since a step holds several. */
export function visibleSteps(a: Answers): StepId[] { type Errors = Partial<Record<keyof Answers, string>>;
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;
}
export type IntakeState = 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: 'Submitting'; data: ValidIntake }
| { tag: 'Submitted'; data: ValidIntake } | { tag: 'Submitted'; data: ValidIntake }
| { tag: 'Failed'; data: ValidIntake; error: string }; | { 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 { 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. */ /** Validate every question currently visible in ONE step. Errors keyed per field. */
function validateStep(step: StepId, a: Answers): Result<Partial<Record<StepId, string>>, void> { function validateStep(step: StepId, a: Answers, scholingThreshold: number): Result<Errors, void> {
const errors: Errors = {};
switch (step) { switch (step) {
case 'buitenland': case 'buitenland':
return a.buitenlandGewerkt ? ok(undefined) : err({ buitenland: 'Maak een keuze.' }); if (!a.buitenlandGewerkt) errors.buitenlandGewerkt = 'Maak een keuze.';
case 'buitenlandDetails': { else if (a.buitenlandGewerkt === 'ja') {
if (!a.land || a.land.trim() === '') return err({ buitenlandDetails: 'Vul een land in.' }); if (!a.land || a.land.trim() === '') errors.land = 'Vul een land in.';
const u = parseUren(a.buitenlandseUren ?? ''); 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 ?? ''); const u = parseUren(a.uren ?? '');
return u.ok ? ok(undefined) : err({ uren: u.error }); if (!u.ok) errors.uren = u.error;
} if (lageUren(a, scholingThreshold) && !a.scholingGevolgd) errors.scholingGevolgd = 'Maak een keuze.';
case 'scholing': // Nascholingspunten are only asked (and required) when scholing was followed.
return a.scholingGevolgd ? ok(undefined) : err({ scholing: 'Maak een keuze.' }); if (a.scholingGevolgd === 'ja') {
case 'punten': {
const p = parseUren(a.punten ?? ''); const p = parseUren(a.punten ?? '');
return p.ok ? ok(undefined) : err({ punten: p.error }); if (!p.ok) errors.punten = p.error;
}
break;
} }
case 'review': case 'review':
return ok(undefined); // review shows a summary; no own fields break; // review shows a summary; no own fields
default: default:
return assertNever(step); return assertNever(step);
} }
return Object.keys(errors).length > 0 ? err(errors) : ok(undefined);
} }
/** Parse the whole questionnaire into a ValidIntake (called on submit). */ /** Parse the whole questionnaire into a ValidIntake (called on submit). */
function validateAll(a: Answers): Result<Partial<Record<StepId, string>>, ValidIntake> { function validateAll(a: Answers, scholingThreshold: number): Result<Errors, ValidIntake> {
const errors: Partial<Record<StepId, string>> = {}; const errors: Errors = {};
for (const step of visibleSteps(a)) { for (const step of STEPS) {
const r = validateStep(step, a); const r = validateStep(step, a, scholingThreshold);
if (!r.ok) Object.assign(errors, r.error); if (!r.ok) Object.assign(errors, r.error);
} }
if (Object.keys(errors).length > 0) return err(errors); if (Object.keys(errors).length > 0) return err(errors);
const uren = parseUren(a.uren ?? ''); const uren = parseUren(a.uren ?? '');
const punten = parseUren(a.punten ?? ''); // validateStep guaranteed uren parses, but keep the compiler happy.
// visibleSteps guaranteed these parse, but keep the compiler happy. if (!uren.ok) return err(errors);
if (!uren.ok || !punten.ok) return err(errors);
const werktBuitenland = a.buitenlandGewerkt === 'ja'; const werktBuitenland = a.buitenlandGewerkt === 'ja';
const buitenland = parseUren(a.buitenlandseUren ?? ''); 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({ return ok({
werktBuitenland, werktBuitenland,
land: werktBuitenland ? a.land : undefined, land: werktBuitenland ? a.land : undefined,
buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined, buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,
uren: uren.value, uren: uren.value,
aanvullendeScholing: lageUren(a) ? a.scholingGevolgd === 'ja' : undefined, aanvullendeScholing: lageUren(a, scholingThreshold) ? a.scholingGevolgd === 'ja' : undefined,
punten: punten.value, punten: punten?.ok ? punten.value : undefined,
}); });
} }
export function setAnswer(s: IntakeState, key: keyof Answers, value: string): IntakeState { export function setAnswer(s: IntakeState, key: keyof Answers, value: string): IntakeState {
if (s.tag !== 'Answering') return s; if (s.tag !== 'Answering') return s;
const answers = { ...s.answers, [key]: value }; // Steps are fixed, so editing an answer never moves the cursor — it only
// Editing an earlier answer can shrink the step list; clamp the cursor. // reveals/hides inline questions within the current step.
const cursor = Math.min(s.cursor, visibleSteps(answers).length - 1); return { ...s, answers: { ...s.answers, [key]: value } };
return { ...s, answers, cursor };
} }
export function next(s: IntakeState): IntakeState { export function next(s: IntakeState): IntakeState {
if (s.tag !== 'Answering') return s; 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 }; if (!r.ok) return { ...s, errors: r.error };
const last = visibleSteps(s.answers).length - 1; return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };
return { ...s, cursor: Math.min(s.cursor + 1, last), 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 { export function back(s: IntakeState): IntakeState {
@@ -147,7 +152,7 @@ export function back(s: IntakeState): IntakeState {
export function submit(s: IntakeState): IntakeState { export function submit(s: IntakeState): IntakeState {
if (s.tag !== 'Answering') return s; 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 }; return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
} }
@@ -164,6 +169,7 @@ export type IntakeMsg =
| { tag: 'Retry' } | { tag: 'Retry' }
| { tag: 'SubmitConfirmed' } | { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string } | { tag: 'SubmitFailed'; error: string }
| { tag: 'SetPolicy'; scholingThreshold: number }
| { tag: 'Seed'; state: IntakeState }; | { tag: 'Seed'; state: IntakeState };
export function reduce(s: IntakeState, m: IntakeMsg): 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; return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
case 'SubmitFailed': case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s; return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'SetPolicy':
return setPolicy(s, m.scholingThreshold);
case 'Seed': case 'Seed':
return m.state; return m.state;
default: 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 })" <app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
name="jaren" [invalid]="!!errJaren()" placeholder="bijv. 5" /> name="jaren" [invalid]="!!errJaren()" placeholder="bijv. 5" />
</app-form-field> </app-form-field>
<div style="display:flex;gap:0.5rem">
<app-button type="submit" variant="primary">Volgende</app-button> <app-button type="submit" variant="primary">Volgende</app-button>
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
</div>
} @else { } @else {
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="errPunten()"> <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 })" <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"> <div style="display:flex;gap:0.5rem">
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button> <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="submit" variant="primary">Herregistratie aanvragen</app-button>
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
</div> </div>
} }
</form> </form>
@@ -95,6 +99,11 @@ export class HerregistratieWizardComponent {
this.runIfSubmitting(); 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 /** The effect: when we entered Submitting, call the backend command, flip the
optimistic cross-page flag, then dispatch the result (and commit/rollback). */ optimistic cross-page flag, then dispatch the result (and commit/rollback). */
private async runIfSubmitting() { 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 { ASYNC } from '@shared/ui/async/async.component';
import { map } from '@shared/application/remote-data'; import { map } from '@shared/application/remote-data';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
import { isHerregistratieEligible } from '@registratie/domain/registration.policy';
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component'; import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
/** A whole new page built from existing building blocks. Eligibility is a pure /** A whole new page built from existing building blocks. Eligibility is a
domain rule (registration.policy) read from the shared profile state. */ SERVER-computed decision read from the aggregated view — the frontend renders
it, it does not recompute the rule. */
@Component({ @Component({
selector: 'app-herregistratie-page', selector: 'app-herregistratie-page',
imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent], imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent],
@@ -35,8 +35,9 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie
}) })
export class HerregistratiePage { export class HerregistratiePage {
private store = inject(BigProfileStore); 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(() => 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 { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
@@ -15,11 +16,14 @@ import {
StepId, StepId,
initial, initial,
reduce, reduce,
visibleSteps, STEPS,
lageUren,
SCHOLING_THRESHOLD_DEFAULT,
} from '@herregistratie/domain/intake.machine'; } from '@herregistratie/domain/intake.machine';
import { IntakePolicyDto } from '@herregistratie/contracts/intake-policy.dto';
import { submitIntake } from '@herregistratie/application/submit-intake'; 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' }]; const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal /** 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: ` template: `
@switch (state().tag) { @switch (state().tag) {
@case ('Answering') { @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"> <form (ngSubmit)="onPrimary()" style="max-width:30rem">
@switch (step()) { @switch (step()) {
@case ('buitenland') { @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" <app-radio-group name="buitenland" [options]="jaNee"
[ngModel]="answers().buitenlandGewerkt ?? ''" (ngModelChange)="set('buitenlandGewerkt', $event)" /> [ngModel]="answers().buitenlandGewerkt ?? ''" (ngModelChange)="set('buitenlandGewerkt', $event)" />
</app-form-field> </app-form-field>
} @if (answers().buitenlandGewerkt === 'ja') {
@case ('buitenlandDetails') { <app-form-field label="In welk land?" fieldId="land" [error]="err('land')">
<app-form-field label="In welk land?" fieldId="land" [error]="err('buitenlandDetails')">
<app-text-input inputId="land" [ngModel]="answers().land ?? ''" (ngModelChange)="set('land', $event)" name="land" placeholder="bijv. België" /> <app-text-input inputId="land" [ngModel]="answers().land ?? ''" (ngModelChange)="set('land', $event)" name="land" placeholder="bijv. België" />
</app-form-field> </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-text-input inputId="buitenlandseUren" [ngModel]="answers().buitenlandseUren ?? ''" (ngModelChange)="set('buitenlandseUren', $event)" name="buitenlandseUren" placeholder="bijv. 800" />
</app-form-field> </app-form-field>
} }
@case ('uren') { }
@case ('werk') {
<app-form-field label="Gewerkte uren in Nederland (afgelopen 5 jaar)" fieldId="uren" [error]="err('uren')"> <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-text-input inputId="uren" [ngModel]="answers().uren ?? ''" (ngModelChange)="set('uren', $event)" name="uren" placeholder="bijv. 4160" />
</app-form-field> </app-form-field>
} @if (scholingZichtbaar()) {
@case ('scholing') { <app-form-field label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholing" [error]="err('scholingGevolgd')">
<app-form-field label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholing" [error]="err('scholing')">
<app-radio-group name="scholing" [options]="jaNee" <app-radio-group name="scholing" [options]="jaNee"
[ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" /> [ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" />
</app-form-field> </app-form-field>
} }
@case ('punten') { @if (answers().scholingGevolgd === 'ja') {
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="err('punten')"> <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-text-input inputId="punten" [ngModel]="answers().punten ?? ''" (ngModelChange)="set('punten', $event)" name="punten" placeholder="bijv. 200" />
</app-form-field> </app-form-field>
} }
}
@case ('review') { @case ('review') {
<app-alert type="info">Controleer uw antwoorden en dien de aanvraag in.</app-alert> <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"> <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>Buitenlandse uren</dt><dd>{{ answers().buitenlandseUren }}</dd></div>
} }
<div><dt>Uren NL</dt><dd>{{ answers().uren }}</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> <div><dt>Aanvullende scholing</dt><dd>{{ answers().scholingGevolgd }}</dd></div>
} }
@if (answers().scholingGevolgd === 'ja') {
<div><dt>Nascholingspunten</dt><dd>{{ answers().punten }}</dd></div> <div><dt>Nascholingspunten</dt><dd>{{ answers().punten }}</dd></div>
}
</dl> </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="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="submit" variant="primary">{{ step() === 'review' ? 'Aanvraag indienen' : 'Volgende' }}</app-button>
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
</div> </div>
</form> </form>
} }
@@ -112,6 +119,12 @@ export class IntakeWizardComponent {
private profile = inject(BigProfileStore); private profile = inject(BigProfileStore);
private store = createStore<IntakeState, IntakeMsg>(initial, reduce); 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. */ /** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<IntakeState>(initial); seed = input<IntakeState>(initial);
@@ -120,14 +133,18 @@ export class IntakeWizardComponent {
readonly dispatch = this.store.dispatch; readonly dispatch = this.store.dispatch;
private answering = computed(() => (this.state().tag === 'Answering' ? (this.state() as Extract<IntakeState, { tag: 'Answering' }>) : null)); 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. */ /** Public so the showcase can render the (fixed) step list next to the wizard. */
readonly steps = computed<StepId[]>(() => visibleSteps(this.answering()?.answers ?? {})); readonly steps = STEPS;
protected cursor = computed(() => this.answering()?.cursor ?? 0); protected cursor = computed(() => this.answering()?.cursor ?? 0);
protected answers = computed<Answers>(() => this.answering()?.answers ?? {}); 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 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 }); protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value });
constructor() { constructor() {
@@ -140,6 +157,13 @@ export class IntakeWizardComponent {
if (s.tag === 'Answering') localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); if (s.tag === 'Answering') localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
else localStorage.removeItem(STORAGE_KEY); 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 { private restore(): IntakeState | null {

View File

@@ -16,12 +16,14 @@ const meta: Meta<IntakeWizardComponent> = {
export default meta; export default meta;
type Story = StoryObj<IntakeWizardComponent>; 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 Start: Story = { args: { seed: answering({}) } };
export const AbroadBranch: Story = { args: { seed: answering({ buitenlandGewerkt: 'ja' }, 1) } }; // Inline reveal: country/hours appear within the buitenland step (cursor 0).
export const LowHoursScholing: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '500' }, 2) } }; export const AbroadBranch: Story = { args: { seed: answering({ buitenlandGewerkt: 'ja' }, 0) } };
export const Review: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: '200' }, 3) } }; // 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 Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } }; export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } }; 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 { 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 { Aantekening } from '../domain/registration';
import { BigProfile } from '../domain/big-profile'; import { BigProfile } from '../domain/big-profile';
import { HerregistratieDecisions, DashboardView } from '../contracts/dashboard-view.dto';
import { BigRegisterAdapter } from '../infrastructure/big-register.adapter'; 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; type Err = Error | undefined;
@@ -13,29 +14,32 @@ type Err = Error | undefined;
* (created here, in the required injection context) and exposes them as * (created here, in the required injection context) and exposes them as
* RemoteData signals. * RemoteData signals.
* *
* The headline trick: `profile` combines TWO independent services — the * The dashboard data now comes from ONE screen-shaped ("BFF-lite") call that
* BIG-register and the BRP — into ONE RemoteData via map2. A page renders a * returns registration + person + server-computed `decisions`. One request → one
* single state (loading / error / loaded), never juggling three. * 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' }) @Injectable({ providedIn: 'root' })
export class BigProfileStore { export class BigProfileStore {
private big = inject(BigRegisterAdapter); 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 aantekeningenRes = this.big.aantekeningenResource();
private personRes = this.brp.personResource();
/** BIG-register + BRP folded into one state. */ /** The aggregated view, validated at the trust boundary (DTO → domain). */
readonly profile = computed<RemoteData<Err, BigProfile>>(() => private view = computed<RemoteData<Err, DashboardView>>(() => {
map2( const rd = fromResource(this.viewRes);
fromResource(this.registrationRes), if (rd.tag !== 'Success') return rd;
fromResource(this.personRes), const parsed = parseDashboardView(rd.value);
// httpResource types value as T | undefined; in the Success branch it is return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
// always present, so narrowing here is safe. });
(registration, person) => ({ registration: registration!, person: person! }),
), /** 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). */ /** Specialisms/notes stay a separate stream (they have their own empty state). */
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() => readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() =>
@@ -52,7 +56,7 @@ export class BigProfileStore {
} }
confirmHerregistratie() { confirmHerregistratie() {
this.pending.set(false); 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() { rollbackHerregistratie() {
this.pending.set(false); // submission failed — undo the optimistic flag 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 /** 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 { export function isHerregistratieEligible(reg: Registration, today: Date, windowMonths = 12): boolean {
const deadline = herregistratieDeadline(reg); const deadline = herregistratieDeadline(reg);
if (!deadline) return false; 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 { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http'; 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 * Infrastructure adapter for the BIG-register source. Exposes signal-based
* resources (Angular's httpResource); each returns a Resource with * resources (Angular's httpResource); each returns a Resource with
* status()/value()/error()/reload(). Call from an injection context * status()/value()/error()/reload(). Call from an injection context
* (a field initializer in the store). * (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' }) @Injectable({ providedIn: 'root' })
export class BigRegisterAdapter { export class BigRegisterAdapter {
registrationResource() {
return httpResource<Registration>(() => 'mock/registration.json');
}
aantekeningenResource() { aantekeningenResource() {
return httpResource<Aantekening[]>(() => 'mock/notes.json', { defaultValue: [] }); 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 { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http'; 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' }) @Injectable({ providedIn: 'root' })
export class BrpAdapter { export class BrpAdapter {
personResource() { // Typed as the DTO for ergonomics, but the value is untrusted JSON until
return httpResource<Person>(() => 'mock/brp.json'); // 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> </div>
<p style="margin-top:2rem"> <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> <app-link to="/registratie">Gegevens bekijken of een wijziging doorgeven →</app-link>
</p> </p>
<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 { Component, input } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { LinkComponent } from '@shared/ui/link/link.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, /** Template: standard page body — optional breadcrumb, optional back-link, a
and projected content. Rendered inside the persistent ShellComponent via the heading, optional intro, and projected content. Rendered inside the persistent
router outlet, so it owns only the content (not the header/footer chrome). */ ShellComponent via the router outlet, so it owns only the content (not chrome). */
@Component({ @Component({
selector: 'app-page-shell', selector: 'app-page-shell',
imports: [HeadingComponent, LinkComponent], imports: [HeadingComponent, LinkComponent, BreadcrumbComponent],
styles: [':host{display:block}'], 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: ` 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()) { @if (backLink()) {
<p><app-link [to]="backLink()!">← {{ backLabel() }}</app-link></p> <p><app-link [to]="backLink()!">← {{ backLabel() }}</app-link></p>
} }
<app-heading [level]="1">{{ heading() }}</app-heading> <app-heading [level]="1">{{ heading() }}</app-heading>
@if (intro()) { @if (intro()) {
<p class="rhc-paragraph" style="margin-bottom:1.5rem">{{ intro() }}</p> <p class="rhc-paragraph intro">{{ intro() }}</p>
} }
<ng-content /> <ng-content />
</div> </div>
@@ -28,4 +37,5 @@ export class PageShellComponent {
backLink = input<string>(); backLink = input<string>();
backLabel = input('Terug naar overzicht'); backLabel = input('Terug naar overzicht');
width = input<'default' | 'narrow'>('default'); 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({ @Component({
selector: 'app-shell', selector: 'app-shell',
imports: [RouterOutlet, SiteHeaderComponent, SiteFooterComponent], 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: ` template: `
<a href="#main" class="rhc-skip-link" style="position:absolute;left:-999px">Naar de inhoud</a> <a href="#main" class="rhc-skip-link skip">Naar de inhoud</a>
<div class="utrecht-page-layout utrecht-page-layout--stretch" style="--app-content-max:64rem;min-height:100vh;align-items:stretch"> <div class="utrecht-page-layout utrecht-page-layout--stretch layout">
<app-site-header /> <app-site-header />
<main id="main" class="utrecht-page-content" style="flex:1;width:100%;box-sizing:border-box"> <main id="main" class="utrecht-page-content main">
<div style="max-width:var(--app-content-max);margin:0 auto;padding:2rem 1.5rem;box-sizing:border-box"> <div class="content">
<router-outlet /> <router-outlet />
</div> </div>
</main> </main>

View File

@@ -3,13 +3,18 @@ import { Component } from '@angular/core';
/** Organism: site footer. */ /** Organism: site footer. */
@Component({ @Component({
selector: 'app-site-footer', 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: ` template: `
<footer class="utrecht-page-footer" style="background:var(--rhc-color-lintblauw-900,#01689b);color:#fff;margin-top:3rem;width:100%"> <footer class="utrecht-page-footer bar">
<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"> <div class="inner">
<span>BIG-register</span> <span>BIG-register</span>
<span>CIBG — Ministerie van Volksgezondheid, Welzijn en Sport</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> </div>
</footer> </footer>
`, `,

View File

@@ -8,17 +8,26 @@ import { RouterLink } from '@angular/router';
imports: [RouterLink], imports: [RouterLink],
// :host display:block so the full-bleed bar fills the flex column (custom // :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). // 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: ` template: `
<header class="utrecht-page-header" style="background:var(--rhc-color-lintblauw-700,#154273);color:#fff;width:100%"> <header class="utrecht-page-header bar">
<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"> <div class="inner">
<a routerLink="/dashboard" style="display:flex;align-items:center;gap:0.75rem;color:inherit;text-decoration:none"> <a routerLink="/dashboard" class="brand">
<span aria-hidden="true" style="display:inline-block;width:0.5rem;height:2.25rem;background:#fff"></span> <span aria-hidden="true" class="mark"></span>
<span style="font-weight:700;line-height:1.1"> <span class="name">
Rijksoverheid<br><span style="font-weight:400;font-size:0.9rem">BIG-register</span> Rijksoverheid<br><span class="sub">BIG-register</span>
</span> </span>
</a> </a>
<span style="margin-left:auto;font-weight:500">{{ subtitle() }}</span> <span class="portal">{{ subtitle() }}</span>
</div> </div>
</header> </header>
`, `,

View File

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

View File

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

View File

@@ -10,10 +10,23 @@ export interface RadioOption {
form control so it works with ngModel just like the text-input atom. */ form control so it works with ngModel just like the text-input atom. */
@Component({ @Component({
selector: 'app-radio-group', 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: ` 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) { @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 <input
class="utrecht-radio-button" class="utrecht-radio-button"
[class.utrecht-radio-button--checked]="value === opt.value" [class.utrecht-radio-button--checked]="value === opt.value"
@@ -34,6 +47,7 @@ export interface RadioOption {
export class RadioGroupComponent implements ControlValueAccessor { export class RadioGroupComponent implements ControlValueAccessor {
options = input.required<RadioOption[]>(); options = input.required<RadioOption[]>();
name = input.required<string>(); name = input.required<string>();
invalid = input(false);
value = ''; value = '';
disabled = false; disabled = false;

View File

@@ -6,15 +6,15 @@ import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/
selector: 'app-skeleton', selector: 'app-skeleton',
styles: [` styles: [`
:host{display:block} :host{display:block}
.sk{background:linear-gradient(90deg,#e8ebee 25%,#f3f5f6 37%,#e8ebee 63%); .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:4px; background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:var(--rhc-border-radius-md);
margin-block-end:0.6rem} margin-block-end:var(--rhc-space-max-lg)}
@keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}} @keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}
`], `],
template: ` template: `
@if (visible()) { @if (visible()) {
@for (l of lines(); track $index) { @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', selector: 'app-spinner',
styles: [` styles: [`
:host{display:block} :host{display:block}
.sp{width:2rem;height:2rem;border-radius:50%; .sp{inline-size:var(--rhc-space-max-3xl);block-size:var(--rhc-space-max-3xl);border-radius:var(--rhc-border-radius-round);
border:3px solid var(--rhc-color-grijs-300,#cad0d6); border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-300);
border-block-start-color:var(--rhc-color-lintblauw-700,#154273); border-block-start-color:var(--rhc-color-lintblauw-700);
animation:sp 0.8s linear infinite;margin:1.5rem auto} animation:sp 0.8s linear infinite;margin:var(--rhc-space-max-2xl) auto}
@keyframes sp{to{transform:rotate(360deg)}} @keyframes sp{to{transform:rotate(360deg)}}
`], `],
template: ` template: `

View File

@@ -5,8 +5,9 @@ import { Component, input } from '@angular/core';
This keeps the shared UI kernel free of any domain knowledge. */ This keeps the shared UI kernel free of any domain knowledge. */
@Component({ @Component({
selector: 'app-status-badge', selector: 'app-status-badge',
styles: [`.badge{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md)}`],
template: ` 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 class="rhc-dot-badge" [style.background-color]="color()" aria-hidden="true"></span>
<span>{{ label() }}</span> <span>{{ label() }}</span>
</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()" [class.utrecht-textbox--invalid]="invalid()"
[type]="type()" [type]="type()"
[id]="inputId()" [id]="inputId()"
[attr.aria-invalid]="invalid() ? 'true' : null"
[attr.aria-describedby]="invalid() && inputId() ? inputId() + '-error' : null"
[placeholder]="placeholder()" [placeholder]="placeholder()"
[disabled]="disabled" [disabled]="disabled"
[value]="value" [value]="value"

View File

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

View File

@@ -7,6 +7,31 @@
html, body { margin: 0; min-height: 100%; } 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. /* 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 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. */ `root` snapshot and stays put while the content fades. */