From 64385999eb8627e0021fcc22e28c3c93b8b1156a Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 26 Jun 2026 17:23:52 +0200 Subject: [PATCH] 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 --- .storybook/preview.ts | 5 + CLAUDE.md | 123 + .../0001-bff-lite-decision-dtos.md | 122 + docs/ui-ux-audit.md | 112 + documentation.json | 5374 ++++++++++++++++- package.json | 3 +- public/mock/{brp.json => brp-address.json} | 3 +- public/mock/dashboard-view.json | 23 + public/mock/duo-diplomas.json | 38 + public/mock/intake-policy.json | 1 + public/mock/registration.json | 8 - src/app/app.routes.ts | 1 + src/app/auth/application/session.store.ts | 29 +- .../contracts/intake-policy.dto.ts | 12 + .../domain/intake.machine.spec.ts | 77 +- .../herregistratie/domain/intake.machine.ts | 136 +- .../herregistratie-wizard.component.ts | 11 +- .../herregistratie/ui/herregistratie.page.ts | 11 +- .../intake-wizard/intake-wizard.component.ts | 86 +- .../ui/intake-wizard/intake-wizard.stories.ts | 10 +- .../application/big-profile.store.ts | 42 +- .../application/submit-registratie.ts | 19 + .../registratie/contracts/brp-address.dto.ts | 15 + .../contracts/dashboard-view.dto.ts | 36 + .../registratie/contracts/duo-diplomas.dto.ts | 38 + .../domain/registratie-wizard.machine.spec.ts | 206 + .../domain/registratie-wizard.machine.ts | 280 + .../registratie/domain/registration.policy.ts | 5 +- .../domain/value-objects/email.spec.ts | 17 + .../registratie/domain/value-objects/email.ts | 19 + .../infrastructure/big-register.adapter.ts | 9 +- .../infrastructure/brp.adapter.spec.ts | 22 + .../registratie/infrastructure/brp.adapter.ts | 31 +- .../dashboard-view.adapter.spec.ts | 32 + .../infrastructure/dashboard-view.adapter.ts | 47 + .../infrastructure/duo.adapter.spec.ts | 39 + .../registratie/infrastructure/duo.adapter.ts | 60 + src/app/registratie/ui/dashboard.page.ts | 3 + .../registratie-wizard.component.ts | 360 ++ .../registratie-wizard.stories.ts | 40 + src/app/registratie/ui/registratie.page.ts | 27 + .../layout/breadcrumb/breadcrumb.component.ts | 39 + .../layout/breadcrumb/breadcrumb.stories.ts | 23 + .../layout/page-shell/page-shell.component.ts | 24 +- .../shared/layout/shell/shell.component.ts | 16 +- .../site-footer/site-footer.component.ts | 13 +- .../site-header/site-header.component.ts | 25 +- src/app/shared/ui/async/async.component.ts | 2 + .../ui/form-field/form-field.component.ts | 6 +- .../ui/radio-group/radio-group.component.ts | 18 +- .../shared/ui/skeleton/skeleton.component.ts | 8 +- .../shared/ui/spinner/spinner.component.ts | 8 +- .../ui/status-badge/status-badge.component.ts | 3 +- .../shared/ui/stepper/stepper.component.ts | 46 + src/app/shared/ui/stepper/stepper.stories.ts | 19 + .../ui/text-input/text-input.component.ts | 2 + src/app/showcase/concepts.page.ts | 18 +- src/styles.scss | 25 + 58 files changed, 7271 insertions(+), 556 deletions(-) create mode 100644 CLAUDE.md create mode 100644 docs/architecture/0001-bff-lite-decision-dtos.md create mode 100644 docs/ui-ux-audit.md rename public/mock/{brp.json => brp-address.json} (61%) create mode 100644 public/mock/dashboard-view.json create mode 100644 public/mock/duo-diplomas.json create mode 100644 public/mock/intake-policy.json delete mode 100644 public/mock/registration.json create mode 100644 src/app/herregistratie/contracts/intake-policy.dto.ts create mode 100644 src/app/registratie/application/submit-registratie.ts create mode 100644 src/app/registratie/contracts/brp-address.dto.ts create mode 100644 src/app/registratie/contracts/dashboard-view.dto.ts create mode 100644 src/app/registratie/contracts/duo-diplomas.dto.ts create mode 100644 src/app/registratie/domain/registratie-wizard.machine.spec.ts create mode 100644 src/app/registratie/domain/registratie-wizard.machine.ts create mode 100644 src/app/registratie/domain/value-objects/email.spec.ts create mode 100644 src/app/registratie/domain/value-objects/email.ts create mode 100644 src/app/registratie/infrastructure/brp.adapter.spec.ts create mode 100644 src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts create mode 100644 src/app/registratie/infrastructure/dashboard-view.adapter.ts create mode 100644 src/app/registratie/infrastructure/duo.adapter.spec.ts create mode 100644 src/app/registratie/infrastructure/duo.adapter.ts create mode 100644 src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts create mode 100644 src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts create mode 100644 src/app/registratie/ui/registratie.page.ts create mode 100644 src/app/shared/layout/breadcrumb/breadcrumb.component.ts create mode 100644 src/app/shared/layout/breadcrumb/breadcrumb.stories.ts create mode 100644 src/app/shared/ui/stepper/stepper.component.ts create mode 100644 src/app/shared/ui/stepper/stepper.stories.ts diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 605d8cd..58c5d8b 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -14,6 +14,11 @@ const preview: Preview = { ], parameters: { layout: 'padded', + // Accessibility (axe) via @storybook/addon-a11y. Runs WCAG 2.0/2.1 A+AA rule + // sets on every story; flag violations in the a11y panel. + a11y: { + config: { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] } }, + }, controls: { matchers: { color: /(background|color)$/i, diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..88d0d00 --- /dev/null +++ b/CLAUDE.md @@ -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///`. 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`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`. + Combine sources with `map`/`map2`/`map3`/`andThen` (Failure > Loading > Success). + Render it via the `` 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` + 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 ``, +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. diff --git a/docs/architecture/0001-bff-lite-decision-dtos.md b/docs/architecture/0001-bff-lite-decision-dtos.md new file mode 100644 index 0000000..8bd0fb4 --- /dev/null +++ b/docs/architecture/0001-bff-lite-decision-dtos.md @@ -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 | Low–medium | +| 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 | Medium–high; 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. diff --git a/docs/ui-ux-audit.md b/docs/ui-ux-audit.md new file mode 100644 index 0000000..2b7c256 --- /dev/null +++ b/docs/ui-ux-audit.md @@ -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 50–700), 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 `

` (page-shell) but the **wizard steps have no `

`** — step title is a plain `

`. Screen-reader users get no step heading to navigate to. | per-step `

` 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 `
` 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` (`
    `, `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 `

    ` (`tabindex="-1"`) on `cursor` change | +| 9.2 | **H** | Error/label/invalid association missing (6.1–6.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. diff --git a/documentation.json b/documentation.json index 4a7de95..6707f24 100644 --- a/documentation.json +++ b/documentation.json @@ -95,12 +95,12 @@ }, { "name": "Answers", - "id": "interface-Answers-53c06ae2dad9470cd0c742a7e5903458a870e5eaaf70ce3043cec53935f09d2cb0d5f9980b723ce79be2460d550908bc894743c867abdf0974f17f7596cdd05d", + "id": "interface-Answers-13704a2a04ab3cffa46cbc109ee84699b236928e1c2649f459e18e4daf323047e8b21cdd5a21440c5fd696a605a09feb673b4020394ba7954ae35162f1bd2732", "file": "src/app/herregistratie/domain/intake.machine.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "import { Result, ok, err, assertNever } from '@shared/kernel/fp';\nimport { Uren, parseUren } from '@registratie/domain/value-objects/uren';\n\n/**\n * A BRANCHING wizard. Unlike herregistratie.machine (fixed 2 steps), the set of\n * steps here is NOT stored — it's *derived* from the answers by `visibleSteps`.\n * Answer \"buiten Nederland gewerkt? → ja\" and two extra steps appear; report few\n * hours and a scholing-question appears. The progress bar's denominator changes\n * as you type. \"Which question comes next\" is a pure function, so it's trivial\n * to test and impossible to get out of sync with the data.\n */\n\nexport type JaNee = 'ja' | 'nee';\n\n/** Every possible question, as a union — never a bare string. */\nexport type StepId = 'buitenland' | 'buitenlandDetails' | 'uren' | 'scholing' | 'punten' | 'review';\n\n/** One record carried across every step (and persisted). All optional: the user\n fills it in gradually, and branches may never ask some fields. */\nexport interface Answers {\n buitenlandGewerkt?: JaNee; // Q1\n land?: string; // Q1a — only when buitenlandGewerkt === 'ja'\n buitenlandseUren?: string; // Q1b — only when buitenlandGewerkt === 'ja'\n uren?: string; // Q2 — uren in NL\n scholingGevolgd?: JaNee; // Q3 — only when total hours are below the threshold\n punten?: string; // Q4\n}\n\n/** What we have after the review step parses — guaranteed valid/typed. */\nexport interface ValidIntake {\n werktBuitenland: boolean;\n land?: string;\n buitenlandseUren?: Uren;\n uren: Uren;\n aanvullendeScholing?: boolean;\n punten: Uren;\n}\n\n/** Below this many NL-hours we ask whether extra scholing was followed. */\nconst LAGE_UREN_DREMPEL = 1000;\n\nfunction lageUren(a: Answers): boolean {\n const r = parseUren(a.uren ?? '');\n return r.ok && r.value < LAGE_UREN_DREMPEL;\n}\n\n/**\n * THE branching, as one pure function. The step list is recomputed on every\n * transition, so changing an earlier answer immediately adds/removes later steps.\n */\nexport function visibleSteps(a: Answers): StepId[] {\n const steps: StepId[] = ['buitenland'];\n if (a.buitenlandGewerkt === 'ja') steps.push('buitenlandDetails');\n steps.push('uren');\n if (lageUren(a)) steps.push('scholing');\n steps.push('punten', 'review');\n return steps;\n}\n\nexport type IntakeState =\n | { tag: 'Answering'; answers: Answers; cursor: number; errors: Partial> }\n | { tag: 'Submitting'; data: ValidIntake }\n | { tag: 'Submitted'; data: ValidIntake }\n | { tag: 'Failed'; data: ValidIntake; error: string };\n\nexport const initial: IntakeState = { tag: 'Answering', answers: {}, cursor: 0, errors: {} };\n\n/** Which step the cursor currently points at (clamped to the live step list). */\nexport function currentStep(s: Extract): StepId {\n const steps = visibleSteps(s.answers);\n return steps[Math.min(s.cursor, steps.length - 1)];\n}\n\n/** Validate ONE step's fields. Returns the per-field error keyed by StepId. */\nfunction validateStep(step: StepId, a: Answers): Result>, void> {\n switch (step) {\n case 'buitenland':\n return a.buitenlandGewerkt ? ok(undefined) : err({ buitenland: 'Maak een keuze.' });\n case 'buitenlandDetails': {\n if (!a.land || a.land.trim() === '') return err({ buitenlandDetails: 'Vul een land in.' });\n const u = parseUren(a.buitenlandseUren ?? '');\n return u.ok ? ok(undefined) : err({ buitenlandDetails: u.error });\n }\n case 'uren': {\n const u = parseUren(a.uren ?? '');\n return u.ok ? ok(undefined) : err({ uren: u.error });\n }\n case 'scholing':\n return a.scholingGevolgd ? ok(undefined) : err({ scholing: 'Maak een keuze.' });\n case 'punten': {\n const p = parseUren(a.punten ?? '');\n return p.ok ? ok(undefined) : err({ punten: p.error });\n }\n case 'review':\n return ok(undefined); // review shows a summary; no own fields\n default:\n return assertNever(step);\n }\n}\n\n/** Parse the whole questionnaire into a ValidIntake (called on submit). */\nfunction validateAll(a: Answers): Result>, ValidIntake> {\n const errors: Partial> = {};\n for (const step of visibleSteps(a)) {\n const r = validateStep(step, a);\n if (!r.ok) Object.assign(errors, r.error);\n }\n if (Object.keys(errors).length > 0) return err(errors);\n\n const uren = parseUren(a.uren ?? '');\n const punten = parseUren(a.punten ?? '');\n // visibleSteps guaranteed these parse, but keep the compiler happy.\n if (!uren.ok || !punten.ok) return err(errors);\n\n const werktBuitenland = a.buitenlandGewerkt === 'ja';\n const buitenland = parseUren(a.buitenlandseUren ?? '');\n return ok({\n werktBuitenland,\n land: werktBuitenland ? a.land : undefined,\n buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,\n uren: uren.value,\n aanvullendeScholing: lageUren(a) ? a.scholingGevolgd === 'ja' : undefined,\n punten: punten.value,\n });\n}\n\nexport function setAnswer(s: IntakeState, key: keyof Answers, value: string): IntakeState {\n if (s.tag !== 'Answering') return s;\n const answers = { ...s.answers, [key]: value };\n // Editing an earlier answer can shrink the step list; clamp the cursor.\n const cursor = Math.min(s.cursor, visibleSteps(answers).length - 1);\n return { ...s, answers, cursor };\n}\n\nexport function next(s: IntakeState): IntakeState {\n if (s.tag !== 'Answering') return s;\n const r = validateStep(currentStep(s), s.answers);\n if (!r.ok) return { ...s, errors: r.error };\n const last = visibleSteps(s.answers).length - 1;\n return { ...s, cursor: Math.min(s.cursor + 1, last), errors: {} };\n}\n\nexport function back(s: IntakeState): IntakeState {\n if (s.tag !== 'Answering' || s.cursor === 0) return s;\n return { ...s, cursor: s.cursor - 1, errors: {} };\n}\n\nexport function submit(s: IntakeState): IntakeState {\n if (s.tag !== 'Answering') return s;\n const r = validateAll(s.answers);\n return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };\n}\n\nexport function resolve(s: IntakeState, r: Result): IntakeState {\n if (s.tag !== 'Submitting') return s;\n return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error };\n}\n\nexport type IntakeMsg =\n | { tag: 'SetAnswer'; key: keyof Answers; value: string }\n | { tag: 'Next' }\n | { tag: 'Back' }\n | { tag: 'Submit' }\n | { tag: 'Retry' }\n | { tag: 'SubmitConfirmed' }\n | { tag: 'SubmitFailed'; error: string }\n | { tag: 'Seed'; state: IntakeState };\n\nexport function reduce(s: IntakeState, m: IntakeMsg): IntakeState {\n switch (m.tag) {\n case 'SetAnswer':\n return setAnswer(s, m.key, m.value);\n case 'Next':\n return next(s);\n case 'Back':\n return back(s);\n case 'Submit':\n return submit(s);\n case 'Retry':\n return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;\n case 'SubmitConfirmed':\n return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;\n case 'SubmitFailed':\n return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;\n case 'Seed':\n return m.state;\n default:\n return assertNever(m);\n }\n}\n", + "sourceCode": "import { Result, ok, err, assertNever } from '@shared/kernel/fp';\nimport { Uren, parseUren } from '@registratie/domain/value-objects/uren';\n\n/**\n * A FIXED 3-step wizard with progressive disclosure. The steps never change in\n * number (always `STEPS`); instead, follow-up questions appear *inline within a\n * step* depending on earlier answers — answer \"buiten Nederland gewerkt? → ja\"\n * and the country/hours questions reveal in the same step; report few hours and\n * the scholing-question reveals inside the 'werk' step. \"Is this field required\n * right now\" is a pure function (`validateStep`/`lageUren`), so it's trivial to\n * test and impossible to get out of sync with the data.\n */\n\nexport type JaNee = 'ja' | 'nee';\n\n/** The three fixed steps. Each step groups one or more questions. */\nexport type StepId = 'buitenland' | 'werk' | 'review';\n\n/** One record carried across every step (and persisted). All optional: the user\n fills it in gradually, and branches may never ask some fields. */\nexport interface Answers {\n buitenlandGewerkt?: JaNee; // Q1\n land?: string; // Q1a — only when buitenlandGewerkt === 'ja'\n buitenlandseUren?: string; // Q1b — only when buitenlandGewerkt === 'ja'\n uren?: string; // Q2 — uren in NL\n scholingGevolgd?: JaNee; // Q3 — only when total hours are below the threshold\n punten?: string; // Q4\n}\n\n/** What we have after the review step parses — guaranteed valid/typed. */\nexport interface ValidIntake {\n werktBuitenland: boolean;\n land?: string;\n buitenlandseUren?: Uren;\n uren: Uren;\n aanvullendeScholing?: boolean;\n punten?: Uren; // only collected when aanvullende scholing is gevolgd (scholingGevolgd === 'ja')\n}\n\n/** Demo fallback only — the real threshold is a SERVER-OWNED policy value fetched\n at runtime (see IntakePolicyDto / SetPolicy). ponytail: default is the offline\n fallback; the server value wins. */\nexport const SCHOLING_THRESHOLD_DEFAULT = 1000;\n\n/** True when NL-hours are low enough that the scholing question must be answered.\n The threshold is passed in (server-owned), not hardcoded. */\nexport function lageUren(a: Answers, scholingThreshold = SCHOLING_THRESHOLD_DEFAULT): boolean {\n const r = parseUren(a.uren ?? '');\n return r.ok && r.value < scholingThreshold;\n}\n\n/** The fixed step list. Number of steps never changes; questions reveal inline. */\nexport const STEPS: StepId[] = ['buitenland', 'werk', 'review'];\n\n/** Per-field error map: one message per question, since a step holds several. */\ntype Errors = Partial>;\n\nexport type IntakeState =\n | { tag: 'Answering'; answers: Answers; cursor: number; errors: Errors; scholingThreshold: number }\n | { tag: 'Submitting'; data: ValidIntake }\n | { tag: 'Submitted'; data: ValidIntake }\n | { tag: 'Failed'; data: ValidIntake; error: string };\n\nexport const initial: IntakeState = { tag: 'Answering', answers: {}, cursor: 0, errors: {}, scholingThreshold: SCHOLING_THRESHOLD_DEFAULT };\n\n/** Which step the cursor currently points at (clamped to the fixed list). */\nexport function currentStep(s: Extract): StepId {\n return STEPS[Math.min(s.cursor, STEPS.length - 1)];\n}\n\n/** Validate every question currently visible in ONE step. Errors keyed per field. */\nfunction validateStep(step: StepId, a: Answers, scholingThreshold: number): Result {\n const errors: Errors = {};\n switch (step) {\n case 'buitenland':\n if (!a.buitenlandGewerkt) errors.buitenlandGewerkt = 'Maak een keuze.';\n else if (a.buitenlandGewerkt === 'ja') {\n if (!a.land || a.land.trim() === '') errors.land = 'Vul een land in.';\n const u = parseUren(a.buitenlandseUren ?? '');\n if (!u.ok) errors.buitenlandseUren = u.error;\n }\n break;\n case 'werk': {\n const u = parseUren(a.uren ?? '');\n if (!u.ok) errors.uren = u.error;\n if (lageUren(a, scholingThreshold) && !a.scholingGevolgd) errors.scholingGevolgd = 'Maak een keuze.';\n // Nascholingspunten are only asked (and required) when scholing was followed.\n if (a.scholingGevolgd === 'ja') {\n const p = parseUren(a.punten ?? '');\n if (!p.ok) errors.punten = p.error;\n }\n break;\n }\n case 'review':\n break; // review shows a summary; no own fields\n default:\n return assertNever(step);\n }\n return Object.keys(errors).length > 0 ? err(errors) : ok(undefined);\n}\n\n/** Parse the whole questionnaire into a ValidIntake (called on submit). */\nfunction validateAll(a: Answers, scholingThreshold: number): Result {\n const errors: Errors = {};\n for (const step of STEPS) {\n const r = validateStep(step, a, scholingThreshold);\n if (!r.ok) Object.assign(errors, r.error);\n }\n if (Object.keys(errors).length > 0) return err(errors);\n\n const uren = parseUren(a.uren ?? '');\n // validateStep guaranteed uren parses, but keep the compiler happy.\n if (!uren.ok) return err(errors);\n\n const werktBuitenland = a.buitenlandGewerkt === 'ja';\n const buitenland = parseUren(a.buitenlandseUren ?? '');\n // Punten are only collected when aanvullende scholing was gevolgd.\n const punten = a.scholingGevolgd === 'ja' ? parseUren(a.punten ?? '') : undefined;\n return ok({\n werktBuitenland,\n land: werktBuitenland ? a.land : undefined,\n buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,\n uren: uren.value,\n aanvullendeScholing: lageUren(a, scholingThreshold) ? a.scholingGevolgd === 'ja' : undefined,\n punten: punten?.ok ? punten.value : undefined,\n });\n}\n\nexport function setAnswer(s: IntakeState, key: keyof Answers, value: string): IntakeState {\n if (s.tag !== 'Answering') return s;\n // Steps are fixed, so editing an answer never moves the cursor — it only\n // reveals/hides inline questions within the current step.\n return { ...s, answers: { ...s.answers, [key]: value } };\n}\n\nexport function next(s: IntakeState): IntakeState {\n if (s.tag !== 'Answering') return s;\n const r = validateStep(currentStep(s), s.answers, s.scholingThreshold);\n if (!r.ok) return { ...s, errors: r.error };\n return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };\n}\n\n/** Apply a server-owned policy value (e.g. the scholing threshold). */\nexport function setPolicy(s: IntakeState, scholingThreshold: number): IntakeState {\n return s.tag === 'Answering' ? { ...s, scholingThreshold } : s;\n}\n\nexport function back(s: IntakeState): IntakeState {\n if (s.tag !== 'Answering' || s.cursor === 0) return s;\n return { ...s, cursor: s.cursor - 1, errors: {} };\n}\n\nexport function submit(s: IntakeState): IntakeState {\n if (s.tag !== 'Answering') return s;\n const r = validateAll(s.answers, s.scholingThreshold);\n return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };\n}\n\nexport function resolve(s: IntakeState, r: Result): IntakeState {\n if (s.tag !== 'Submitting') return s;\n return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error };\n}\n\nexport type IntakeMsg =\n | { tag: 'SetAnswer'; key: keyof Answers; value: string }\n | { tag: 'Next' }\n | { tag: 'Back' }\n | { tag: 'Submit' }\n | { tag: 'Retry' }\n | { tag: 'SubmitConfirmed' }\n | { tag: 'SubmitFailed'; error: string }\n | { tag: 'SetPolicy'; scholingThreshold: number }\n | { tag: 'Seed'; state: IntakeState };\n\nexport function reduce(s: IntakeState, m: IntakeMsg): IntakeState {\n switch (m.tag) {\n case 'SetAnswer':\n return setAnswer(s, m.key, m.value);\n case 'Next':\n return next(s);\n case 'Back':\n return back(s);\n case 'Submit':\n return submit(s);\n case 'Retry':\n return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;\n case 'SubmitConfirmed':\n return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;\n case 'SubmitFailed':\n return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;\n case 'SetPolicy':\n return setPolicy(s, m.scholingThreshold);\n case 'Seed':\n return m.state;\n default:\n return assertNever(m);\n }\n}\n", "properties": [ { "name": "buitenlandGewerkt", @@ -110,7 +110,7 @@ "indexKey": "", "optional": true, "description": "", - "line": 21 + "line": 22 }, { "name": "buitenlandseUren", @@ -120,7 +120,7 @@ "indexKey": "", "optional": true, "description": "", - "line": 23 + "line": 24 }, { "name": "land", @@ -130,7 +130,7 @@ "indexKey": "", "optional": true, "description": "", - "line": 22 + "line": 23 }, { "name": "punten", @@ -140,7 +140,7 @@ "indexKey": "", "optional": true, "description": "", - "line": 26 + "line": 27 }, { "name": "scholingGevolgd", @@ -150,7 +150,7 @@ "indexKey": "", "optional": true, "description": "", - "line": 25 + "line": 26 }, { "name": "uren", @@ -160,7 +160,7 @@ "indexKey": "", "optional": true, "description": "", - "line": 24 + "line": 25 } ], "indexSignatures": [], @@ -207,6 +207,78 @@ "methods": [], "extends": [] }, + { + "name": "BreadcrumbItem", + "id": "interface-BreadcrumbItem-4dedba5c509fabd51900b3062e33290365b01e1fdc64a11c5ce7fe12f20201b340d7fcfbe61254b0d0b7a386ab40a29795bd467542fb1a353f4d9bda46e65688", + "file": "src/app/shared/layout/breadcrumb/breadcrumb.component.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "import { Component, input } from '@angular/core';\nimport { RouterLink } from '@angular/router';\n\nexport interface BreadcrumbItem {\n label: string;\n link?: string; // omit on the current (last) page\n}\n\n/** Chrome: breadcrumb navigation. Renders the RHC/Utrecht breadcrumb pattern; the\n last item is the current page (no link, aria-current). Domain-free — the caller\n supplies the trail. */\n@Component({\n selector: 'app-breadcrumb',\n imports: [RouterLink],\n styles: [`\n :host{display:block}\n .list{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;list-style:none;margin:0;padding:0}\n .sep{color:var(--rhc-color-foreground-subtle)}\n `],\n template: `\n \n `,\n})\nexport class BreadcrumbComponent {\n items = input.required();\n}\n", + "properties": [ + { + "name": "label", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 5 + }, + { + "name": "link", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": true, + "description": "", + "line": 6 + } + ], + "indexSignatures": [], + "kind": 172, + "methods": [], + "extends": [] + }, + { + "name": "BrpAddressDto", + "id": "interface-BrpAddressDto-029e98a059f908b1aab86b91327ec0c2a409efb766692f042935997cd36e685859ecf375e579b912496b2dac205dfd2c751f3a301b7486f0da9cb04cda7c704f", + "file": "src/app/registratie/contracts/brp-address.dto.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "export interface BrpAddressDto {\n gevonden: boolean;\n adres?: { straat: string; postcode: string; woonplaats: string };\n}\n", + "properties": [ + { + "name": "adres", + "deprecated": false, + "deprecationMessage": "", + "type": "literal type", + "indexKey": "", + "optional": true, + "description": "", + "line": 14 + }, + { + "name": "gevonden", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 13 + } + ], + "indexSignatures": [], + "kind": 172, + "description": "

    WIRE CONTRACT for the BRP address lookup ("BFF-lite" — one screen-shaped call).

    \n

    In production this is GENERATED from the OpenAPI/TypeSpec spec and served by our\nown backend, which talks to the BRP behind an adapter. The frontend never sees\nthe BRP's own wire format. See docs/architecture/0001-bff-lite-decision-dtos.md.

    \n

    "Geen adres bekend" is a first-class outcome (gevonden: false), not an error —\nthe wizard falls back to manual entry (PRD §7). Slice 1 ships only the happy\npath (gevonden: true).

    \n", + "rawdescription": "\n\nWIRE CONTRACT for the BRP address lookup (\"BFF-lite\" — one screen-shaped call).\n\nIn production this is GENERATED from the OpenAPI/TypeSpec spec and served by our\nown backend, which talks to the BRP behind an adapter. The frontend never sees\nthe BRP's own wire format. See docs/architecture/0001-bff-lite-decision-dtos.md.\n\n\"Geen adres bekend\" is a first-class outcome (`gevonden: false`), not an error —\nthe wizard falls back to manual entry (PRD §7). Slice 1 ships only the happy\npath (gevonden: true).\n", + "methods": [], + "extends": [] + }, { "name": "ChangeRequest", "id": "interface-ChangeRequest-8d79b6a9ce75f62f4e04f9d3ed0867e00d005c9c05cf267503536c4eec9813372aff51a3c8686927fd07f46503b60ddb4107edcc5f57fdfa1b6b52fe2a98026c", @@ -254,6 +326,90 @@ "methods": [], "extends": [] }, + { + "name": "DashboardView", + "id": "interface-DashboardView-b39e266ec8fabdaddad87f1e15a5b1b86e45d84ac2c95028b4e676c2eb425fd675c77383c3d4f8f88f4a072f3eb88a954eb0f8755597224ebef59da18d24fda0", + "file": "src/app/registratie/contracts/dashboard-view.dto.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "import { Registration } from '@registratie/domain/registration';\nimport { Person } from '@registratie/domain/person';\nimport { BigProfile } from '@registratie/domain/big-profile';\n\n/**\n * WIRE CONTRACT for the dashboard screen — the \"BFF-lite\" response.\n *\n * In production this type is GENERATED from the OpenAPI/TypeSpec spec (one source\n * of truth for both sides), and the `decisions` block is computed BY THE BACKEND\n * — never recomputed on the client. The frontend renders decisions; it does not\n * own the rules. See docs/architecture/0001-bff-lite-decision-dtos.md.\n *\n * One screen-shaped call replaces the previous three (BIG-register + BRP + …),\n * so the page always sees one consistent snapshot instead of three independently\n * loading/erroring resources.\n */\nexport interface DashboardViewDto {\n registration: Registration;\n person: Person;\n decisions: HerregistratieDecisions;\n}\n\n/** Server-computed decisions. The eligibility rule lives on the backend; the\n optional reason lets the UI explain itself without knowing the rule. */\nexport interface HerregistratieDecisions {\n eligibleForHerregistratie: boolean;\n herregistratieReason?: string;\n}\n\n/** The parsed, frontend-side view (DTO mapped onto our own domain model). Keeping\n this distinct from DashboardViewDto is the decoupling seam: the wire shape can\n change without the FE domain following, and vice-versa. */\nexport interface DashboardView {\n profile: BigProfile;\n decisions: HerregistratieDecisions;\n}\n", + "properties": [ + { + "name": "decisions", + "deprecated": false, + "deprecationMessage": "", + "type": "HerregistratieDecisions", + "indexKey": "", + "optional": false, + "description": "", + "line": 35 + }, + { + "name": "profile", + "deprecated": false, + "deprecationMessage": "", + "type": "BigProfile", + "indexKey": "", + "optional": false, + "description": "", + "line": 34 + } + ], + "indexSignatures": [], + "kind": 172, + "description": "

    The parsed, frontend-side view (DTO mapped onto our own domain model). Keeping\nthis distinct from DashboardViewDto is the decoupling seam: the wire shape can\nchange without the FE domain following, and vice-versa.

    \n", + "rawdescription": "\nThe parsed, frontend-side view (DTO mapped onto our own domain model). Keeping\nthis distinct from DashboardViewDto is the decoupling seam: the wire shape can\nchange without the FE domain following, and vice-versa.", + "methods": [], + "extends": [] + }, + { + "name": "DashboardViewDto", + "id": "interface-DashboardViewDto-b39e266ec8fabdaddad87f1e15a5b1b86e45d84ac2c95028b4e676c2eb425fd675c77383c3d4f8f88f4a072f3eb88a954eb0f8755597224ebef59da18d24fda0", + "file": "src/app/registratie/contracts/dashboard-view.dto.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "import { Registration } from '@registratie/domain/registration';\nimport { Person } from '@registratie/domain/person';\nimport { BigProfile } from '@registratie/domain/big-profile';\n\n/**\n * WIRE CONTRACT for the dashboard screen — the \"BFF-lite\" response.\n *\n * In production this type is GENERATED from the OpenAPI/TypeSpec spec (one source\n * of truth for both sides), and the `decisions` block is computed BY THE BACKEND\n * — never recomputed on the client. The frontend renders decisions; it does not\n * own the rules. See docs/architecture/0001-bff-lite-decision-dtos.md.\n *\n * One screen-shaped call replaces the previous three (BIG-register + BRP + …),\n * so the page always sees one consistent snapshot instead of three independently\n * loading/erroring resources.\n */\nexport interface DashboardViewDto {\n registration: Registration;\n person: Person;\n decisions: HerregistratieDecisions;\n}\n\n/** Server-computed decisions. The eligibility rule lives on the backend; the\n optional reason lets the UI explain itself without knowing the rule. */\nexport interface HerregistratieDecisions {\n eligibleForHerregistratie: boolean;\n herregistratieReason?: string;\n}\n\n/** The parsed, frontend-side view (DTO mapped onto our own domain model). Keeping\n this distinct from DashboardViewDto is the decoupling seam: the wire shape can\n change without the FE domain following, and vice-versa. */\nexport interface DashboardView {\n profile: BigProfile;\n decisions: HerregistratieDecisions;\n}\n", + "properties": [ + { + "name": "decisions", + "deprecated": false, + "deprecationMessage": "", + "type": "HerregistratieDecisions", + "indexKey": "", + "optional": false, + "description": "", + "line": 20 + }, + { + "name": "person", + "deprecated": false, + "deprecationMessage": "", + "type": "Person", + "indexKey": "", + "optional": false, + "description": "", + "line": 19 + }, + { + "name": "registration", + "deprecated": false, + "deprecationMessage": "", + "type": "Registration", + "indexKey": "", + "optional": false, + "description": "", + "line": 18 + } + ], + "indexSignatures": [], + "kind": 172, + "description": "

    WIRE CONTRACT for the dashboard screen — the "BFF-lite" response.

    \n

    In production this type is GENERATED from the OpenAPI/TypeSpec spec (one source\nof truth for both sides), and the decisions block is computed BY THE BACKEND\n— never recomputed on the client. The frontend renders decisions; it does not\nown the rules. See docs/architecture/0001-bff-lite-decision-dtos.md.

    \n

    One screen-shaped call replaces the previous three (BIG-register + BRP + …),\nso the page always sees one consistent snapshot instead of three independently\nloading/erroring resources.

    \n", + "rawdescription": "\n\nWIRE CONTRACT for the dashboard screen — the \"BFF-lite\" response.\n\nIn production this type is GENERATED from the OpenAPI/TypeSpec spec (one source\nof truth for both sides), and the `decisions` block is computed BY THE BACKEND\n— never recomputed on the client. The frontend renders decisions; it does not\nown the rules. See docs/architecture/0001-bff-lite-decision-dtos.md.\n\nOne screen-shaped call replaces the previous three (BIG-register + BRP + …),\nso the page always sees one consistent snapshot instead of three independently\nloading/erroring resources.\n", + "methods": [], + "extends": [] + }, { "name": "Draft", "id": "interface-Draft-097a742aa05bc7bbfee387dd3ea5b2ac382815db8ca18107e699d11e6da110cdeae2eb377bcea284f415cfd263937c3b0f6e7ed2ee9090d660d898dddefe3a4f", @@ -301,6 +457,435 @@ "methods": [], "extends": [] }, + { + "name": "Draft", + "id": "interface-Draft-6ed41f6a28b6c00dc907f54219be9c1d7955aa11bc22f128620d2206b1ea12e6730533ac33f5f5df7b8d7c8d7f977584b3a0a3e98a6c2c3b91d1afe6411278e2-1", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "import { Result, ok, err, assertNever } from '@shared/kernel/fp';\nimport { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';\nimport { Email, parseEmail } from '@registratie/domain/value-objects/email';\n\n/**\n * A FIXED 3-step registration wizard. The steps never change in number (always\n * `STEPS`): (1) adres + correspondentievoorkeur, (2) beroep o.b.v. diploma,\n * (3) controle & indienen. Follow-up questions appear *inline within a step*\n * (e.g. choosing 'email' reveals the e-mail field). \"Is this field required\n * right now\" is a pure function (`validateStep`), so it is trivial to test and\n * impossible to get out of sync with the data. Invariants live here, not in the\n * UI: the wizard reaches `Indienen` only when a complete `ValidRegistratie` parses.\n */\n\nexport type StepId = 'adres' | 'beroep' | 'controle';\n\n/** The fixed step list. Number of steps never changes; questions reveal inline. */\nexport const STEPS: StepId[] = ['adres', 'beroep', 'controle'];\n\n/** Where a piece of data came from — recorded on the aggregate (PRD §5). */\nexport type AdresHerkomst = 'brp' | 'handmatig';\nexport type DiplomaHerkomst = 'duo' | 'handmatig';\nexport type Correspondentie = 'email' | 'post';\n\n/** One record carried across every step (and persisted). All optional: the user\n fills it in gradually. Adres fields are kept flat so one `SetField` message\n serves them all (mirrors the intake machine). */\nexport interface Draft {\n straat?: string;\n postcode?: string;\n woonplaats?: string;\n adresHerkomst?: AdresHerkomst;\n correspondentie?: Correspondentie;\n email?: string;\n diplomaId?: string;\n diplomaHerkomst?: DiplomaHerkomst;\n beroep?: string; // DERIVED from the chosen DUO diploma (or declared for a manual one)\n vraagIds?: string[]; // ids of the policy questions that apply to the chosen diploma\n antwoorden: Record; // geldigheidsantwoorden, keyed by question id\n}\n\n/** What we have after the controle step parses — guaranteed valid/typed. */\nexport interface ValidRegistratie {\n adres: { straat: string; postcode: Postcode; woonplaats: string };\n adresHerkomst: AdresHerkomst;\n correspondentie: Correspondentie;\n email?: Email; // only when correspondentie === 'email'\n diplomaId: string;\n diplomaHerkomst: DiplomaHerkomst;\n beroep: string;\n antwoorden: Record;\n}\n\n/** Text fields settable via SetField. */\nexport type DraftField = 'straat' | 'postcode' | 'woonplaats' | 'email';\n\n/** Per-field error map. `antwoorden` holds per-policy-question errors, keyed by\n question id (a step can show several questions). */\nexport interface Errors {\n straat?: string;\n postcode?: string;\n woonplaats?: string;\n email?: string;\n correspondentie?: string;\n diploma?: string;\n antwoorden?: Record;\n}\n\nexport type RegistratieState =\n | { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors }\n | { tag: 'Indienen'; data: ValidRegistratie }\n | { tag: 'Ingediend'; data: ValidRegistratie; referentie: string }\n | { tag: 'Mislukt'; data: ValidRegistratie; error: string };\n\nconst emptyDraft: Draft = { antwoorden: {} };\nexport const initial: RegistratieState = { tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {} };\n\n/** Which step the cursor currently points at (clamped to the fixed list). */\nexport function currentStep(s: Extract): StepId {\n return STEPS[Math.min(s.cursor, STEPS.length - 1)];\n}\n\n/** Validate every question currently visible in ONE step. Errors keyed per field. */\nfunction validateStep(step: StepId, d: Draft): Result {\n const errors: Errors = {};\n switch (step) {\n case 'adres': {\n if (!d.straat || d.straat.trim() === '') errors.straat = 'Vul een straat en huisnummer in.';\n const pc = parsePostcode(d.postcode ?? '');\n if (!pc.ok) errors.postcode = pc.error;\n if (!d.woonplaats || d.woonplaats.trim() === '') errors.woonplaats = 'Vul een woonplaats in.';\n if (!d.correspondentie) errors.correspondentie = 'Maak een keuze.';\n // E-mail is only required when 'email' is the chosen channel.\n if (d.correspondentie === 'email') {\n const e = parseEmail(d.email ?? '');\n if (!e.ok) errors.email = e.error;\n }\n break;\n }\n case 'beroep': {\n // A diploma must be chosen (or declared manually); its beroep is then known.\n if (!d.diplomaId || !d.beroep) {\n errors.diploma = 'Kies het diploma waarmee u zich wilt registreren, of voer het handmatig in.';\n break;\n }\n // Every policy question the chosen diploma raised must be answered. Which\n // questions apply is server-decided (carried in `vraagIds`); we only check\n // they're answered.\n const open: Record = {};\n for (const id of d.vraagIds ?? []) {\n if (!(d.antwoorden[id] ?? '').trim()) open[id] = 'Beantwoord deze vraag.';\n }\n if (Object.keys(open).length > 0) errors.antwoorden = open;\n break;\n }\n case 'controle':\n break; // controle shows a summary; no own fields\n default:\n return assertNever(step);\n }\n return Object.keys(errors).length > 0 ? err(errors) : ok(undefined);\n}\n\n/** Parse the whole wizard into a ValidRegistratie (called on submit). */\nfunction validateAll(d: Draft): Result {\n const errors: Errors = {};\n for (const step of STEPS) {\n const r = validateStep(step, d);\n if (!r.ok) Object.assign(errors, r.error);\n }\n if (Object.keys(errors).length > 0) return err(errors);\n\n const pc = parsePostcode(d.postcode ?? '');\n // validateStep guaranteed these parse, but keep the compiler happy.\n if (!pc.ok || !d.diplomaId || !d.beroep || !d.correspondentie) return err(errors);\n const email = d.correspondentie === 'email' ? parseEmail(d.email ?? '') : undefined;\n // Keep only the answers to the questions that actually applied.\n const vraagIds = d.vraagIds ?? [];\n const antwoorden = Object.fromEntries(vraagIds.map((id) => [id, d.antwoorden[id] ?? '']));\n\n return ok({\n adres: { straat: d.straat!, postcode: pc.value, woonplaats: d.woonplaats! },\n adresHerkomst: d.adresHerkomst ?? 'handmatig',\n correspondentie: d.correspondentie,\n email: email?.ok ? email.value : undefined,\n diplomaId: d.diplomaId,\n diplomaHerkomst: d.diplomaHerkomst ?? 'handmatig',\n beroep: d.beroep,\n antwoorden,\n });\n}\n\nexport function setField(s: RegistratieState, key: DraftField, value: string): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n const draft: Draft = { ...s.draft, [key]: value };\n // Editing an address field means the user owns it now — not the BRP copy.\n if (key === 'straat' || key === 'postcode' || key === 'woonplaats') draft.adresHerkomst = 'handmatig';\n return { ...s, draft };\n}\n\nexport function setCorrespondentie(s: RegistratieState, value: Correspondentie): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n return { ...s, draft: { ...s.draft, correspondentie: value } };\n}\n\n/** Prefill the address from a BRP lookup and flag its origin (PRD §7). */\nexport function prefillAdres(s: RegistratieState, straat: string, postcode: string, woonplaats: string): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n return { ...s, draft: { ...s.draft, straat, postcode, woonplaats, adresHerkomst: 'brp' } };\n}\n\n/** Pick a DUO diploma; the beroep is derived from it and the applicable policy\n questions (`vraagIds`) come with it (both server-computed, passed in). */\nexport function kiesDiploma(s: RegistratieState, diplomaId: string, beroep: string, vraagIds: string[]): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n return { ...s, draft: { ...s.draft, diplomaId, beroep, vraagIds, diplomaHerkomst: 'duo' }, errors: {} };\n}\n\n/** Switch to manual diploma entry: the diploma isn't in DUO, so the MAXIMAL\n policy-question set applies and the entry is flagged handmatig/unverified. The\n beroep is declared separately (declareerBeroep). */\nexport function kiesHandmatig(s: RegistratieState, vraagIds: string[]): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n return { ...s, draft: { ...s.draft, diplomaId: 'handmatig', beroep: undefined, vraagIds, diplomaHerkomst: 'handmatig' }, errors: {} };\n}\n\n/** Declare the beroep for a manually-entered diploma (chosen from a fixed list). */\nexport function declareerBeroep(s: RegistratieState, beroep: string): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n return { ...s, draft: { ...s.draft, beroep } };\n}\n\nexport function setAntwoord(s: RegistratieState, vraagId: string, value: string): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n return { ...s, draft: { ...s.draft, antwoorden: { ...s.draft.antwoorden, [vraagId]: value } } };\n}\n\nexport function next(s: RegistratieState): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n const r = validateStep(currentStep(s), s.draft);\n if (!r.ok) return { ...s, errors: r.error };\n return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };\n}\n\nexport function back(s: RegistratieState): RegistratieState {\n if (s.tag !== 'Invullen' || s.cursor === 0) return s;\n return { ...s, cursor: s.cursor - 1, errors: {} };\n}\n\n/** Jump back to an earlier step to correct data (controle → step N). Forward\n jumps are not allowed (would skip validation). Preserves the draft. */\nexport function gaNaarStap(s: RegistratieState, cursor: number): RegistratieState {\n if (s.tag !== 'Invullen' || cursor < 0 || cursor >= s.cursor) return s;\n return { ...s, cursor, errors: {} };\n}\n\nexport function submit(s: RegistratieState): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n const r = validateAll(s.draft);\n return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error };\n}\n\nexport function resolve(s: RegistratieState, r: Result): RegistratieState {\n if (s.tag !== 'Indienen') return s;\n return r.ok ? { tag: 'Ingediend', data: s.data, referentie: r.value } : { tag: 'Mislukt', data: s.data, error: r.error };\n}\n\nexport type RegistratieMsg =\n | { tag: 'SetField'; key: DraftField; value: string }\n | { tag: 'SetCorrespondentie'; value: Correspondentie }\n | { tag: 'PrefillAdres'; straat: string; postcode: string; woonplaats: string }\n | { tag: 'KiesDiploma'; diplomaId: string; beroep: string; vraagIds: string[] }\n | { tag: 'KiesHandmatig'; vraagIds: string[] }\n | { tag: 'DeclareerBeroep'; beroep: string }\n | { tag: 'SetAntwoord'; vraagId: string; value: string }\n | { tag: 'Next' }\n | { tag: 'Back' }\n | { tag: 'GaNaarStap'; cursor: number }\n | { tag: 'Submit' }\n | { tag: 'Retry' }\n | { tag: 'SubmitConfirmed'; referentie: string }\n | { tag: 'SubmitFailed'; error: string }\n | { tag: 'Seed'; state: RegistratieState };\n\nexport function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState {\n switch (m.tag) {\n case 'SetField':\n return setField(s, m.key, m.value);\n case 'SetCorrespondentie':\n return setCorrespondentie(s, m.value);\n case 'PrefillAdres':\n return prefillAdres(s, m.straat, m.postcode, m.woonplaats);\n case 'KiesDiploma':\n return kiesDiploma(s, m.diplomaId, m.beroep, m.vraagIds);\n case 'KiesHandmatig':\n return kiesHandmatig(s, m.vraagIds);\n case 'DeclareerBeroep':\n return declareerBeroep(s, m.beroep);\n case 'SetAntwoord':\n return setAntwoord(s, m.vraagId, m.value);\n case 'Next':\n return next(s);\n case 'Back':\n return back(s);\n case 'GaNaarStap':\n return gaNaarStap(s, m.cursor);\n case 'Submit':\n return submit(s);\n case 'Retry':\n return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s;\n case 'SubmitConfirmed':\n return s.tag === 'Indienen' ? { tag: 'Ingediend', data: s.data, referentie: m.referentie } : s;\n case 'SubmitFailed':\n return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s;\n case 'Seed':\n return m.state;\n default:\n return assertNever(m);\n }\n}\n", + "properties": [ + { + "name": "adresHerkomst", + "deprecated": false, + "deprecationMessage": "", + "type": "AdresHerkomst", + "indexKey": "", + "optional": true, + "description": "", + "line": 32 + }, + { + "name": "antwoorden", + "deprecated": false, + "deprecationMessage": "", + "type": "Record", + "indexKey": "", + "optional": false, + "description": "", + "line": 39 + }, + { + "name": "beroep", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": true, + "description": "", + "line": 37 + }, + { + "name": "correspondentie", + "deprecated": false, + "deprecationMessage": "", + "type": "Correspondentie", + "indexKey": "", + "optional": true, + "description": "", + "line": 33 + }, + { + "name": "diplomaHerkomst", + "deprecated": false, + "deprecationMessage": "", + "type": "DiplomaHerkomst", + "indexKey": "", + "optional": true, + "description": "", + "line": 36 + }, + { + "name": "diplomaId", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": true, + "description": "", + "line": 35 + }, + { + "name": "email", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": true, + "description": "", + "line": 34 + }, + { + "name": "postcode", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": true, + "description": "", + "line": 30 + }, + { + "name": "straat", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": true, + "description": "", + "line": 29 + }, + { + "name": "vraagIds", + "deprecated": false, + "deprecationMessage": "", + "type": "string[]", + "indexKey": "", + "optional": true, + "description": "", + "line": 38 + }, + { + "name": "woonplaats", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": true, + "description": "", + "line": 31 + } + ], + "indexSignatures": [], + "kind": 172, + "description": "

    One record carried across every step (and persisted). All optional: the user\nfills it in gradually. Adres fields are kept flat so one SetField message\nserves them all (mirrors the intake machine).

    \n", + "rawdescription": "\nOne record carried across every step (and persisted). All optional: the user\nfills it in gradually. Adres fields are kept flat so one `SetField` message\nserves them all (mirrors the intake machine).", + "methods": [], + "extends": [], + "isDuplicate": true, + "duplicateId": 1, + "duplicateName": "Draft-1" + }, + { + "name": "DuoDiplomaDto", + "id": "interface-DuoDiplomaDto-03b724eabc3fb95affe36abb0bef921275f0c9eeefd35fab64dd906d4d76e5a38fe849098232c0aaeaf7dd32fa6c0a7b7f2a1bfbbf4c03de4a440f860b3c46e1", + "file": "src/app/registratie/contracts/duo-diplomas.dto.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "export interface DuoLookupDto {\n diplomas: DuoDiplomaDto[];\n handmatig: ManualDiplomaPolicyDto;\n}\n\nexport interface DuoDiplomaDto {\n id: string;\n naam: string;\n instelling: string;\n jaar: number;\n beroep: string; // server-derived profession\n policyQuestions: PolicyQuestionDto[]; // server-decided geldigheidsvragen\n}\n\nexport interface ManualDiplomaPolicyDto {\n beroepen: string[]; // professions the user may declare for a manual diploma\n policyQuestions: PolicyQuestionDto[]; // maximal set applied to a manual diploma\n}\n\nexport interface PolicyQuestionDto {\n id: string;\n vraag: string;\n type: 'ja-nee' | 'tekst';\n}\n", + "properties": [ + { + "name": "beroep", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 25 + }, + { + "name": "id", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 21 + }, + { + "name": "instelling", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 23 + }, + { + "name": "jaar", + "deprecated": false, + "deprecationMessage": "", + "type": "number", + "indexKey": "", + "optional": false, + "description": "", + "line": 24 + }, + { + "name": "naam", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 22 + }, + { + "name": "policyQuestions", + "deprecated": false, + "deprecationMessage": "", + "type": "PolicyQuestionDto[]", + "indexKey": "", + "optional": false, + "description": "", + "line": 26 + } + ], + "indexSignatures": [], + "kind": 172, + "methods": [], + "extends": [] + }, + { + "name": "DuoLookupDto", + "id": "interface-DuoLookupDto-03b724eabc3fb95affe36abb0bef921275f0c9eeefd35fab64dd906d4d76e5a38fe849098232c0aaeaf7dd32fa6c0a7b7f2a1bfbbf4c03de4a440f860b3c46e1", + "file": "src/app/registratie/contracts/duo-diplomas.dto.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "export interface DuoLookupDto {\n diplomas: DuoDiplomaDto[];\n handmatig: ManualDiplomaPolicyDto;\n}\n\nexport interface DuoDiplomaDto {\n id: string;\n naam: string;\n instelling: string;\n jaar: number;\n beroep: string; // server-derived profession\n policyQuestions: PolicyQuestionDto[]; // server-decided geldigheidsvragen\n}\n\nexport interface ManualDiplomaPolicyDto {\n beroepen: string[]; // professions the user may declare for a manual diploma\n policyQuestions: PolicyQuestionDto[]; // maximal set applied to a manual diploma\n}\n\nexport interface PolicyQuestionDto {\n id: string;\n vraag: string;\n type: 'ja-nee' | 'tekst';\n}\n", + "properties": [ + { + "name": "diplomas", + "deprecated": false, + "deprecationMessage": "", + "type": "DuoDiplomaDto[]", + "indexKey": "", + "optional": false, + "description": "", + "line": 16 + }, + { + "name": "handmatig", + "deprecated": false, + "deprecationMessage": "", + "type": "ManualDiplomaPolicyDto", + "indexKey": "", + "optional": false, + "description": "", + "line": 17 + } + ], + "indexSignatures": [], + "kind": 172, + "description": "

    WIRE CONTRACT for the DUO diploma lookup ("BFF-lite" — one screen-shaped call\nreturning everything the beroep step needs).

    \n

    Each diploma carries its server-computed beroep (the profession it maps to)\nand the policyQuestions (geldigheidsvragen) that apply to it. These are\nDECISIONS computed by the backend from the diploma's attributes — the frontend\nrenders them, it does not derive them (decision-DTO pattern, ADR-0001). E.g. an\nEnglish-language diploma carries the Dutch-proficiency question.

    \n

    handmatig is the fallback when the diploma is not in the DUO list: the\nprofessions the user may declare and the MAXIMAL policy-question set that then\napplies (a manual diploma is unverified, so the strictest set is used).

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

    Per-field error map. antwoorden holds per-policy-question errors, keyed by\nquestion id (a step can show several questions).

    \n", + "rawdescription": "\nPer-field error map. `antwoorden` holds per-policy-question errors, keyed by\nquestion id (a step can show several questions).", + "methods": [], + "extends": [] + }, + { + "name": "HerregistratieDecisions", + "id": "interface-HerregistratieDecisions-b39e266ec8fabdaddad87f1e15a5b1b86e45d84ac2c95028b4e676c2eb425fd675c77383c3d4f8f88f4a072f3eb88a954eb0f8755597224ebef59da18d24fda0", + "file": "src/app/registratie/contracts/dashboard-view.dto.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "import { Registration } from '@registratie/domain/registration';\nimport { Person } from '@registratie/domain/person';\nimport { BigProfile } from '@registratie/domain/big-profile';\n\n/**\n * WIRE CONTRACT for the dashboard screen — the \"BFF-lite\" response.\n *\n * In production this type is GENERATED from the OpenAPI/TypeSpec spec (one source\n * of truth for both sides), and the `decisions` block is computed BY THE BACKEND\n * — never recomputed on the client. The frontend renders decisions; it does not\n * own the rules. See docs/architecture/0001-bff-lite-decision-dtos.md.\n *\n * One screen-shaped call replaces the previous three (BIG-register + BRP + …),\n * so the page always sees one consistent snapshot instead of three independently\n * loading/erroring resources.\n */\nexport interface DashboardViewDto {\n registration: Registration;\n person: Person;\n decisions: HerregistratieDecisions;\n}\n\n/** Server-computed decisions. The eligibility rule lives on the backend; the\n optional reason lets the UI explain itself without knowing the rule. */\nexport interface HerregistratieDecisions {\n eligibleForHerregistratie: boolean;\n herregistratieReason?: string;\n}\n\n/** The parsed, frontend-side view (DTO mapped onto our own domain model). Keeping\n this distinct from DashboardViewDto is the decoupling seam: the wire shape can\n change without the FE domain following, and vice-versa. */\nexport interface DashboardView {\n profile: BigProfile;\n decisions: HerregistratieDecisions;\n}\n", + "properties": [ + { + "name": "eligibleForHerregistratie", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 26 + }, + { + "name": "herregistratieReason", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": true, + "description": "", + "line": 27 + } + ], + "indexSignatures": [], + "kind": 172, + "description": "

    Server-computed decisions. The eligibility rule lives on the backend; the\noptional reason lets the UI explain itself without knowing the rule.

    \n", + "rawdescription": "\nServer-computed decisions. The eligibility rule lives on the backend; the\noptional reason lets the UI explain itself without knowing the rule.", + "methods": [], + "extends": [] + }, + { + "name": "IntakePolicyDto", + "id": "interface-IntakePolicyDto-1c35bbad790b2fa78117a69dd0537aa3737a0ede256f3d6834bedb78c85cb056d6f571ed0667abc30f5f6c9eff3e6561723917f21ba9f67b0712fd109594b27a", + "file": "src/app/herregistratie/contracts/intake-policy.dto.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "export interface IntakePolicyDto {\n /** Below this many NL-hours the scholing question is required. */\n scholingThreshold: number;\n}\n", + "properties": [ + { + "name": "scholingThreshold", + "deprecated": false, + "deprecationMessage": "", + "type": "number", + "indexKey": "", + "optional": false, + "description": "

    Below this many NL-hours the scholing question is required.

    \n", + "line": 11, + "rawdescription": "\nBelow this many NL-hours the scholing question is required." + } + ], + "indexSignatures": [], + "kind": 172, + "description": "

    WIRE CONTRACT for intake policy values owned by the backend.

    \n

    This is the "config value" shape of moving policy off the client: instead of\nhardcoding the scholing threshold in the frontend, the backend ships the value\nand the UI applies it for instant feedback. The backend remains the AUTHORITY\n— it re-validates on submit. See docs/architecture/0001-bff-lite-decision-dtos.md.

    \n", + "rawdescription": "\n\nWIRE CONTRACT for intake policy values owned by the backend.\n\nThis is the \"config value\" shape of moving policy off the client: instead of\nhardcoding the scholing threshold in the frontend, the backend ships the value\nand the UI applies it for instant feedback. The backend remains the AUTHORITY\n— it re-validates on submit. See docs/architecture/0001-bff-lite-decision-dtos.md.\n", + "methods": [], + "extends": [] + }, + { + "name": "ManualDiplomaPolicyDto", + "id": "interface-ManualDiplomaPolicyDto-03b724eabc3fb95affe36abb0bef921275f0c9eeefd35fab64dd906d4d76e5a38fe849098232c0aaeaf7dd32fa6c0a7b7f2a1bfbbf4c03de4a440f860b3c46e1", + "file": "src/app/registratie/contracts/duo-diplomas.dto.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "export interface DuoLookupDto {\n diplomas: DuoDiplomaDto[];\n handmatig: ManualDiplomaPolicyDto;\n}\n\nexport interface DuoDiplomaDto {\n id: string;\n naam: string;\n instelling: string;\n jaar: number;\n beroep: string; // server-derived profession\n policyQuestions: PolicyQuestionDto[]; // server-decided geldigheidsvragen\n}\n\nexport interface ManualDiplomaPolicyDto {\n beroepen: string[]; // professions the user may declare for a manual diploma\n policyQuestions: PolicyQuestionDto[]; // maximal set applied to a manual diploma\n}\n\nexport interface PolicyQuestionDto {\n id: string;\n vraag: string;\n type: 'ja-nee' | 'tekst';\n}\n", + "properties": [ + { + "name": "beroepen", + "deprecated": false, + "deprecationMessage": "", + "type": "string[]", + "indexKey": "", + "optional": false, + "description": "", + "line": 30 + }, + { + "name": "policyQuestions", + "deprecated": false, + "deprecationMessage": "", + "type": "PolicyQuestionDto[]", + "indexKey": "", + "optional": false, + "description": "", + "line": 31 + } + ], + "indexSignatures": [], + "kind": 172, + "methods": [], + "extends": [] + }, { "name": "Person", "id": "interface-Person-07cec46d80a41919e40d0bcb002981627d1a30edb363d64a0a140a8af2c38ad85be5603d88030d0704870800694c5bed9c709ae6891fe83e646e90c1a67cf548", @@ -346,14 +931,59 @@ "methods": [], "extends": [] }, + { + "name": "PolicyQuestionDto", + "id": "interface-PolicyQuestionDto-03b724eabc3fb95affe36abb0bef921275f0c9eeefd35fab64dd906d4d76e5a38fe849098232c0aaeaf7dd32fa6c0a7b7f2a1bfbbf4c03de4a440f860b3c46e1", + "file": "src/app/registratie/contracts/duo-diplomas.dto.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "export interface DuoLookupDto {\n diplomas: DuoDiplomaDto[];\n handmatig: ManualDiplomaPolicyDto;\n}\n\nexport interface DuoDiplomaDto {\n id: string;\n naam: string;\n instelling: string;\n jaar: number;\n beroep: string; // server-derived profession\n policyQuestions: PolicyQuestionDto[]; // server-decided geldigheidsvragen\n}\n\nexport interface ManualDiplomaPolicyDto {\n beroepen: string[]; // professions the user may declare for a manual diploma\n policyQuestions: PolicyQuestionDto[]; // maximal set applied to a manual diploma\n}\n\nexport interface PolicyQuestionDto {\n id: string;\n vraag: string;\n type: 'ja-nee' | 'tekst';\n}\n", + "properties": [ + { + "name": "id", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 35 + }, + { + "name": "type", + "deprecated": false, + "deprecationMessage": "", + "type": "\"ja-nee\" | \"tekst\"", + "indexKey": "", + "optional": false, + "description": "", + "line": 37 + }, + { + "name": "vraag", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 36 + } + ], + "indexSignatures": [], + "kind": 172, + "methods": [], + "extends": [] + }, { "name": "RadioOption", - "id": "interface-RadioOption-ac09f04d2bd5bb44d96600b5081778fdf878ad9137e373a0832855877706afbe37687f7937f568a890e541467637b067a6c8646dcb2005ab746f1a829d8d39a2", + "id": "interface-RadioOption-a4c75390d3b62730cdd6f53a2ffcb3badde60f23c9104d57d8f8221d038f1dc96a229916ee28bbec4a54dd41e0e88c838ea46b35facfab0ddb5fea79fed37cc0", "file": "src/app/shared/ui/radio-group/radio-group.component.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "import { Component, forwardRef, input } from '@angular/core';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\n\nexport interface RadioOption {\n value: string;\n label: string;\n}\n\n/** Atom: a radio group. Thin wrapper over the Utrecht/RHC radio CSS, wired as a\n form control so it works with ngModel just like the text-input atom. */\n@Component({\n selector: 'app-radio-group',\n template: `\n
    \n @for (opt of options(); track opt.value) {\n \n }\n
    \n `,\n providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RadioGroupComponent), multi: true }],\n})\nexport class RadioGroupComponent implements ControlValueAccessor {\n options = input.required();\n name = input.required();\n\n value = '';\n disabled = false;\n onChange: (v: string) => void = () => {};\n onTouched: () => void = () => {};\n\n select(v: string) {\n this.value = v;\n this.onChange(v);\n }\n writeValue(v: string) { this.value = v ?? ''; }\n registerOnChange(fn: (v: string) => void) { this.onChange = fn; }\n registerOnTouched(fn: () => void) { this.onTouched = fn; }\n setDisabledState(d: boolean) { this.disabled = d; }\n}\n", + "sourceCode": "import { Component, forwardRef, input } from '@angular/core';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\n\nexport interface RadioOption {\n value: string;\n label: string;\n}\n\n/** Atom: a radio group. Thin wrapper over the Utrecht/RHC radio CSS, wired as a\n form control so it works with ngModel just like the text-input atom. */\n@Component({\n selector: 'app-radio-group',\n styles: [`\n .rhc-radio-option {\n display: flex;\n align-items: center;\n gap: var(--rhc-space-max-md);\n padding-block: var(--rhc-space-max-sm);\n }\n `],\n template: `\n \n @for (opt of options(); track opt.value) {\n \n }\n

\n `,\n providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RadioGroupComponent), multi: true }],\n})\nexport class RadioGroupComponent implements ControlValueAccessor {\n options = input.required();\n name = input.required();\n invalid = input(false);\n\n value = '';\n disabled = false;\n onChange: (v: string) => void = () => {};\n onTouched: () => void = () => {};\n\n select(v: string) {\n this.value = v;\n this.onChange(v);\n }\n writeValue(v: string) { this.value = v ?? ''; }\n registerOnChange(fn: (v: string) => void) { this.onChange = fn; }\n registerOnTouched(fn: () => void) { this.onTouched = fn; }\n setDisabledState(d: boolean) { this.disabled = d; }\n}\n", "properties": [ { "name": "label", @@ -614,12 +1244,12 @@ }, { "name": "ValidIntake", - "id": "interface-ValidIntake-53c06ae2dad9470cd0c742a7e5903458a870e5eaaf70ce3043cec53935f09d2cb0d5f9980b723ce79be2460d550908bc894743c867abdf0974f17f7596cdd05d", + "id": "interface-ValidIntake-13704a2a04ab3cffa46cbc109ee84699b236928e1c2649f459e18e4daf323047e8b21cdd5a21440c5fd696a605a09feb673b4020394ba7954ae35162f1bd2732", "file": "src/app/herregistratie/domain/intake.machine.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "import { Result, ok, err, assertNever } from '@shared/kernel/fp';\nimport { Uren, parseUren } from '@registratie/domain/value-objects/uren';\n\n/**\n * A BRANCHING wizard. Unlike herregistratie.machine (fixed 2 steps), the set of\n * steps here is NOT stored — it's *derived* from the answers by `visibleSteps`.\n * Answer \"buiten Nederland gewerkt? → ja\" and two extra steps appear; report few\n * hours and a scholing-question appears. The progress bar's denominator changes\n * as you type. \"Which question comes next\" is a pure function, so it's trivial\n * to test and impossible to get out of sync with the data.\n */\n\nexport type JaNee = 'ja' | 'nee';\n\n/** Every possible question, as a union — never a bare string. */\nexport type StepId = 'buitenland' | 'buitenlandDetails' | 'uren' | 'scholing' | 'punten' | 'review';\n\n/** One record carried across every step (and persisted). All optional: the user\n fills it in gradually, and branches may never ask some fields. */\nexport interface Answers {\n buitenlandGewerkt?: JaNee; // Q1\n land?: string; // Q1a — only when buitenlandGewerkt === 'ja'\n buitenlandseUren?: string; // Q1b — only when buitenlandGewerkt === 'ja'\n uren?: string; // Q2 — uren in NL\n scholingGevolgd?: JaNee; // Q3 — only when total hours are below the threshold\n punten?: string; // Q4\n}\n\n/** What we have after the review step parses — guaranteed valid/typed. */\nexport interface ValidIntake {\n werktBuitenland: boolean;\n land?: string;\n buitenlandseUren?: Uren;\n uren: Uren;\n aanvullendeScholing?: boolean;\n punten: Uren;\n}\n\n/** Below this many NL-hours we ask whether extra scholing was followed. */\nconst LAGE_UREN_DREMPEL = 1000;\n\nfunction lageUren(a: Answers): boolean {\n const r = parseUren(a.uren ?? '');\n return r.ok && r.value < LAGE_UREN_DREMPEL;\n}\n\n/**\n * THE branching, as one pure function. The step list is recomputed on every\n * transition, so changing an earlier answer immediately adds/removes later steps.\n */\nexport function visibleSteps(a: Answers): StepId[] {\n const steps: StepId[] = ['buitenland'];\n if (a.buitenlandGewerkt === 'ja') steps.push('buitenlandDetails');\n steps.push('uren');\n if (lageUren(a)) steps.push('scholing');\n steps.push('punten', 'review');\n return steps;\n}\n\nexport type IntakeState =\n | { tag: 'Answering'; answers: Answers; cursor: number; errors: Partial> }\n | { tag: 'Submitting'; data: ValidIntake }\n | { tag: 'Submitted'; data: ValidIntake }\n | { tag: 'Failed'; data: ValidIntake; error: string };\n\nexport const initial: IntakeState = { tag: 'Answering', answers: {}, cursor: 0, errors: {} };\n\n/** Which step the cursor currently points at (clamped to the live step list). */\nexport function currentStep(s: Extract): StepId {\n const steps = visibleSteps(s.answers);\n return steps[Math.min(s.cursor, steps.length - 1)];\n}\n\n/** Validate ONE step's fields. Returns the per-field error keyed by StepId. */\nfunction validateStep(step: StepId, a: Answers): Result>, void> {\n switch (step) {\n case 'buitenland':\n return a.buitenlandGewerkt ? ok(undefined) : err({ buitenland: 'Maak een keuze.' });\n case 'buitenlandDetails': {\n if (!a.land || a.land.trim() === '') return err({ buitenlandDetails: 'Vul een land in.' });\n const u = parseUren(a.buitenlandseUren ?? '');\n return u.ok ? ok(undefined) : err({ buitenlandDetails: u.error });\n }\n case 'uren': {\n const u = parseUren(a.uren ?? '');\n return u.ok ? ok(undefined) : err({ uren: u.error });\n }\n case 'scholing':\n return a.scholingGevolgd ? ok(undefined) : err({ scholing: 'Maak een keuze.' });\n case 'punten': {\n const p = parseUren(a.punten ?? '');\n return p.ok ? ok(undefined) : err({ punten: p.error });\n }\n case 'review':\n return ok(undefined); // review shows a summary; no own fields\n default:\n return assertNever(step);\n }\n}\n\n/** Parse the whole questionnaire into a ValidIntake (called on submit). */\nfunction validateAll(a: Answers): Result>, ValidIntake> {\n const errors: Partial> = {};\n for (const step of visibleSteps(a)) {\n const r = validateStep(step, a);\n if (!r.ok) Object.assign(errors, r.error);\n }\n if (Object.keys(errors).length > 0) return err(errors);\n\n const uren = parseUren(a.uren ?? '');\n const punten = parseUren(a.punten ?? '');\n // visibleSteps guaranteed these parse, but keep the compiler happy.\n if (!uren.ok || !punten.ok) return err(errors);\n\n const werktBuitenland = a.buitenlandGewerkt === 'ja';\n const buitenland = parseUren(a.buitenlandseUren ?? '');\n return ok({\n werktBuitenland,\n land: werktBuitenland ? a.land : undefined,\n buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,\n uren: uren.value,\n aanvullendeScholing: lageUren(a) ? a.scholingGevolgd === 'ja' : undefined,\n punten: punten.value,\n });\n}\n\nexport function setAnswer(s: IntakeState, key: keyof Answers, value: string): IntakeState {\n if (s.tag !== 'Answering') return s;\n const answers = { ...s.answers, [key]: value };\n // Editing an earlier answer can shrink the step list; clamp the cursor.\n const cursor = Math.min(s.cursor, visibleSteps(answers).length - 1);\n return { ...s, answers, cursor };\n}\n\nexport function next(s: IntakeState): IntakeState {\n if (s.tag !== 'Answering') return s;\n const r = validateStep(currentStep(s), s.answers);\n if (!r.ok) return { ...s, errors: r.error };\n const last = visibleSteps(s.answers).length - 1;\n return { ...s, cursor: Math.min(s.cursor + 1, last), errors: {} };\n}\n\nexport function back(s: IntakeState): IntakeState {\n if (s.tag !== 'Answering' || s.cursor === 0) return s;\n return { ...s, cursor: s.cursor - 1, errors: {} };\n}\n\nexport function submit(s: IntakeState): IntakeState {\n if (s.tag !== 'Answering') return s;\n const r = validateAll(s.answers);\n return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };\n}\n\nexport function resolve(s: IntakeState, r: Result): IntakeState {\n if (s.tag !== 'Submitting') return s;\n return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error };\n}\n\nexport type IntakeMsg =\n | { tag: 'SetAnswer'; key: keyof Answers; value: string }\n | { tag: 'Next' }\n | { tag: 'Back' }\n | { tag: 'Submit' }\n | { tag: 'Retry' }\n | { tag: 'SubmitConfirmed' }\n | { tag: 'SubmitFailed'; error: string }\n | { tag: 'Seed'; state: IntakeState };\n\nexport function reduce(s: IntakeState, m: IntakeMsg): IntakeState {\n switch (m.tag) {\n case 'SetAnswer':\n return setAnswer(s, m.key, m.value);\n case 'Next':\n return next(s);\n case 'Back':\n return back(s);\n case 'Submit':\n return submit(s);\n case 'Retry':\n return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;\n case 'SubmitConfirmed':\n return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;\n case 'SubmitFailed':\n return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;\n case 'Seed':\n return m.state;\n default:\n return assertNever(m);\n }\n}\n", + "sourceCode": "import { Result, ok, err, assertNever } from '@shared/kernel/fp';\nimport { Uren, parseUren } from '@registratie/domain/value-objects/uren';\n\n/**\n * A FIXED 3-step wizard with progressive disclosure. The steps never change in\n * number (always `STEPS`); instead, follow-up questions appear *inline within a\n * step* depending on earlier answers — answer \"buiten Nederland gewerkt? → ja\"\n * and the country/hours questions reveal in the same step; report few hours and\n * the scholing-question reveals inside the 'werk' step. \"Is this field required\n * right now\" is a pure function (`validateStep`/`lageUren`), so it's trivial to\n * test and impossible to get out of sync with the data.\n */\n\nexport type JaNee = 'ja' | 'nee';\n\n/** The three fixed steps. Each step groups one or more questions. */\nexport type StepId = 'buitenland' | 'werk' | 'review';\n\n/** One record carried across every step (and persisted). All optional: the user\n fills it in gradually, and branches may never ask some fields. */\nexport interface Answers {\n buitenlandGewerkt?: JaNee; // Q1\n land?: string; // Q1a — only when buitenlandGewerkt === 'ja'\n buitenlandseUren?: string; // Q1b — only when buitenlandGewerkt === 'ja'\n uren?: string; // Q2 — uren in NL\n scholingGevolgd?: JaNee; // Q3 — only when total hours are below the threshold\n punten?: string; // Q4\n}\n\n/** What we have after the review step parses — guaranteed valid/typed. */\nexport interface ValidIntake {\n werktBuitenland: boolean;\n land?: string;\n buitenlandseUren?: Uren;\n uren: Uren;\n aanvullendeScholing?: boolean;\n punten?: Uren; // only collected when aanvullende scholing is gevolgd (scholingGevolgd === 'ja')\n}\n\n/** Demo fallback only — the real threshold is a SERVER-OWNED policy value fetched\n at runtime (see IntakePolicyDto / SetPolicy). ponytail: default is the offline\n fallback; the server value wins. */\nexport const SCHOLING_THRESHOLD_DEFAULT = 1000;\n\n/** True when NL-hours are low enough that the scholing question must be answered.\n The threshold is passed in (server-owned), not hardcoded. */\nexport function lageUren(a: Answers, scholingThreshold = SCHOLING_THRESHOLD_DEFAULT): boolean {\n const r = parseUren(a.uren ?? '');\n return r.ok && r.value < scholingThreshold;\n}\n\n/** The fixed step list. Number of steps never changes; questions reveal inline. */\nexport const STEPS: StepId[] = ['buitenland', 'werk', 'review'];\n\n/** Per-field error map: one message per question, since a step holds several. */\ntype Errors = Partial>;\n\nexport type IntakeState =\n | { tag: 'Answering'; answers: Answers; cursor: number; errors: Errors; scholingThreshold: number }\n | { tag: 'Submitting'; data: ValidIntake }\n | { tag: 'Submitted'; data: ValidIntake }\n | { tag: 'Failed'; data: ValidIntake; error: string };\n\nexport const initial: IntakeState = { tag: 'Answering', answers: {}, cursor: 0, errors: {}, scholingThreshold: SCHOLING_THRESHOLD_DEFAULT };\n\n/** Which step the cursor currently points at (clamped to the fixed list). */\nexport function currentStep(s: Extract): StepId {\n return STEPS[Math.min(s.cursor, STEPS.length - 1)];\n}\n\n/** Validate every question currently visible in ONE step. Errors keyed per field. */\nfunction validateStep(step: StepId, a: Answers, scholingThreshold: number): Result {\n const errors: Errors = {};\n switch (step) {\n case 'buitenland':\n if (!a.buitenlandGewerkt) errors.buitenlandGewerkt = 'Maak een keuze.';\n else if (a.buitenlandGewerkt === 'ja') {\n if (!a.land || a.land.trim() === '') errors.land = 'Vul een land in.';\n const u = parseUren(a.buitenlandseUren ?? '');\n if (!u.ok) errors.buitenlandseUren = u.error;\n }\n break;\n case 'werk': {\n const u = parseUren(a.uren ?? '');\n if (!u.ok) errors.uren = u.error;\n if (lageUren(a, scholingThreshold) && !a.scholingGevolgd) errors.scholingGevolgd = 'Maak een keuze.';\n // Nascholingspunten are only asked (and required) when scholing was followed.\n if (a.scholingGevolgd === 'ja') {\n const p = parseUren(a.punten ?? '');\n if (!p.ok) errors.punten = p.error;\n }\n break;\n }\n case 'review':\n break; // review shows a summary; no own fields\n default:\n return assertNever(step);\n }\n return Object.keys(errors).length > 0 ? err(errors) : ok(undefined);\n}\n\n/** Parse the whole questionnaire into a ValidIntake (called on submit). */\nfunction validateAll(a: Answers, scholingThreshold: number): Result {\n const errors: Errors = {};\n for (const step of STEPS) {\n const r = validateStep(step, a, scholingThreshold);\n if (!r.ok) Object.assign(errors, r.error);\n }\n if (Object.keys(errors).length > 0) return err(errors);\n\n const uren = parseUren(a.uren ?? '');\n // validateStep guaranteed uren parses, but keep the compiler happy.\n if (!uren.ok) return err(errors);\n\n const werktBuitenland = a.buitenlandGewerkt === 'ja';\n const buitenland = parseUren(a.buitenlandseUren ?? '');\n // Punten are only collected when aanvullende scholing was gevolgd.\n const punten = a.scholingGevolgd === 'ja' ? parseUren(a.punten ?? '') : undefined;\n return ok({\n werktBuitenland,\n land: werktBuitenland ? a.land : undefined,\n buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,\n uren: uren.value,\n aanvullendeScholing: lageUren(a, scholingThreshold) ? a.scholingGevolgd === 'ja' : undefined,\n punten: punten?.ok ? punten.value : undefined,\n });\n}\n\nexport function setAnswer(s: IntakeState, key: keyof Answers, value: string): IntakeState {\n if (s.tag !== 'Answering') return s;\n // Steps are fixed, so editing an answer never moves the cursor — it only\n // reveals/hides inline questions within the current step.\n return { ...s, answers: { ...s.answers, [key]: value } };\n}\n\nexport function next(s: IntakeState): IntakeState {\n if (s.tag !== 'Answering') return s;\n const r = validateStep(currentStep(s), s.answers, s.scholingThreshold);\n if (!r.ok) return { ...s, errors: r.error };\n return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };\n}\n\n/** Apply a server-owned policy value (e.g. the scholing threshold). */\nexport function setPolicy(s: IntakeState, scholingThreshold: number): IntakeState {\n return s.tag === 'Answering' ? { ...s, scholingThreshold } : s;\n}\n\nexport function back(s: IntakeState): IntakeState {\n if (s.tag !== 'Answering' || s.cursor === 0) return s;\n return { ...s, cursor: s.cursor - 1, errors: {} };\n}\n\nexport function submit(s: IntakeState): IntakeState {\n if (s.tag !== 'Answering') return s;\n const r = validateAll(s.answers, s.scholingThreshold);\n return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };\n}\n\nexport function resolve(s: IntakeState, r: Result): IntakeState {\n if (s.tag !== 'Submitting') return s;\n return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error };\n}\n\nexport type IntakeMsg =\n | { tag: 'SetAnswer'; key: keyof Answers; value: string }\n | { tag: 'Next' }\n | { tag: 'Back' }\n | { tag: 'Submit' }\n | { tag: 'Retry' }\n | { tag: 'SubmitConfirmed' }\n | { tag: 'SubmitFailed'; error: string }\n | { tag: 'SetPolicy'; scholingThreshold: number }\n | { tag: 'Seed'; state: IntakeState };\n\nexport function reduce(s: IntakeState, m: IntakeMsg): IntakeState {\n switch (m.tag) {\n case 'SetAnswer':\n return setAnswer(s, m.key, m.value);\n case 'Next':\n return next(s);\n case 'Back':\n return back(s);\n case 'Submit':\n return submit(s);\n case 'Retry':\n return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;\n case 'SubmitConfirmed':\n return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;\n case 'SubmitFailed':\n return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;\n case 'SetPolicy':\n return setPolicy(s, m.scholingThreshold);\n case 'Seed':\n return m.state;\n default:\n return assertNever(m);\n }\n}\n", "properties": [ { "name": "aanvullendeScholing", @@ -629,7 +1259,7 @@ "indexKey": "", "optional": true, "description": "", - "line": 35 + "line": 36 }, { "name": "buitenlandseUren", @@ -639,7 +1269,7 @@ "indexKey": "", "optional": true, "description": "", - "line": 33 + "line": 34 }, { "name": "land", @@ -649,7 +1279,7 @@ "indexKey": "", "optional": true, "description": "", - "line": 32 + "line": 33 }, { "name": "punten", @@ -657,9 +1287,9 @@ "deprecationMessage": "", "type": "Uren", "indexKey": "", - "optional": false, + "optional": true, "description": "", - "line": 36 + "line": 37 }, { "name": "uren", @@ -669,7 +1299,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 34 + "line": 35 }, { "name": "werktBuitenland", @@ -679,7 +1309,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 31 + "line": 32 } ], "indexSignatures": [], @@ -688,12 +1318,109 @@ "rawdescription": "\nWhat we have after the review step parses — guaranteed valid/typed.", "methods": [], "extends": [] + }, + { + "name": "ValidRegistratie", + "id": "interface-ValidRegistratie-6ed41f6a28b6c00dc907f54219be9c1d7955aa11bc22f128620d2206b1ea12e6730533ac33f5f5df7b8d7c8d7f977584b3a0a3e98a6c2c3b91d1afe6411278e2", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "import { Result, ok, err, assertNever } from '@shared/kernel/fp';\nimport { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';\nimport { Email, parseEmail } from '@registratie/domain/value-objects/email';\n\n/**\n * A FIXED 3-step registration wizard. The steps never change in number (always\n * `STEPS`): (1) adres + correspondentievoorkeur, (2) beroep o.b.v. diploma,\n * (3) controle & indienen. Follow-up questions appear *inline within a step*\n * (e.g. choosing 'email' reveals the e-mail field). \"Is this field required\n * right now\" is a pure function (`validateStep`), so it is trivial to test and\n * impossible to get out of sync with the data. Invariants live here, not in the\n * UI: the wizard reaches `Indienen` only when a complete `ValidRegistratie` parses.\n */\n\nexport type StepId = 'adres' | 'beroep' | 'controle';\n\n/** The fixed step list. Number of steps never changes; questions reveal inline. */\nexport const STEPS: StepId[] = ['adres', 'beroep', 'controle'];\n\n/** Where a piece of data came from — recorded on the aggregate (PRD §5). */\nexport type AdresHerkomst = 'brp' | 'handmatig';\nexport type DiplomaHerkomst = 'duo' | 'handmatig';\nexport type Correspondentie = 'email' | 'post';\n\n/** One record carried across every step (and persisted). All optional: the user\n fills it in gradually. Adres fields are kept flat so one `SetField` message\n serves them all (mirrors the intake machine). */\nexport interface Draft {\n straat?: string;\n postcode?: string;\n woonplaats?: string;\n adresHerkomst?: AdresHerkomst;\n correspondentie?: Correspondentie;\n email?: string;\n diplomaId?: string;\n diplomaHerkomst?: DiplomaHerkomst;\n beroep?: string; // DERIVED from the chosen DUO diploma (or declared for a manual one)\n vraagIds?: string[]; // ids of the policy questions that apply to the chosen diploma\n antwoorden: Record; // geldigheidsantwoorden, keyed by question id\n}\n\n/** What we have after the controle step parses — guaranteed valid/typed. */\nexport interface ValidRegistratie {\n adres: { straat: string; postcode: Postcode; woonplaats: string };\n adresHerkomst: AdresHerkomst;\n correspondentie: Correspondentie;\n email?: Email; // only when correspondentie === 'email'\n diplomaId: string;\n diplomaHerkomst: DiplomaHerkomst;\n beroep: string;\n antwoorden: Record;\n}\n\n/** Text fields settable via SetField. */\nexport type DraftField = 'straat' | 'postcode' | 'woonplaats' | 'email';\n\n/** Per-field error map. `antwoorden` holds per-policy-question errors, keyed by\n question id (a step can show several questions). */\nexport interface Errors {\n straat?: string;\n postcode?: string;\n woonplaats?: string;\n email?: string;\n correspondentie?: string;\n diploma?: string;\n antwoorden?: Record;\n}\n\nexport type RegistratieState =\n | { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors }\n | { tag: 'Indienen'; data: ValidRegistratie }\n | { tag: 'Ingediend'; data: ValidRegistratie; referentie: string }\n | { tag: 'Mislukt'; data: ValidRegistratie; error: string };\n\nconst emptyDraft: Draft = { antwoorden: {} };\nexport const initial: RegistratieState = { tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {} };\n\n/** Which step the cursor currently points at (clamped to the fixed list). */\nexport function currentStep(s: Extract): StepId {\n return STEPS[Math.min(s.cursor, STEPS.length - 1)];\n}\n\n/** Validate every question currently visible in ONE step. Errors keyed per field. */\nfunction validateStep(step: StepId, d: Draft): Result {\n const errors: Errors = {};\n switch (step) {\n case 'adres': {\n if (!d.straat || d.straat.trim() === '') errors.straat = 'Vul een straat en huisnummer in.';\n const pc = parsePostcode(d.postcode ?? '');\n if (!pc.ok) errors.postcode = pc.error;\n if (!d.woonplaats || d.woonplaats.trim() === '') errors.woonplaats = 'Vul een woonplaats in.';\n if (!d.correspondentie) errors.correspondentie = 'Maak een keuze.';\n // E-mail is only required when 'email' is the chosen channel.\n if (d.correspondentie === 'email') {\n const e = parseEmail(d.email ?? '');\n if (!e.ok) errors.email = e.error;\n }\n break;\n }\n case 'beroep': {\n // A diploma must be chosen (or declared manually); its beroep is then known.\n if (!d.diplomaId || !d.beroep) {\n errors.diploma = 'Kies het diploma waarmee u zich wilt registreren, of voer het handmatig in.';\n break;\n }\n // Every policy question the chosen diploma raised must be answered. Which\n // questions apply is server-decided (carried in `vraagIds`); we only check\n // they're answered.\n const open: Record = {};\n for (const id of d.vraagIds ?? []) {\n if (!(d.antwoorden[id] ?? '').trim()) open[id] = 'Beantwoord deze vraag.';\n }\n if (Object.keys(open).length > 0) errors.antwoorden = open;\n break;\n }\n case 'controle':\n break; // controle shows a summary; no own fields\n default:\n return assertNever(step);\n }\n return Object.keys(errors).length > 0 ? err(errors) : ok(undefined);\n}\n\n/** Parse the whole wizard into a ValidRegistratie (called on submit). */\nfunction validateAll(d: Draft): Result {\n const errors: Errors = {};\n for (const step of STEPS) {\n const r = validateStep(step, d);\n if (!r.ok) Object.assign(errors, r.error);\n }\n if (Object.keys(errors).length > 0) return err(errors);\n\n const pc = parsePostcode(d.postcode ?? '');\n // validateStep guaranteed these parse, but keep the compiler happy.\n if (!pc.ok || !d.diplomaId || !d.beroep || !d.correspondentie) return err(errors);\n const email = d.correspondentie === 'email' ? parseEmail(d.email ?? '') : undefined;\n // Keep only the answers to the questions that actually applied.\n const vraagIds = d.vraagIds ?? [];\n const antwoorden = Object.fromEntries(vraagIds.map((id) => [id, d.antwoorden[id] ?? '']));\n\n return ok({\n adres: { straat: d.straat!, postcode: pc.value, woonplaats: d.woonplaats! },\n adresHerkomst: d.adresHerkomst ?? 'handmatig',\n correspondentie: d.correspondentie,\n email: email?.ok ? email.value : undefined,\n diplomaId: d.diplomaId,\n diplomaHerkomst: d.diplomaHerkomst ?? 'handmatig',\n beroep: d.beroep,\n antwoorden,\n });\n}\n\nexport function setField(s: RegistratieState, key: DraftField, value: string): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n const draft: Draft = { ...s.draft, [key]: value };\n // Editing an address field means the user owns it now — not the BRP copy.\n if (key === 'straat' || key === 'postcode' || key === 'woonplaats') draft.adresHerkomst = 'handmatig';\n return { ...s, draft };\n}\n\nexport function setCorrespondentie(s: RegistratieState, value: Correspondentie): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n return { ...s, draft: { ...s.draft, correspondentie: value } };\n}\n\n/** Prefill the address from a BRP lookup and flag its origin (PRD §7). */\nexport function prefillAdres(s: RegistratieState, straat: string, postcode: string, woonplaats: string): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n return { ...s, draft: { ...s.draft, straat, postcode, woonplaats, adresHerkomst: 'brp' } };\n}\n\n/** Pick a DUO diploma; the beroep is derived from it and the applicable policy\n questions (`vraagIds`) come with it (both server-computed, passed in). */\nexport function kiesDiploma(s: RegistratieState, diplomaId: string, beroep: string, vraagIds: string[]): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n return { ...s, draft: { ...s.draft, diplomaId, beroep, vraagIds, diplomaHerkomst: 'duo' }, errors: {} };\n}\n\n/** Switch to manual diploma entry: the diploma isn't in DUO, so the MAXIMAL\n policy-question set applies and the entry is flagged handmatig/unverified. The\n beroep is declared separately (declareerBeroep). */\nexport function kiesHandmatig(s: RegistratieState, vraagIds: string[]): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n return { ...s, draft: { ...s.draft, diplomaId: 'handmatig', beroep: undefined, vraagIds, diplomaHerkomst: 'handmatig' }, errors: {} };\n}\n\n/** Declare the beroep for a manually-entered diploma (chosen from a fixed list). */\nexport function declareerBeroep(s: RegistratieState, beroep: string): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n return { ...s, draft: { ...s.draft, beroep } };\n}\n\nexport function setAntwoord(s: RegistratieState, vraagId: string, value: string): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n return { ...s, draft: { ...s.draft, antwoorden: { ...s.draft.antwoorden, [vraagId]: value } } };\n}\n\nexport function next(s: RegistratieState): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n const r = validateStep(currentStep(s), s.draft);\n if (!r.ok) return { ...s, errors: r.error };\n return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };\n}\n\nexport function back(s: RegistratieState): RegistratieState {\n if (s.tag !== 'Invullen' || s.cursor === 0) return s;\n return { ...s, cursor: s.cursor - 1, errors: {} };\n}\n\n/** Jump back to an earlier step to correct data (controle → step N). Forward\n jumps are not allowed (would skip validation). Preserves the draft. */\nexport function gaNaarStap(s: RegistratieState, cursor: number): RegistratieState {\n if (s.tag !== 'Invullen' || cursor < 0 || cursor >= s.cursor) return s;\n return { ...s, cursor, errors: {} };\n}\n\nexport function submit(s: RegistratieState): RegistratieState {\n if (s.tag !== 'Invullen') return s;\n const r = validateAll(s.draft);\n return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error };\n}\n\nexport function resolve(s: RegistratieState, r: Result): RegistratieState {\n if (s.tag !== 'Indienen') return s;\n return r.ok ? { tag: 'Ingediend', data: s.data, referentie: r.value } : { tag: 'Mislukt', data: s.data, error: r.error };\n}\n\nexport type RegistratieMsg =\n | { tag: 'SetField'; key: DraftField; value: string }\n | { tag: 'SetCorrespondentie'; value: Correspondentie }\n | { tag: 'PrefillAdres'; straat: string; postcode: string; woonplaats: string }\n | { tag: 'KiesDiploma'; diplomaId: string; beroep: string; vraagIds: string[] }\n | { tag: 'KiesHandmatig'; vraagIds: string[] }\n | { tag: 'DeclareerBeroep'; beroep: string }\n | { tag: 'SetAntwoord'; vraagId: string; value: string }\n | { tag: 'Next' }\n | { tag: 'Back' }\n | { tag: 'GaNaarStap'; cursor: number }\n | { tag: 'Submit' }\n | { tag: 'Retry' }\n | { tag: 'SubmitConfirmed'; referentie: string }\n | { tag: 'SubmitFailed'; error: string }\n | { tag: 'Seed'; state: RegistratieState };\n\nexport function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState {\n switch (m.tag) {\n case 'SetField':\n return setField(s, m.key, m.value);\n case 'SetCorrespondentie':\n return setCorrespondentie(s, m.value);\n case 'PrefillAdres':\n return prefillAdres(s, m.straat, m.postcode, m.woonplaats);\n case 'KiesDiploma':\n return kiesDiploma(s, m.diplomaId, m.beroep, m.vraagIds);\n case 'KiesHandmatig':\n return kiesHandmatig(s, m.vraagIds);\n case 'DeclareerBeroep':\n return declareerBeroep(s, m.beroep);\n case 'SetAntwoord':\n return setAntwoord(s, m.vraagId, m.value);\n case 'Next':\n return next(s);\n case 'Back':\n return back(s);\n case 'GaNaarStap':\n return gaNaarStap(s, m.cursor);\n case 'Submit':\n return submit(s);\n case 'Retry':\n return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s;\n case 'SubmitConfirmed':\n return s.tag === 'Indienen' ? { tag: 'Ingediend', data: s.data, referentie: m.referentie } : s;\n case 'SubmitFailed':\n return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s;\n case 'Seed':\n return m.state;\n default:\n return assertNever(m);\n }\n}\n", + "properties": [ + { + "name": "adres", + "deprecated": false, + "deprecationMessage": "", + "type": "literal type", + "indexKey": "", + "optional": false, + "description": "", + "line": 44 + }, + { + "name": "adresHerkomst", + "deprecated": false, + "deprecationMessage": "", + "type": "AdresHerkomst", + "indexKey": "", + "optional": false, + "description": "", + "line": 45 + }, + { + "name": "antwoorden", + "deprecated": false, + "deprecationMessage": "", + "type": "Record", + "indexKey": "", + "optional": false, + "description": "", + "line": 51 + }, + { + "name": "beroep", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 50 + }, + { + "name": "correspondentie", + "deprecated": false, + "deprecationMessage": "", + "type": "Correspondentie", + "indexKey": "", + "optional": false, + "description": "", + "line": 46 + }, + { + "name": "diplomaHerkomst", + "deprecated": false, + "deprecationMessage": "", + "type": "DiplomaHerkomst", + "indexKey": "", + "optional": false, + "description": "", + "line": 49 + }, + { + "name": "diplomaId", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 48 + }, + { + "name": "email", + "deprecated": false, + "deprecationMessage": "", + "type": "Email", + "indexKey": "", + "optional": true, + "description": "", + "line": 47 + } + ], + "indexSignatures": [], + "kind": 172, + "description": "

What we have after the controle step parses — guaranteed valid/typed.

\n", + "rawdescription": "\nWhat we have after the controle step parses — guaranteed valid/typed.", + "methods": [], + "extends": [] } ], "injectables": [ { "name": "BigProfileStore", - "id": "injectable-BigProfileStore-d0fd61e9f3ea54ee7ebca6eb9a097da261a7447f337e972e7bbb2a8c238f15a8f175b1fddac6c031fc95f77ab8d2778748a149f358ef5f5ec22ecaa23971ef9a", + "id": "injectable-BigProfileStore-69aed5b05b57a19cafee014d2f45ac87468f795733cf7c00ad07d0283d30edd836e2d4b8a45cad42641fa5c84cad96f314aa4d287ca1c55d3fdd8feceefcd642", "file": "src/app/registratie/application/big-profile.store.ts", "properties": [ { @@ -705,7 +1432,7 @@ "indexKey": "", "optional": false, "description": "

Specialisms/notes stay a separate stream (they have their own empty state).

\n", - "line": 41, + "line": 45, "rawdescription": "\nSpecialisms/notes stay a separate stream (they have their own empty state).", "modifierKind": [ 148 @@ -720,7 +1447,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 26, + "line": 28, "modifierKind": [ 123 ] @@ -734,23 +1461,24 @@ "indexKey": "", "optional": false, "description": "", - "line": 22, + "line": 24, "modifierKind": [ 123 ] }, { - "name": "brp", - "defaultValue": "inject(BrpAdapter)", + "name": "decisions", + "defaultValue": "computed>(() => map(this.view(), (v) => v.decisions))", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, - "description": "", - "line": 23, + "description": "

Server-computed decisions (e.g. herregistratie eligibility) — rendered, not recomputed.

\n", + "line": 42, + "rawdescription": "\nServer-computed decisions (e.g. herregistratie eligibility) — rendered, not recomputed.", "modifierKind": [ - 123 + 148 ] }, { @@ -762,7 +1490,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 46, + "line": 50, "modifierKind": [ 123 ] @@ -776,44 +1504,45 @@ "indexKey": "", "optional": false, "description": "

True while a herregistratie submission is in flight or just submitted.

\n", - "line": 48, + "line": 52, "rawdescription": "\nTrue while a herregistratie submission is in flight or just submitted.", "modifierKind": [ 148 ] }, - { - "name": "personRes", - "defaultValue": "this.brp.personResource()", - "deprecated": false, - "deprecationMessage": "", - "type": "unknown", - "indexKey": "", - "optional": false, - "description": "", - "line": 27, - "modifierKind": [ - 123 - ] - }, { "name": "profile", - "defaultValue": "computed>(() =>\n map2(\n fromResource(this.registrationRes),\n fromResource(this.personRes),\n // httpResource types value as T | undefined; in the Success branch it is\n // always present, so narrowing here is safe.\n (registration, person) => ({ registration: registration!, person: person! }),\n ),\n )", + "defaultValue": "computed>(() => map(this.view(), (v) => v.profile))", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, - "description": "

BIG-register + BRP folded into one state.

\n", - "line": 30, - "rawdescription": "\nBIG-register + BRP folded into one state.", + "description": "

Registration + person, from the single aggregated call.

\n", + "line": 39, + "rawdescription": "\nRegistration + person, from the single aggregated call.", "modifierKind": [ 148 ] }, { - "name": "registrationRes", - "defaultValue": "this.big.registrationResource()", + "name": "view", + "defaultValue": "computed>(() => {\n const rd = fromResource(this.viewRes);\n if (rd.tag !== 'Success') return rd;\n const parsed = parseDashboardView(rd.value);\n return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };\n })", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "

The aggregated view, validated at the trust boundary (DTO → domain).

\n", + "line": 31, + "rawdescription": "\nThe aggregated view, validated at the trust boundary (DTO → domain).", + "modifierKind": [ + 123 + ] + }, + { + "name": "viewAdapter", + "defaultValue": "inject(DashboardViewAdapter)", "deprecated": false, "deprecationMessage": "", "type": "unknown", @@ -824,6 +1553,20 @@ "modifierKind": [ 123 ] + }, + { + "name": "viewRes", + "defaultValue": "this.viewAdapter.dashboardViewResource()", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 27, + "modifierKind": [ + 123 + ] } ], "methods": [ @@ -833,7 +1576,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 50, + "line": 54, "deprecated": false, "deprecationMessage": "" }, @@ -843,7 +1586,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 53, + "line": 57, "deprecated": false, "deprecationMessage": "" }, @@ -853,22 +1596,22 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 57, + "line": 61, "deprecated": false, "deprecationMessage": "" } ], "deprecated": false, "deprecationMessage": "", - "description": "

The single source of truth for the logged-in professional's profile, shared\nacross pages (providedIn:'root' = one instance). It owns the httpResources\n(created here, in the required injection context) and exposes them as\nRemoteData signals.

\n

The headline trick: profile combines TWO independent services — the\nBIG-register and the BRP — into ONE RemoteData via map2. A page renders a\nsingle state (loading / error / loaded), never juggling three.

\n", - "rawdescription": "\n\nThe single source of truth for the logged-in professional's profile, shared\nacross pages (providedIn:'root' = one instance). It owns the httpResources\n(created here, in the required injection context) and exposes them as\nRemoteData signals.\n\nThe headline trick: `profile` combines TWO independent services — the\nBIG-register and the BRP — into ONE RemoteData via map2. A page renders a\nsingle state (loading / error / loaded), never juggling three.\n", - "sourceCode": "import { Injectable, computed, inject, signal } from '@angular/core';\nimport { RemoteData, fromResource, map2 } from '@shared/application/remote-data';\nimport { Aantekening } from '../domain/registration';\nimport { BigProfile } from '../domain/big-profile';\nimport { BigRegisterAdapter } from '../infrastructure/big-register.adapter';\nimport { BrpAdapter } from '../infrastructure/brp.adapter';\n\ntype Err = Error | undefined;\n\n/**\n * The single source of truth for the logged-in professional's profile, shared\n * across pages (providedIn:'root' = one instance). It owns the httpResources\n * (created here, in the required injection context) and exposes them as\n * RemoteData signals.\n *\n * The headline trick: `profile` combines TWO independent services — the\n * BIG-register and the BRP — into ONE RemoteData via map2. A page renders a\n * single state (loading / error / loaded), never juggling three.\n */\n@Injectable({ providedIn: 'root' })\nexport class BigProfileStore {\n private big = inject(BigRegisterAdapter);\n private brp = inject(BrpAdapter);\n\n private registrationRes = this.big.registrationResource();\n private aantekeningenRes = this.big.aantekeningenResource();\n private personRes = this.brp.personResource();\n\n /** BIG-register + BRP folded into one state. */\n readonly profile = computed>(() =>\n map2(\n fromResource(this.registrationRes),\n fromResource(this.personRes),\n // httpResource types value as T | undefined; in the Success branch it is\n // always present, so narrowing here is safe.\n (registration, person) => ({ registration: registration!, person: person! }),\n ),\n );\n\n /** Specialisms/notes stay a separate stream (they have their own empty state). */\n readonly aantekeningen = computed>(() =>\n fromResource(this.aantekeningenRes, (v) => v.length === 0),\n );\n\n // --- Optimistic herregistratie state, shared with the dashboard -----------\n private pending = signal(false);\n /** True while a herregistratie submission is in flight or just submitted. */\n readonly pendingHerregistratie = this.pending.asReadonly();\n\n beginHerregistratie() {\n this.pending.set(true); // optimistic: show it immediately on the dashboard\n }\n confirmHerregistratie() {\n this.pending.set(false);\n this.registrationRes.reload(); // invalidate: re-fetch the now-updated registration\n }\n rollbackHerregistratie() {\n this.pending.set(false); // submission failed — undo the optimistic flag\n }\n}\n", + "description": "

The single source of truth for the logged-in professional's profile, shared\nacross pages (providedIn:'root' = one instance). It owns the httpResources\n(created here, in the required injection context) and exposes them as\nRemoteData signals.

\n

The dashboard data now comes from ONE screen-shaped ("BFF-lite") call that\nreturns registration + person + server-computed decisions. One request → one\nconsistent snapshot, instead of stitching three independently loading/erroring\nresources together client-side. See docs/architecture/0001-bff-lite-decision-dtos.md.

\n", + "rawdescription": "\n\nThe single source of truth for the logged-in professional's profile, shared\nacross pages (providedIn:'root' = one instance). It owns the httpResources\n(created here, in the required injection context) and exposes them as\nRemoteData signals.\n\nThe dashboard data now comes from ONE screen-shaped (\"BFF-lite\") call that\nreturns registration + person + server-computed `decisions`. One request → one\nconsistent snapshot, instead of stitching three independently loading/erroring\nresources together client-side. See docs/architecture/0001-bff-lite-decision-dtos.md.\n", + "sourceCode": "import { Injectable, computed, inject, signal } from '@angular/core';\nimport { RemoteData, fromResource, map } from '@shared/application/remote-data';\nimport { Aantekening } from '../domain/registration';\nimport { BigProfile } from '../domain/big-profile';\nimport { HerregistratieDecisions, DashboardView } from '../contracts/dashboard-view.dto';\nimport { BigRegisterAdapter } from '../infrastructure/big-register.adapter';\nimport { DashboardViewAdapter, parseDashboardView } from '../infrastructure/dashboard-view.adapter';\n\ntype Err = Error | undefined;\n\n/**\n * The single source of truth for the logged-in professional's profile, shared\n * across pages (providedIn:'root' = one instance). It owns the httpResources\n * (created here, in the required injection context) and exposes them as\n * RemoteData signals.\n *\n * The dashboard data now comes from ONE screen-shaped (\"BFF-lite\") call that\n * returns registration + person + server-computed `decisions`. One request → one\n * consistent snapshot, instead of stitching three independently loading/erroring\n * resources together client-side. See docs/architecture/0001-bff-lite-decision-dtos.md.\n */\n@Injectable({ providedIn: 'root' })\nexport class BigProfileStore {\n private big = inject(BigRegisterAdapter);\n private viewAdapter = inject(DashboardViewAdapter);\n\n private viewRes = this.viewAdapter.dashboardViewResource();\n private aantekeningenRes = this.big.aantekeningenResource();\n\n /** The aggregated view, validated at the trust boundary (DTO → domain). */\n private view = computed>(() => {\n const rd = fromResource(this.viewRes);\n if (rd.tag !== 'Success') return rd;\n const parsed = parseDashboardView(rd.value);\n return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };\n });\n\n /** Registration + person, from the single aggregated call. */\n readonly profile = computed>(() => map(this.view(), (v) => v.profile));\n\n /** Server-computed decisions (e.g. herregistratie eligibility) — rendered, not recomputed. */\n readonly decisions = computed>(() => map(this.view(), (v) => v.decisions));\n\n /** Specialisms/notes stay a separate stream (they have their own empty state). */\n readonly aantekeningen = computed>(() =>\n fromResource(this.aantekeningenRes, (v) => v.length === 0),\n );\n\n // --- Optimistic herregistratie state, shared with the dashboard -----------\n private pending = signal(false);\n /** True while a herregistratie submission is in flight or just submitted. */\n readonly pendingHerregistratie = this.pending.asReadonly();\n\n beginHerregistratie() {\n this.pending.set(true); // optimistic: show it immediately on the dashboard\n }\n confirmHerregistratie() {\n this.pending.set(false);\n this.viewRes.reload(); // invalidate: re-fetch the now-updated view (registration + decisions)\n }\n rollbackHerregistratie() {\n this.pending.set(false); // submission failed — undo the optimistic flag\n }\n}\n", "extends": [], "type": "injectable" }, { "name": "BigRegisterAdapter", - "id": "injectable-BigRegisterAdapter-b658d9915e8f09eed460d1f75d5dc229398186277cfa4d35fd143799842c80174c41daf2f30936090fda88e72f3bbfc3e9422cbfa7b1d1342eb004cc2d80e3ae", + "id": "injectable-BigRegisterAdapter-a97ebf3c62a06bc0fe10b80bf7c03978bd380fe2f177627c268298d8cb92c8ac99f4977a95602bc5b4038f0d32aef28a0075d314b56d6c3b1edeeb66327cf358", "file": "src/app/registratie/infrastructure/big-register.adapter.ts", "properties": [], "methods": [ @@ -878,51 +1621,66 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 17, - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "registrationResource", - "args": [], - "optional": false, - "returnType": "any", - "typeParameters": [], - "line": 13, + "line": 16, "deprecated": false, "deprecationMessage": "" } ], "deprecated": false, "deprecationMessage": "", - "description": "

Infrastructure adapter for the BIG-register source. Exposes signal-based\nresources (Angular's httpResource); each returns a Resource with\nstatus()/value()/error()/reload(). Call from an injection context\n(a field initializer in the store).

\n", - "rawdescription": "\n\nInfrastructure adapter for the BIG-register source. Exposes signal-based\nresources (Angular's httpResource); each returns a Resource with\nstatus()/value()/error()/reload(). Call from an injection context\n(a field initializer in the store).\n", - "sourceCode": "import { Injectable } from '@angular/core';\nimport { httpResource } from '@angular/common/http';\nimport { Registration, Aantekening } from '../domain/registration';\n\n/**\n * Infrastructure adapter for the BIG-register source. Exposes signal-based\n * resources (Angular's httpResource); each returns a Resource with\n * status()/value()/error()/reload(). Call from an injection context\n * (a field initializer in the store).\n */\n@Injectable({ providedIn: 'root' })\nexport class BigRegisterAdapter {\n registrationResource() {\n return httpResource(() => 'mock/registration.json');\n }\n\n aantekeningenResource() {\n return httpResource(() => 'mock/notes.json', { defaultValue: [] });\n }\n}\n", + "description": "

Infrastructure adapter for the BIG-register source. Exposes signal-based\nresources (Angular's httpResource); each returns a Resource with\nstatus()/value()/error()/reload(). Call from an injection context\n(a field initializer in the store).

\n

Note: registration + person are now served via the aggregated dashboard-view\nendpoint (see DashboardViewAdapter). Only the notes stream remains separate.

\n", + "rawdescription": "\n\nInfrastructure adapter for the BIG-register source. Exposes signal-based\nresources (Angular's httpResource); each returns a Resource with\nstatus()/value()/error()/reload(). Call from an injection context\n(a field initializer in the store).\n\nNote: registration + person are now served via the aggregated dashboard-view\nendpoint (see DashboardViewAdapter). Only the notes stream remains separate.\n", + "sourceCode": "import { Injectable } from '@angular/core';\nimport { httpResource } from '@angular/common/http';\nimport { Aantekening } from '../domain/registration';\n\n/**\n * Infrastructure adapter for the BIG-register source. Exposes signal-based\n * resources (Angular's httpResource); each returns a Resource with\n * status()/value()/error()/reload(). Call from an injection context\n * (a field initializer in the store).\n *\n * Note: registration + person are now served via the aggregated dashboard-view\n * endpoint (see DashboardViewAdapter). Only the notes stream remains separate.\n */\n@Injectable({ providedIn: 'root' })\nexport class BigRegisterAdapter {\n aantekeningenResource() {\n return httpResource(() => 'mock/notes.json', { defaultValue: [] });\n }\n}\n", "extends": [], "type": "injectable" }, { "name": "BrpAdapter", - "id": "injectable-BrpAdapter-66b7fdb8331dd6cebe24a4713e26a6b025c4c97b239a8de8a81f9b72167dc376459e44aca001b24a7c44bdba6b97fa82d248762bbd1fca3a476993f709834b89", + "id": "injectable-BrpAdapter-a344bff4ee9ecd8353a0f1cac582f6ade0f15a250cd704bceb4f884101b9063dcba26b6db8242dbcd28fc6a7b8c259b0ed02732215fe9fec1dbd5dbba6b6c336", "file": "src/app/registratie/infrastructure/brp.adapter.ts", "properties": [], "methods": [ { - "name": "personResource", + "name": "adresResource", "args": [], "optional": false, "returnType": "any", "typeParameters": [], - "line": 8, + "line": 15, "deprecated": false, "deprecationMessage": "" } ], "deprecated": false, "deprecationMessage": "", - "description": "

Infrastructure adapter for the BRP (Basisregistratie Personen) source.

\n", - "rawdescription": "\nInfrastructure adapter for the BRP (Basisregistratie Personen) source.", - "sourceCode": "import { Injectable } from '@angular/core';\nimport { httpResource } from '@angular/common/http';\nimport { Person } from '../domain/person';\n\n/** Infrastructure adapter for the BRP (Basisregistratie Personen) source. */\n@Injectable({ providedIn: 'root' })\nexport class BrpAdapter {\n personResource() {\n return httpResource(() => 'mock/brp.json');\n }\n}\n", + "description": "

Infrastructure adapter for the BRP address lookup, reached only through our own\n("BFF-lite") endpoint — the anti-corruption boundary. In this POC the endpoint\nis a static mock; pointing at a real backend touches only this file + the DTO.

\n", + "rawdescription": "\n\nInfrastructure adapter for the BRP address lookup, reached only through our own\n(\"BFF-lite\") endpoint — the anti-corruption boundary. In this POC the endpoint\nis a static mock; pointing at a real backend touches only this file + the DTO.\n", + "sourceCode": "import { Injectable } from '@angular/core';\nimport { httpResource } from '@angular/common/http';\nimport { Result, ok, err } from '@shared/kernel/fp';\nimport { BrpAddressDto } from '@registratie/contracts/brp-address.dto';\n\n/**\n * Infrastructure adapter for the BRP address lookup, reached only through our own\n * (\"BFF-lite\") endpoint — the anti-corruption boundary. In this POC the endpoint\n * is a static mock; pointing at a real backend touches only this file + the DTO.\n */\n@Injectable({ providedIn: 'root' })\nexport class BrpAdapter {\n // Typed as the DTO for ergonomics, but the value is untrusted JSON until\n // parseBrpAddress validates it.\n adresResource() {\n return httpResource(() => 'mock/brp-address.json');\n }\n}\n\n/** Trust-boundary parse: validate the untrusted response shape. \"Geen adres\" is a\n valid outcome (gevonden: false), not a malformed response. ponytail: hand-written;\n reach for a schema lib once the contract count grows. */\nexport function parseBrpAddress(json: unknown): Result {\n if (typeof json !== 'object' || json === null) return err('brp-address: not an object');\n const dto = json as Partial;\n if (typeof dto.gevonden !== 'boolean') return err('brp-address: missing/invalid gevonden');\n if (dto.gevonden) {\n const a = dto.adres;\n if (!a || typeof a.straat !== 'string' || typeof a.postcode !== 'string' || typeof a.woonplaats !== 'string') {\n return err('brp-address: missing/invalid adres');\n }\n }\n return ok({ gevonden: dto.gevonden, adres: dto.adres });\n}\n", + "extends": [], + "type": "injectable" + }, + { + "name": "DashboardViewAdapter", + "id": "injectable-DashboardViewAdapter-f9f802caa4aa1f8e0892c550edb337b98c92f6fc1b268ed0f6c5f4e28416042eab089262dcfac88d0d16f9bbbec112731a09f9329be75c7030133e8827a8eb47", + "file": "src/app/registratie/infrastructure/dashboard-view.adapter.ts", + "properties": [], + "methods": [ + { + "name": "dashboardViewResource", + "args": [], + "optional": false, + "returnType": "any", + "typeParameters": [], + "line": 16, + "deprecated": false, + "deprecationMessage": "" + } + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

Infrastructure adapter for the screen-shaped ("BFF-lite") dashboard endpoint.\nONE call returns registration + person + server-computed decisions.\n(In this POC the endpoint is a static mock; the decisions are precomputed to\nstand in for what the backend would compute.)

\n", + "rawdescription": "\n\nInfrastructure adapter for the screen-shaped (\"BFF-lite\") dashboard endpoint.\nONE call returns registration + person + server-computed decisions.\n(In this POC the endpoint is a static mock; the decisions are precomputed to\nstand in for what the backend would compute.)\n", + "sourceCode": "import { Injectable } from '@angular/core';\nimport { httpResource } from '@angular/common/http';\nimport { Result, ok, err } from '@shared/kernel/fp';\nimport { DashboardViewDto, DashboardView } from '@registratie/contracts/dashboard-view.dto';\n\n/**\n * Infrastructure adapter for the screen-shaped (\"BFF-lite\") dashboard endpoint.\n * ONE call returns registration + person + server-computed decisions.\n * (In this POC the endpoint is a static mock; the decisions are precomputed to\n * stand in for what the backend would compute.)\n */\n@Injectable({ providedIn: 'root' })\nexport class DashboardViewAdapter {\n // Typed as the DTO for ergonomics, but the value is still untrusted JSON —\n // parseDashboardView validates it at the boundary before the app uses it.\n dashboardViewResource() {\n return httpResource(() => 'mock/dashboard-view.json');\n }\n}\n\n/**\n * Trust-boundary parse: validate the untrusted response shape and map the DTO\n * onto our own domain model. Hand-written on purpose — no Zod for a single\n * contract. ponytail: reach for a schema lib once the contract count grows.\n */\nexport function parseDashboardView(json: unknown): Result {\n if (typeof json !== 'object' || json === null) return err('dashboard-view: not an object');\n const dto = json as Partial;\n\n const reg = dto.registration;\n if (!reg || typeof reg.bigNummer !== 'string' || !reg.status || typeof reg.status.tag !== 'string') {\n return err('dashboard-view: missing/invalid registration');\n }\n const person = dto.person;\n if (!person || !person.adres || typeof person.adres.postcode !== 'string') {\n return err('dashboard-view: missing/invalid person');\n }\n const d = dto.decisions;\n if (!d || typeof d.eligibleForHerregistratie !== 'boolean') {\n return err('dashboard-view: missing/invalid decisions');\n }\n\n return ok({\n profile: { registration: reg, person },\n decisions: { eligibleForHerregistratie: d.eligibleForHerregistratie, herregistratieReason: d.herregistratieReason },\n });\n}\n", "extends": [], "type": "injectable" }, @@ -976,21 +1734,46 @@ "extends": [], "type": "injectable" }, + { + "name": "DuoAdapter", + "id": "injectable-DuoAdapter-616feea0d64b7da051ee732696d22103481dabb2238ec6ec39b7fdba3b8d67af05b0d4eaf45c3d1c0bddfbadd70cb5b603a56d5a231bd35be56366357866cdb3", + "file": "src/app/registratie/infrastructure/duo.adapter.ts", + "properties": [], + "methods": [ + { + "name": "diplomasResource", + "args": [], + "optional": false, + "returnType": "any", + "typeParameters": [], + "line": 17, + "deprecated": false, + "deprecationMessage": "" + } + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

Infrastructure adapter for the DUO diploma lookup, reached only through our own\n("BFF-lite") endpoint — the anti-corruption boundary. The response carries the\nuser's diplomas (each with its server-computed beroep + policy questions) and\nthe manual-entry fallback policy. The frontend renders; it does not derive. In\nthis POC the endpoint is a static mock.

\n", + "rawdescription": "\n\nInfrastructure adapter for the DUO diploma lookup, reached only through our own\n(\"BFF-lite\") endpoint — the anti-corruption boundary. The response carries the\nuser's diplomas (each with its server-computed beroep + policy questions) and\nthe manual-entry fallback policy. The frontend renders; it does not derive. In\nthis POC the endpoint is a static mock.\n", + "sourceCode": "import { Injectable } from '@angular/core';\nimport { httpResource } from '@angular/common/http';\nimport { Result, ok, err } from '@shared/kernel/fp';\nimport { DuoLookupDto, DuoDiplomaDto, PolicyQuestionDto, ManualDiplomaPolicyDto } from '@registratie/contracts/duo-diplomas.dto';\n\nconst EMPTY: DuoLookupDto = { diplomas: [], handmatig: { beroepen: [], policyQuestions: [] } };\n\n/**\n * Infrastructure adapter for the DUO diploma lookup, reached only through our own\n * (\"BFF-lite\") endpoint — the anti-corruption boundary. The response carries the\n * user's diplomas (each with its server-computed beroep + policy questions) and\n * the manual-entry fallback policy. The frontend renders; it does not derive. In\n * this POC the endpoint is a static mock.\n */\n@Injectable({ providedIn: 'root' })\nexport class DuoAdapter {\n diplomasResource() {\n return httpResource(() => 'mock/duo-diplomas.json', { defaultValue: EMPTY });\n }\n}\n\nfunction parseQuestions(json: unknown): PolicyQuestionDto[] | null {\n if (!Array.isArray(json)) return null;\n const out: PolicyQuestionDto[] = [];\n for (const q of json) {\n if (typeof q !== 'object' || q === null) return null;\n const p = q as Partial;\n if (typeof p.id !== 'string' || typeof p.vraag !== 'string' || (p.type !== 'ja-nee' && p.type !== 'tekst')) return null;\n out.push({ id: p.id, vraag: p.vraag, type: p.type });\n }\n return out;\n}\n\n/** Trust-boundary parse: validate the untrusted response shape (diplomas, each\n with derived beroep + policy questions, plus the manual-entry fallback). */\nexport function parseDuoLookup(json: unknown): Result {\n if (typeof json !== 'object' || json === null) return err('duo-lookup: not an object');\n const dto = json as Partial;\n if (!Array.isArray(dto.diplomas)) return err('duo-lookup: missing diplomas');\n\n const diplomas: DuoDiplomaDto[] = [];\n for (const item of dto.diplomas) {\n if (typeof item !== 'object' || item === null) return err('duo-lookup: invalid diploma');\n const d = item as Partial;\n const vragen = parseQuestions(d.policyQuestions);\n if (typeof d.id !== 'string' || typeof d.naam !== 'string' || typeof d.beroep !== 'string' || vragen === null) {\n return err('duo-lookup: missing/invalid diploma fields');\n }\n diplomas.push({ id: d.id, naam: d.naam, instelling: d.instelling ?? '', jaar: typeof d.jaar === 'number' ? d.jaar : 0, beroep: d.beroep, policyQuestions: vragen });\n }\n\n const hm = dto.handmatig;\n const hmVragen = hm ? parseQuestions(hm.policyQuestions) : null;\n if (!hm || !Array.isArray(hm.beroepen) || hmVragen === null) {\n return err('duo-lookup: missing/invalid handmatig fallback');\n }\n const handmatig: ManualDiplomaPolicyDto = { beroepen: hm.beroepen, policyQuestions: hmVragen };\n\n return ok({ diplomas, handmatig });\n}\n", + "extends": [], + "type": "injectable" + }, { "name": "SessionStore", - "id": "injectable-SessionStore-73a990a2a5a1c0f7177443b4e29c7af3da44fcbabcc4e6637486764225143283202ac5da0ee355ed30f829eab6f88a25f05dac7fce22d60cbf2dda7e0956b949", + "id": "injectable-SessionStore-9e6ea772dc35cac2224969c550dc4edbeabbc2782278eaab988c670b860fd6232fe010ab80c4cfe06d81e3acf2ed437c648bed3e6e60d5c18f2a8c9ba3a83201", "file": "src/app/auth/application/session.store.ts", "properties": [ { "name": "_session", - "defaultValue": "signal(null)", + "defaultValue": "signal(restore())", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "", - "line": 15, + "line": 30, "modifierKind": [ 123 ] @@ -1004,7 +1787,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 14, + "line": 29, "modifierKind": [ 123 ] @@ -1018,7 +1801,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 18, + "line": 33, "modifierKind": [ 148 ] @@ -1032,7 +1815,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 17, + "line": 32, "modifierKind": [ 148 ] @@ -1054,7 +1837,7 @@ "optional": false, "returnType": "Promise>", "typeParameters": [], - "line": 21, + "line": 44, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nEffectful command: authenticate, then store the session on success.", @@ -1082,16 +1865,24 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 27, + "line": 50, "deprecated": false, "deprecationMessage": "" } ], "deprecated": false, "deprecationMessage": "", - "description": "

Holds the current session for the whole app. Because it is providedIn:'root'\nthere is exactly one instance — every component that injects it sees the same\nsession signal, so logging in is instantly visible everywhere (the guard, the\nheader, etc.). ponytail: in-memory only; a refresh logs you out.

\n", - "rawdescription": "\n\nHolds the current session for the whole app. Because it is providedIn:'root'\nthere is exactly one instance — every component that injects it sees the same\nsession signal, so logging in is instantly visible everywhere (the guard, the\nheader, etc.). ponytail: in-memory only; a refresh logs you out.\n", - "sourceCode": "import { Injectable, computed, inject, signal } from '@angular/core';\nimport { Result } from '@shared/kernel/fp';\nimport { Session } from '../domain/session';\nimport { DigidAdapter } from '../infrastructure/digid.adapter';\n\n/**\n * Holds the current session for the whole app. Because it is providedIn:'root'\n * there is exactly one instance — every component that injects it sees the same\n * session signal, so logging in is instantly visible everywhere (the guard, the\n * header, etc.). ponytail: in-memory only; a refresh logs you out.\n */\n@Injectable({ providedIn: 'root' })\nexport class SessionStore {\n private digid = inject(DigidAdapter);\n private _session = signal(null);\n\n readonly session = this._session.asReadonly();\n readonly isAuthenticated = computed(() => this._session() !== null);\n\n /** Effectful command: authenticate, then store the session on success. */\n async login(bsn: string): Promise> {\n const r = await this.digid.authenticate(bsn);\n if (r.ok) this._session.set(r.value);\n return r;\n }\n\n logout() {\n this._session.set(null);\n }\n}\n", + "description": "

Holds the current session for the whole app. Because it is providedIn:'root'\nthere is exactly one instance — every component that injects it sees the same\nsession signal, so logging in is instantly visible everywhere (the guard, the\nheader, etc.). The session is mirrored to sessionStorage so a refresh or a\ndeep-link to a protected route keeps you logged in; it clears when the tab\ncloses. ponytail: sessionStorage, not localStorage — no cross-tab sync, which\nmatches a single-session portal.

\n", + "rawdescription": "\n\nHolds the current session for the whole app. Because it is providedIn:'root'\nthere is exactly one instance — every component that injects it sees the same\nsession signal, so logging in is instantly visible everywhere (the guard, the\nheader, etc.). The session is mirrored to sessionStorage so a refresh or a\ndeep-link to a protected route keeps you logged in; it clears when the tab\ncloses. ponytail: sessionStorage, not localStorage — no cross-tab sync, which\nmatches a single-session portal.\n", + "sourceCode": "import { Injectable, computed, effect, inject, signal } from '@angular/core';\nimport { Result } from '@shared/kernel/fp';\nimport { Session } from '../domain/session';\nimport { DigidAdapter } from '../infrastructure/digid.adapter';\n\nconst STORAGE_KEY = 'session-v1';\n\n/** Restore a persisted session (best-effort; corrupt entry → logged out). */\nfunction restore(): Session | null {\n try {\n const raw = sessionStorage.getItem(STORAGE_KEY);\n return raw ? (JSON.parse(raw) as Session) : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Holds the current session for the whole app. Because it is providedIn:'root'\n * there is exactly one instance — every component that injects it sees the same\n * session signal, so logging in is instantly visible everywhere (the guard, the\n * header, etc.). The session is mirrored to sessionStorage so a refresh or a\n * deep-link to a protected route keeps you logged in; it clears when the tab\n * closes. ponytail: sessionStorage, not localStorage — no cross-tab sync, which\n * matches a single-session portal.\n */\n@Injectable({ providedIn: 'root' })\nexport class SessionStore {\n private digid = inject(DigidAdapter);\n private _session = signal(restore());\n\n readonly session = this._session.asReadonly();\n readonly isAuthenticated = computed(() => this._session() !== null);\n\n constructor() {\n effect(() => {\n const s = this._session();\n if (s) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));\n else sessionStorage.removeItem(STORAGE_KEY);\n });\n }\n\n /** Effectful command: authenticate, then store the session on success. */\n async login(bsn: string): Promise> {\n const r = await this.digid.authenticate(bsn);\n if (r.ok) this._session.set(r.value);\n return r;\n }\n\n logout() {\n this._session.set(null);\n }\n}\n", + "constructorObj": { + "name": "constructor", + "description": "", + "deprecated": false, + "deprecationMessage": "", + "args": [], + "line": 33 + }, "extends": [], "type": "injectable" } @@ -1102,12 +1893,12 @@ "directives": [ { "name": "AsyncEmptyDirective", - "id": "directive-AsyncEmptyDirective-b3cfb3c4a98444c42ba42c11d432aca9cf324dba35b23ccb8af26360530e37b4edcc64b110afd78fb4d7e4eef1b24c72be872007aaa92127f22e76e1ae7c0964", + "id": "directive-AsyncEmptyDirective-0d66d917cda6e95349b21544c6fed09ee792cd86cf353cab9a71395a29342dfc9115ba1a9c915bb1851c09330826035b08479c514c9e47f9a464e03c3f71675c", "file": "src/app/shared/ui/async/async.component.ts", "type": "directive", "description": "", "rawdescription": "\n", - "sourceCode": "import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport type { Resource } from '@angular/core';\nimport { SpinnerComponent } from '@shared/ui/spinner/spinner.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';\n\n/* Slot markers. Put on children of . */\n@Directive({ selector: '[appAsyncLoaded]' })\nexport class AsyncLoadedDirective {\n constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}\n}\n@Directive({ selector: '[appAsyncLoading]' })\nexport class AsyncLoadingDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncEmpty]' })\nexport class AsyncEmptyDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncError]' })\nexport class AsyncErrorDirective {\n constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}\n}\n\n/**\n * Renders exactly ONE of loading / empty / error / loaded for a signal-based\n * resource (e.g. httpResource). Built on a RemoteData tagged union (see\n * core/remote-data.ts), so the states are mutually exclusive by construction —\n * the UI can never show two at once (\"impossible states\"). Unprovided slots\n * fall back to sensible defaults.\n */\n@Component({\n selector: 'app-async',\n imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],\n template: `\n @switch (rd().tag) {\n @case ('Loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('Failure') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('Empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('Success') {\n \n }\n }\n `,\n})\nexport class AsyncComponent {\n // Two ways to feed this component:\n // [resource] — a raw httpResource (the common case), or\n // [data] — an already-combined RemoteData (e.g. from a store via map2).\n resource = input>();\n data = input>();\n isEmpty = input<(v: T) => boolean>(() => false);\n\n loadedTpl = contentChild.required(AsyncLoadedDirective);\n loadingTpl = contentChild(AsyncLoadingDirective);\n emptyTpl = contentChild(AsyncEmptyDirective);\n errorTpl = contentChild(AsyncErrorDirective);\n\n // Single source of truth: the supplied RemoteData, or the resource projected into one.\n protected rd = computed>(() => {\n const data = this.data();\n if (data) return data;\n const r = this.resource();\n return r ? fromResource(r, this.isEmpty()) : { tag: 'Loading' };\n });\n\n // value/error are pulled out via the exhaustive fold — only Success carries a\n // value, only Failure carries an error, so these can't lie.\n protected value = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: () => undefined, success: (v) => v }),\n );\n protected error = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: (e) => e, success: () => undefined }),\n );\n\n retry = () => {\n const r = this.resource();\n if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {\n (r as { reload: () => void }).reload();\n }\n };\n}\n\n/** Convenience: import this array to get the wrapper + all slot directives. */\nexport const ASYNC = [\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const;\n", + "sourceCode": "import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport type { Resource } from '@angular/core';\nimport { SpinnerComponent } from '@shared/ui/spinner/spinner.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';\n\n/* Slot markers. Put on children of . */\n@Directive({ selector: '[appAsyncLoaded]' })\nexport class AsyncLoadedDirective {\n constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}\n}\n@Directive({ selector: '[appAsyncLoading]' })\nexport class AsyncLoadingDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncEmpty]' })\nexport class AsyncEmptyDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncError]' })\nexport class AsyncErrorDirective {\n constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}\n}\n\n/**\n * Renders exactly ONE of loading / empty / error / loaded for a signal-based\n * resource (e.g. httpResource). Built on a RemoteData tagged union (see\n * core/remote-data.ts), so the states are mutually exclusive by construction —\n * the UI can never show two at once (\"impossible states\"). Unprovided slots\n * fall back to sensible defaults.\n */\n@Component({\n selector: 'app-async',\n imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],\n template: `\n
\n @switch (rd().tag) {\n @case ('Loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('Failure') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('Empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('Success') {\n \n }\n }\n
\n `,\n})\nexport class AsyncComponent {\n // Two ways to feed this component:\n // [resource] — a raw httpResource (the common case), or\n // [data] — an already-combined RemoteData (e.g. from a store via map2).\n resource = input>();\n data = input>();\n isEmpty = input<(v: T) => boolean>(() => false);\n\n loadedTpl = contentChild.required(AsyncLoadedDirective);\n loadingTpl = contentChild(AsyncLoadingDirective);\n emptyTpl = contentChild(AsyncEmptyDirective);\n errorTpl = contentChild(AsyncErrorDirective);\n\n // Single source of truth: the supplied RemoteData, or the resource projected into one.\n protected rd = computed>(() => {\n const data = this.data();\n if (data) return data;\n const r = this.resource();\n return r ? fromResource(r, this.isEmpty()) : { tag: 'Loading' };\n });\n\n // value/error are pulled out via the exhaustive fold — only Success carries a\n // value, only Failure carries an error, so these can't lie.\n protected value = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: () => undefined, success: (v) => v }),\n );\n protected error = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: (e) => e, success: () => undefined }),\n );\n\n retry = () => {\n const r = this.resource();\n if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {\n (r as { reload: () => void }).reload();\n }\n };\n}\n\n/** Convenience: import this array to get the wrapper + all slot directives. */\nexport const ASYNC = [\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const;\n", "selector": "[appAsyncEmpty]", "providers": [], "hostDirectives": [], @@ -1168,12 +1959,12 @@ }, { "name": "AsyncErrorDirective", - "id": "directive-AsyncErrorDirective-b3cfb3c4a98444c42ba42c11d432aca9cf324dba35b23ccb8af26360530e37b4edcc64b110afd78fb4d7e4eef1b24c72be872007aaa92127f22e76e1ae7c0964", + "id": "directive-AsyncErrorDirective-0d66d917cda6e95349b21544c6fed09ee792cd86cf353cab9a71395a29342dfc9115ba1a9c915bb1851c09330826035b08479c514c9e47f9a464e03c3f71675c", "file": "src/app/shared/ui/async/async.component.ts", "type": "directive", "description": "", "rawdescription": "\n", - "sourceCode": "import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport type { Resource } from '@angular/core';\nimport { SpinnerComponent } from '@shared/ui/spinner/spinner.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';\n\n/* Slot markers. Put on children of . */\n@Directive({ selector: '[appAsyncLoaded]' })\nexport class AsyncLoadedDirective {\n constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}\n}\n@Directive({ selector: '[appAsyncLoading]' })\nexport class AsyncLoadingDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncEmpty]' })\nexport class AsyncEmptyDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncError]' })\nexport class AsyncErrorDirective {\n constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}\n}\n\n/**\n * Renders exactly ONE of loading / empty / error / loaded for a signal-based\n * resource (e.g. httpResource). Built on a RemoteData tagged union (see\n * core/remote-data.ts), so the states are mutually exclusive by construction —\n * the UI can never show two at once (\"impossible states\"). Unprovided slots\n * fall back to sensible defaults.\n */\n@Component({\n selector: 'app-async',\n imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],\n template: `\n @switch (rd().tag) {\n @case ('Loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('Failure') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('Empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('Success') {\n \n }\n }\n `,\n})\nexport class AsyncComponent {\n // Two ways to feed this component:\n // [resource] — a raw httpResource (the common case), or\n // [data] — an already-combined RemoteData (e.g. from a store via map2).\n resource = input>();\n data = input>();\n isEmpty = input<(v: T) => boolean>(() => false);\n\n loadedTpl = contentChild.required(AsyncLoadedDirective);\n loadingTpl = contentChild(AsyncLoadingDirective);\n emptyTpl = contentChild(AsyncEmptyDirective);\n errorTpl = contentChild(AsyncErrorDirective);\n\n // Single source of truth: the supplied RemoteData, or the resource projected into one.\n protected rd = computed>(() => {\n const data = this.data();\n if (data) return data;\n const r = this.resource();\n return r ? fromResource(r, this.isEmpty()) : { tag: 'Loading' };\n });\n\n // value/error are pulled out via the exhaustive fold — only Success carries a\n // value, only Failure carries an error, so these can't lie.\n protected value = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: () => undefined, success: (v) => v }),\n );\n protected error = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: (e) => e, success: () => undefined }),\n );\n\n retry = () => {\n const r = this.resource();\n if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {\n (r as { reload: () => void }).reload();\n }\n };\n}\n\n/** Convenience: import this array to get the wrapper + all slot directives. */\nexport const ASYNC = [\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const;\n", + "sourceCode": "import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport type { Resource } from '@angular/core';\nimport { SpinnerComponent } from '@shared/ui/spinner/spinner.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';\n\n/* Slot markers. Put on children of . */\n@Directive({ selector: '[appAsyncLoaded]' })\nexport class AsyncLoadedDirective {\n constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}\n}\n@Directive({ selector: '[appAsyncLoading]' })\nexport class AsyncLoadingDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncEmpty]' })\nexport class AsyncEmptyDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncError]' })\nexport class AsyncErrorDirective {\n constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}\n}\n\n/**\n * Renders exactly ONE of loading / empty / error / loaded for a signal-based\n * resource (e.g. httpResource). Built on a RemoteData tagged union (see\n * core/remote-data.ts), so the states are mutually exclusive by construction —\n * the UI can never show two at once (\"impossible states\"). Unprovided slots\n * fall back to sensible defaults.\n */\n@Component({\n selector: 'app-async',\n imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],\n template: `\n
\n @switch (rd().tag) {\n @case ('Loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('Failure') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('Empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('Success') {\n \n }\n }\n
\n `,\n})\nexport class AsyncComponent {\n // Two ways to feed this component:\n // [resource] — a raw httpResource (the common case), or\n // [data] — an already-combined RemoteData (e.g. from a store via map2).\n resource = input>();\n data = input>();\n isEmpty = input<(v: T) => boolean>(() => false);\n\n loadedTpl = contentChild.required(AsyncLoadedDirective);\n loadingTpl = contentChild(AsyncLoadingDirective);\n emptyTpl = contentChild(AsyncEmptyDirective);\n errorTpl = contentChild(AsyncErrorDirective);\n\n // Single source of truth: the supplied RemoteData, or the resource projected into one.\n protected rd = computed>(() => {\n const data = this.data();\n if (data) return data;\n const r = this.resource();\n return r ? fromResource(r, this.isEmpty()) : { tag: 'Loading' };\n });\n\n // value/error are pulled out via the exhaustive fold — only Success carries a\n // value, only Failure carries an error, so these can't lie.\n protected value = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: () => undefined, success: (v) => v }),\n );\n protected error = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: (e) => e, success: () => undefined }),\n );\n\n retry = () => {\n const r = this.resource();\n if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {\n (r as { reload: () => void }).reload();\n }\n };\n}\n\n/** Convenience: import this array to get the wrapper + all slot directives. */\nexport const ASYNC = [\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const;\n", "selector": "[appAsyncError]", "providers": [], "hostDirectives": [], @@ -1234,12 +2025,12 @@ }, { "name": "AsyncLoadedDirective", - "id": "directive-AsyncLoadedDirective-b3cfb3c4a98444c42ba42c11d432aca9cf324dba35b23ccb8af26360530e37b4edcc64b110afd78fb4d7e4eef1b24c72be872007aaa92127f22e76e1ae7c0964", + "id": "directive-AsyncLoadedDirective-0d66d917cda6e95349b21544c6fed09ee792cd86cf353cab9a71395a29342dfc9115ba1a9c915bb1851c09330826035b08479c514c9e47f9a464e03c3f71675c", "file": "src/app/shared/ui/async/async.component.ts", "type": "directive", "description": "", "rawdescription": "\n", - "sourceCode": "import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport type { Resource } from '@angular/core';\nimport { SpinnerComponent } from '@shared/ui/spinner/spinner.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';\n\n/* Slot markers. Put on children of . */\n@Directive({ selector: '[appAsyncLoaded]' })\nexport class AsyncLoadedDirective {\n constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}\n}\n@Directive({ selector: '[appAsyncLoading]' })\nexport class AsyncLoadingDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncEmpty]' })\nexport class AsyncEmptyDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncError]' })\nexport class AsyncErrorDirective {\n constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}\n}\n\n/**\n * Renders exactly ONE of loading / empty / error / loaded for a signal-based\n * resource (e.g. httpResource). Built on a RemoteData tagged union (see\n * core/remote-data.ts), so the states are mutually exclusive by construction —\n * the UI can never show two at once (\"impossible states\"). Unprovided slots\n * fall back to sensible defaults.\n */\n@Component({\n selector: 'app-async',\n imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],\n template: `\n @switch (rd().tag) {\n @case ('Loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('Failure') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('Empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('Success') {\n \n }\n }\n `,\n})\nexport class AsyncComponent {\n // Two ways to feed this component:\n // [resource] — a raw httpResource (the common case), or\n // [data] — an already-combined RemoteData (e.g. from a store via map2).\n resource = input>();\n data = input>();\n isEmpty = input<(v: T) => boolean>(() => false);\n\n loadedTpl = contentChild.required(AsyncLoadedDirective);\n loadingTpl = contentChild(AsyncLoadingDirective);\n emptyTpl = contentChild(AsyncEmptyDirective);\n errorTpl = contentChild(AsyncErrorDirective);\n\n // Single source of truth: the supplied RemoteData, or the resource projected into one.\n protected rd = computed>(() => {\n const data = this.data();\n if (data) return data;\n const r = this.resource();\n return r ? fromResource(r, this.isEmpty()) : { tag: 'Loading' };\n });\n\n // value/error are pulled out via the exhaustive fold — only Success carries a\n // value, only Failure carries an error, so these can't lie.\n protected value = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: () => undefined, success: (v) => v }),\n );\n protected error = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: (e) => e, success: () => undefined }),\n );\n\n retry = () => {\n const r = this.resource();\n if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {\n (r as { reload: () => void }).reload();\n }\n };\n}\n\n/** Convenience: import this array to get the wrapper + all slot directives. */\nexport const ASYNC = [\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const;\n", + "sourceCode": "import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport type { Resource } from '@angular/core';\nimport { SpinnerComponent } from '@shared/ui/spinner/spinner.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';\n\n/* Slot markers. Put on children of . */\n@Directive({ selector: '[appAsyncLoaded]' })\nexport class AsyncLoadedDirective {\n constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}\n}\n@Directive({ selector: '[appAsyncLoading]' })\nexport class AsyncLoadingDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncEmpty]' })\nexport class AsyncEmptyDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncError]' })\nexport class AsyncErrorDirective {\n constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}\n}\n\n/**\n * Renders exactly ONE of loading / empty / error / loaded for a signal-based\n * resource (e.g. httpResource). Built on a RemoteData tagged union (see\n * core/remote-data.ts), so the states are mutually exclusive by construction —\n * the UI can never show two at once (\"impossible states\"). Unprovided slots\n * fall back to sensible defaults.\n */\n@Component({\n selector: 'app-async',\n imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],\n template: `\n
\n @switch (rd().tag) {\n @case ('Loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('Failure') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('Empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('Success') {\n \n }\n }\n
\n `,\n})\nexport class AsyncComponent {\n // Two ways to feed this component:\n // [resource] — a raw httpResource (the common case), or\n // [data] — an already-combined RemoteData (e.g. from a store via map2).\n resource = input>();\n data = input>();\n isEmpty = input<(v: T) => boolean>(() => false);\n\n loadedTpl = contentChild.required(AsyncLoadedDirective);\n loadingTpl = contentChild(AsyncLoadingDirective);\n emptyTpl = contentChild(AsyncEmptyDirective);\n errorTpl = contentChild(AsyncErrorDirective);\n\n // Single source of truth: the supplied RemoteData, or the resource projected into one.\n protected rd = computed>(() => {\n const data = this.data();\n if (data) return data;\n const r = this.resource();\n return r ? fromResource(r, this.isEmpty()) : { tag: 'Loading' };\n });\n\n // value/error are pulled out via the exhaustive fold — only Success carries a\n // value, only Failure carries an error, so these can't lie.\n protected value = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: () => undefined, success: (v) => v }),\n );\n protected error = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: (e) => e, success: () => undefined }),\n );\n\n retry = () => {\n const r = this.resource();\n if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {\n (r as { reload: () => void }).reload();\n }\n };\n}\n\n/** Convenience: import this array to get the wrapper + all slot directives. */\nexport const ASYNC = [\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const;\n", "selector": "[appAsyncLoaded]", "providers": [], "hostDirectives": [], @@ -1300,12 +2091,12 @@ }, { "name": "AsyncLoadingDirective", - "id": "directive-AsyncLoadingDirective-b3cfb3c4a98444c42ba42c11d432aca9cf324dba35b23ccb8af26360530e37b4edcc64b110afd78fb4d7e4eef1b24c72be872007aaa92127f22e76e1ae7c0964", + "id": "directive-AsyncLoadingDirective-0d66d917cda6e95349b21544c6fed09ee792cd86cf353cab9a71395a29342dfc9115ba1a9c915bb1851c09330826035b08479c514c9e47f9a464e03c3f71675c", "file": "src/app/shared/ui/async/async.component.ts", "type": "directive", "description": "", "rawdescription": "\n", - "sourceCode": "import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport type { Resource } from '@angular/core';\nimport { SpinnerComponent } from '@shared/ui/spinner/spinner.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';\n\n/* Slot markers. Put on children of . */\n@Directive({ selector: '[appAsyncLoaded]' })\nexport class AsyncLoadedDirective {\n constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}\n}\n@Directive({ selector: '[appAsyncLoading]' })\nexport class AsyncLoadingDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncEmpty]' })\nexport class AsyncEmptyDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncError]' })\nexport class AsyncErrorDirective {\n constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}\n}\n\n/**\n * Renders exactly ONE of loading / empty / error / loaded for a signal-based\n * resource (e.g. httpResource). Built on a RemoteData tagged union (see\n * core/remote-data.ts), so the states are mutually exclusive by construction —\n * the UI can never show two at once (\"impossible states\"). Unprovided slots\n * fall back to sensible defaults.\n */\n@Component({\n selector: 'app-async',\n imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],\n template: `\n @switch (rd().tag) {\n @case ('Loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('Failure') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('Empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('Success') {\n \n }\n }\n `,\n})\nexport class AsyncComponent {\n // Two ways to feed this component:\n // [resource] — a raw httpResource (the common case), or\n // [data] — an already-combined RemoteData (e.g. from a store via map2).\n resource = input>();\n data = input>();\n isEmpty = input<(v: T) => boolean>(() => false);\n\n loadedTpl = contentChild.required(AsyncLoadedDirective);\n loadingTpl = contentChild(AsyncLoadingDirective);\n emptyTpl = contentChild(AsyncEmptyDirective);\n errorTpl = contentChild(AsyncErrorDirective);\n\n // Single source of truth: the supplied RemoteData, or the resource projected into one.\n protected rd = computed>(() => {\n const data = this.data();\n if (data) return data;\n const r = this.resource();\n return r ? fromResource(r, this.isEmpty()) : { tag: 'Loading' };\n });\n\n // value/error are pulled out via the exhaustive fold — only Success carries a\n // value, only Failure carries an error, so these can't lie.\n protected value = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: () => undefined, success: (v) => v }),\n );\n protected error = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: (e) => e, success: () => undefined }),\n );\n\n retry = () => {\n const r = this.resource();\n if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {\n (r as { reload: () => void }).reload();\n }\n };\n}\n\n/** Convenience: import this array to get the wrapper + all slot directives. */\nexport const ASYNC = [\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const;\n", + "sourceCode": "import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport type { Resource } from '@angular/core';\nimport { SpinnerComponent } from '@shared/ui/spinner/spinner.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';\n\n/* Slot markers. Put on children of . */\n@Directive({ selector: '[appAsyncLoaded]' })\nexport class AsyncLoadedDirective {\n constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}\n}\n@Directive({ selector: '[appAsyncLoading]' })\nexport class AsyncLoadingDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncEmpty]' })\nexport class AsyncEmptyDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncError]' })\nexport class AsyncErrorDirective {\n constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}\n}\n\n/**\n * Renders exactly ONE of loading / empty / error / loaded for a signal-based\n * resource (e.g. httpResource). Built on a RemoteData tagged union (see\n * core/remote-data.ts), so the states are mutually exclusive by construction —\n * the UI can never show two at once (\"impossible states\"). Unprovided slots\n * fall back to sensible defaults.\n */\n@Component({\n selector: 'app-async',\n imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],\n template: `\n
\n @switch (rd().tag) {\n @case ('Loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('Failure') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('Empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('Success') {\n \n }\n }\n
\n `,\n})\nexport class AsyncComponent {\n // Two ways to feed this component:\n // [resource] — a raw httpResource (the common case), or\n // [data] — an already-combined RemoteData (e.g. from a store via map2).\n resource = input>();\n data = input>();\n isEmpty = input<(v: T) => boolean>(() => false);\n\n loadedTpl = contentChild.required(AsyncLoadedDirective);\n loadingTpl = contentChild(AsyncLoadingDirective);\n emptyTpl = contentChild(AsyncEmptyDirective);\n errorTpl = contentChild(AsyncErrorDirective);\n\n // Single source of truth: the supplied RemoteData, or the resource projected into one.\n protected rd = computed>(() => {\n const data = this.data();\n if (data) return data;\n const r = this.resource();\n return r ? fromResource(r, this.isEmpty()) : { tag: 'Loading' };\n });\n\n // value/error are pulled out via the exhaustive fold — only Success carries a\n // value, only Failure carries an error, so these can't lie.\n protected value = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: () => undefined, success: (v) => v }),\n );\n protected error = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: (e) => e, success: () => undefined }),\n );\n\n retry = () => {\n const r = this.resource();\n if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {\n (r as { reload: () => void }).reload();\n }\n };\n}\n\n/** Convenience: import this array to get the wrapper + all slot directives. */\nexport const ASYNC = [\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const;\n", "selector": "[appAsyncLoading]", "providers": [], "hostDirectives": [], @@ -1455,7 +2246,7 @@ }, { "name": "AsyncComponent", - "id": "component-AsyncComponent-b3cfb3c4a98444c42ba42c11d432aca9cf324dba35b23ccb8af26360530e37b4edcc64b110afd78fb4d7e4eef1b24c72be872007aaa92127f22e76e1ae7c0964", + "id": "component-AsyncComponent-0d66d917cda6e95349b21544c6fed09ee792cd86cf353cab9a71395a29342dfc9115ba1a9c915bb1851c09330826035b08479c514c9e47f9a464e03c3f71675c", "file": "src/app/shared/ui/async/async.component.ts", "encapsulation": [], "entryComponents": [], @@ -1465,7 +2256,7 @@ "selector": "app-async", "styleUrls": [], "styles": [], - "template": "@switch (rd().tag) {\n @case ('Loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('Failure') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('Empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('Success') {\n \n }\n}\n", + "template": "
\n@switch (rd().tag) {\n @case ('Loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('Failure') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('Empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('Success') {\n \n }\n}\n
\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -1478,7 +2269,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 70, + "line": 72, "required": false }, { @@ -1490,7 +2281,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 71, + "line": 73, "required": false }, { @@ -1501,7 +2292,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 69, + "line": 71, "required": false } ], @@ -1516,7 +2307,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 75 + "line": 77 }, { "name": "error", @@ -1527,7 +2318,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 91, + "line": 93, "modifierKind": [ 124 ] @@ -1541,7 +2332,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 76 + "line": 78 }, { "name": "loadedTpl", @@ -1552,7 +2343,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 73 + "line": 75 }, { "name": "loadingTpl", @@ -1563,7 +2354,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 74 + "line": 76 }, { "name": "rd", @@ -1574,7 +2365,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 79, + "line": 81, "modifierKind": [ 124 ] @@ -1588,7 +2379,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 95 + "line": 97 }, { "name": "value", @@ -1599,7 +2390,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 88, + "line": 90, "modifierKind": [ 124 ] @@ -1631,12 +2422,65 @@ "description": "

Renders exactly ONE of loading / empty / error / loaded for a signal-based\nresource (e.g. httpResource). Built on a RemoteData tagged union (see\ncore/remote-data.ts), so the states are mutually exclusive by construction —\nthe UI can never show two at once ("impossible states"). Unprovided slots\nfall back to sensible defaults.

\n", "rawdescription": "\n\nRenders exactly ONE of loading / empty / error / loaded for a signal-based\nresource (e.g. httpResource). Built on a RemoteData tagged union (see\ncore/remote-data.ts), so the states are mutually exclusive by construction —\nthe UI can never show two at once (\"impossible states\"). Unprovided slots\nfall back to sensible defaults.\n", "type": "component", - "sourceCode": "import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport type { Resource } from '@angular/core';\nimport { SpinnerComponent } from '@shared/ui/spinner/spinner.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';\n\n/* Slot markers. Put on children of . */\n@Directive({ selector: '[appAsyncLoaded]' })\nexport class AsyncLoadedDirective {\n constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}\n}\n@Directive({ selector: '[appAsyncLoading]' })\nexport class AsyncLoadingDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncEmpty]' })\nexport class AsyncEmptyDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncError]' })\nexport class AsyncErrorDirective {\n constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}\n}\n\n/**\n * Renders exactly ONE of loading / empty / error / loaded for a signal-based\n * resource (e.g. httpResource). Built on a RemoteData tagged union (see\n * core/remote-data.ts), so the states are mutually exclusive by construction —\n * the UI can never show two at once (\"impossible states\"). Unprovided slots\n * fall back to sensible defaults.\n */\n@Component({\n selector: 'app-async',\n imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],\n template: `\n @switch (rd().tag) {\n @case ('Loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('Failure') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('Empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('Success') {\n \n }\n }\n `,\n})\nexport class AsyncComponent {\n // Two ways to feed this component:\n // [resource] — a raw httpResource (the common case), or\n // [data] — an already-combined RemoteData (e.g. from a store via map2).\n resource = input>();\n data = input>();\n isEmpty = input<(v: T) => boolean>(() => false);\n\n loadedTpl = contentChild.required(AsyncLoadedDirective);\n loadingTpl = contentChild(AsyncLoadingDirective);\n emptyTpl = contentChild(AsyncEmptyDirective);\n errorTpl = contentChild(AsyncErrorDirective);\n\n // Single source of truth: the supplied RemoteData, or the resource projected into one.\n protected rd = computed>(() => {\n const data = this.data();\n if (data) return data;\n const r = this.resource();\n return r ? fromResource(r, this.isEmpty()) : { tag: 'Loading' };\n });\n\n // value/error are pulled out via the exhaustive fold — only Success carries a\n // value, only Failure carries an error, so these can't lie.\n protected value = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: () => undefined, success: (v) => v }),\n );\n protected error = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: (e) => e, success: () => undefined }),\n );\n\n retry = () => {\n const r = this.resource();\n if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {\n (r as { reload: () => void }).reload();\n }\n };\n}\n\n/** Convenience: import this array to get the wrapper + all slot directives. */\nexport const ASYNC = [\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const;\n", + "sourceCode": "import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport type { Resource } from '@angular/core';\nimport { SpinnerComponent } from '@shared/ui/spinner/spinner.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';\n\n/* Slot markers. Put on children of . */\n@Directive({ selector: '[appAsyncLoaded]' })\nexport class AsyncLoadedDirective {\n constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}\n}\n@Directive({ selector: '[appAsyncLoading]' })\nexport class AsyncLoadingDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncEmpty]' })\nexport class AsyncEmptyDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncError]' })\nexport class AsyncErrorDirective {\n constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}\n}\n\n/**\n * Renders exactly ONE of loading / empty / error / loaded for a signal-based\n * resource (e.g. httpResource). Built on a RemoteData tagged union (see\n * core/remote-data.ts), so the states are mutually exclusive by construction —\n * the UI can never show two at once (\"impossible states\"). Unprovided slots\n * fall back to sensible defaults.\n */\n@Component({\n selector: 'app-async',\n imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],\n template: `\n
\n @switch (rd().tag) {\n @case ('Loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('Failure') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('Empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('Success') {\n \n }\n }\n
\n `,\n})\nexport class AsyncComponent {\n // Two ways to feed this component:\n // [resource] — a raw httpResource (the common case), or\n // [data] — an already-combined RemoteData (e.g. from a store via map2).\n resource = input>();\n data = input>();\n isEmpty = input<(v: T) => boolean>(() => false);\n\n loadedTpl = contentChild.required(AsyncLoadedDirective);\n loadingTpl = contentChild(AsyncLoadingDirective);\n emptyTpl = contentChild(AsyncEmptyDirective);\n errorTpl = contentChild(AsyncErrorDirective);\n\n // Single source of truth: the supplied RemoteData, or the resource projected into one.\n protected rd = computed>(() => {\n const data = this.data();\n if (data) return data;\n const r = this.resource();\n return r ? fromResource(r, this.isEmpty()) : { tag: 'Loading' };\n });\n\n // value/error are pulled out via the exhaustive fold — only Success carries a\n // value, only Failure carries an error, so these can't lie.\n protected value = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: () => undefined, success: (v) => v }),\n );\n protected error = computed(() =>\n foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: (e) => e, success: () => undefined }),\n );\n\n retry = () => {\n const r = this.resource();\n if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {\n (r as { reload: () => void }).reload();\n }\n };\n}\n\n/** Convenience: import this array to get the wrapper + all slot directives. */\nexport const ASYNC = [\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const;\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", "extends": [] }, + { + "name": "BreadcrumbComponent", + "id": "component-BreadcrumbComponent-4dedba5c509fabd51900b3062e33290365b01e1fdc64a11c5ce7fe12f20201b340d7fcfbe61254b0d0b7a386ab40a29795bd467542fb1a353f4d9bda46e65688", + "file": "src/app/shared/layout/breadcrumb/breadcrumb.component.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "app-breadcrumb", + "styleUrls": [], + "styles": [ + "\n :host{display:block}\n .list{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;list-style:none;margin:0;padding:0}\n .sep{color:var(--rhc-color-foreground-subtle)}\n " + ], + "template": "\n", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [ + { + "name": "items", + "deprecated": false, + "deprecationMessage": "", + "type": "BreadcrumbItem[]", + "indexKey": "", + "optional": false, + "description": "", + "line": 38, + "required": true + } + ], + "outputsClass": [], + "propertiesClass": [], + "methodsClass": [], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [ + { + "name": "RouterLink" + } + ], + "description": "

Chrome: breadcrumb navigation. Renders the RHC/Utrecht breadcrumb pattern; the\nlast item is the current page (no link, aria-current). Domain-free — the caller\nsupplies the trail.

\n", + "rawdescription": "\nChrome: breadcrumb navigation. Renders the RHC/Utrecht breadcrumb pattern; the\nlast item is the current page (no link, aria-current). Domain-free — the caller\nsupplies the trail.", + "type": "component", + "sourceCode": "import { Component, input } from '@angular/core';\nimport { RouterLink } from '@angular/router';\n\nexport interface BreadcrumbItem {\n label: string;\n link?: string; // omit on the current (last) page\n}\n\n/** Chrome: breadcrumb navigation. Renders the RHC/Utrecht breadcrumb pattern; the\n last item is the current page (no link, aria-current). Domain-free — the caller\n supplies the trail. */\n@Component({\n selector: 'app-breadcrumb',\n imports: [RouterLink],\n styles: [`\n :host{display:block}\n .list{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;list-style:none;margin:0;padding:0}\n .sep{color:var(--rhc-color-foreground-subtle)}\n `],\n template: `\n \n `,\n})\nexport class BreadcrumbComponent {\n items = input.required();\n}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "\n :host{display:block}\n .list{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;list-style:none;margin:0;padding:0}\n .sep{color:var(--rhc-color-foreground-subtle)}\n \n", + "extends": [] + }, { "name": "ButtonComponent", "id": "component-ButtonComponent-6eb936521438d91cc3a2276d1b8e366496e907e8a01d7b84b327c3cc31fa1dfadbb9502c6646472736722cf7ced9950fcaa4d8435ddf28224b3e7c9b7bd29505", @@ -1845,7 +2689,7 @@ }, { "name": "ConceptsPage", - "id": "component-ConceptsPage-ab97eabf19e01429585206b9d60c4a9a576f262daa6eda12eef4bcb48a74a35c2a95cd9a4a5468e3367917201f918965ed03b6fdcbc2a0eca655f2d00a72f209", + "id": "component-ConceptsPage-0a01b17716cd0d25e6658360c5843453e9c20e94e6c616bc73509d9e849eb3c45418b941f5398de972a5969767fcb61df9b3b5b50ecd563f967da87f5b12b919", "file": "src/app/showcase/concepts.page.ts", "encapsulation": [], "entryComponents": [], @@ -1857,7 +2701,7 @@ "styles": [ "\n .section { margin: 0 0 3rem }\n .lead { color: var(--rhc-color-grijs-700); max-width: 46rem; margin: 0.25rem 0 1.25rem }\n .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 1.5rem; align-items: start }\n .card { border: 1px solid var(--rhc-color-grijs-200, #e5e5e5); border-radius: 10px; padding: 1.25rem; background: #fff }\n .card--bad { border-color: var(--rhc-color-rood-300, #f0b4b4) }\n .card--good { border-color: var(--rhc-color-groen-300, #b4e0b4) }\n .tag { display: inline-flex; align-items: center; gap: 0.4rem; font-weight: 700; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 0.75rem }\n .tag::before { content: ''; width: 0.6rem; height: 0.6rem; border-radius: 50% }\n .tag.bad { color: var(--rhc-color-rood-600, #a30000) } .tag.bad::before { background: var(--rhc-color-rood-500, #d52b1e) }\n .tag.good { color: var(--rhc-color-groen-700, #277337) } .tag.good::before { background: var(--rhc-color-groen-500, #39870c) }\n .tag.plain { color: var(--rhc-color-grijs-700) } .tag.plain::before { display: none }\n pre { background: #1e2430; color: #e6e9ef; padding: 1rem; border-radius: 8px; overflow: auto; font-size: 0.82rem; line-height: 1.55; margin: 0 }\n pre .k { color: #c792ea } pre .s { color: #c3e88d } pre .c { color: #7e8aa0; font-style: italic }\n .note { font-size: 0.9rem; color: var(--rhc-color-grijs-700); margin: 0.75rem 0 0 }\n /* live state diagram */\n .machine { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0 0 1rem }\n .node { padding: 0.4rem 0.8rem; border-radius: 999px; border: 1px solid var(--rhc-color-grijs-300, #ccc); font-size: 0.82rem; color: var(--rhc-color-grijs-700); transition: all .15s }\n .node.on { background: var(--rhc-color-hemelblauw-100, #e5f1fb); border-color: var(--rhc-color-hemelblauw-500, #007bc7); color: var(--rhc-color-hemelblauw-700, #00567d); font-weight: 700 }\n .steplist { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin: 0 0 1rem }\n .pill { padding: 0.3rem 0.7rem; border-radius: 8px; background: var(--rhc-color-grijs-100, #f3f3f3); font-size: 0.8rem }\n .pill.extra { background: var(--rhc-color-geel-100, #fff6d6); border: 1px dashed var(--rhc-color-geel-600, #c79a00) }\n .arrow { color: var(--rhc-color-grijs-400, #999) }\n " ], - "template": "\n

\n Vijf functionele patronen die atomic design makkelijker maakt om te tonen — telkens\n \"fout\" (de oude vorm liet het toe) naast \"goed\" (het type maakt het onmogelijk).\n

\n\n \n
\n 1 · Discriminated unions\n

Laat elke variant precies de gegevens dragen die kloppen — niets meer.

\n
\n
\n

Fout — vlakke interface

\n
\n        

Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.

\n
\n
\n

Goed — sum type

\n \n

De variant Doorgehaald kent geen herregistratiedatum, dus de rij bestaat simpelweg niet.

\n
\n
\n
\n\n \n
\n 2 · RemoteData fold\n

Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.

\n
\n
\n

Vier toestanden, één molecuul

\n

Loading

\n {{ v }}\n

Empty

\n {{ v }}\n

Failure

\n {{ v }}\n

Success

\n
    @for (i of v; track i) {
  • {{ i }}
  • }
\n
\n
\n

De exhaustieve fold

\n
\n        

Een nieuwe variant toevoegen breekt de compile via assertNever tot je hem afhandelt.

\n
\n
\n
\n\n \n
\n 3 · Parse, don't validate\n

Na het parsen onthoudt het type dat de waarde geldig is.

\n
\n
\n

Smart constructor → Result

\n \n
\n
\n @if (parsed().ok) {\n

ok

\n
Postcode = \"{{ $any(parsed()).value }}\"
\n

Een gevalideerde Postcode is een ander type dan een ruwe string.

\n } @else {\n

err

\n
{{ $any(parsed()).error }}
\n }\n
\n
\n
\n\n \n
\n 4 · Form als state machine\n

Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de huidige.

\n
\n
\n

Fout — losse booleans

\n
\n        

Niets verhindert \"submitting\" mét validatiefouten of een successcherm met errors.

\n
\n
\n

Goed — één tagged union

\n
\n @for (n of ['Editing','Submitting','Submitted','Failed']; track n) {\n {{ n }}\n }\n
\n \n
\n
\n
\n\n \n
\n 5 · Vertakkende vragenlijst — \"afleiden, niet opslaan\"\n

\n Welke stappen bestaan is geen opgeslagen toestand maar een pure functie van de antwoorden\n (visibleSteps(answers)). Antwoord \"ja\" op buitenland of vul weinig uren in, en de\n lijst hieronder groeit mee.\n

\n
\n
\n

Live afgeleide stappen

\n
\n @for (s of iw.steps(); track s; let last = $last) {\n {{ s }}\n @if (!last) { }\n }\n
\n

De gestippelde stappen verschijnen alleen door eerdere antwoorden. De voortgang \"van N\" verandert mee.

\n
\n
\n

De wizard

\n \n
\n
\n
\n
\n", + "template": "\n

\n Vijf functionele patronen die atomic design makkelijker maakt om te tonen — telkens\n \"fout\" (de oude vorm liet het toe) naast \"goed\" (het type maakt het onmogelijk).\n

\n\n \n
\n 1 · Discriminated unions\n

Laat elke variant precies de gegevens dragen die kloppen — niets meer.

\n
\n
\n

Fout — vlakke interface

\n
\n        

Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.

\n
\n
\n

Goed — sum type

\n \n

De variant Doorgehaald kent geen herregistratiedatum, dus de rij bestaat simpelweg niet.

\n
\n
\n
\n\n \n
\n 2 · RemoteData fold\n

Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.

\n
\n
\n

Vier toestanden, één molecuul

\n

Loading

\n {{ v }}\n

Empty

\n {{ v }}\n

Failure

\n {{ v }}\n

Success

\n
    @for (i of v; track i) {
  • {{ i }}
  • }
\n
\n
\n

De exhaustieve fold

\n
\n        

Een nieuwe variant toevoegen breekt de compile via assertNever tot je hem afhandelt.

\n
\n
\n
\n\n \n
\n 3 · Parse, don't validate\n

Na het parsen onthoudt het type dat de waarde geldig is.

\n
\n
\n

Smart constructor → Result

\n \n
\n
\n @if (parsed().ok) {\n

ok

\n
Postcode = \"{{ $any(parsed()).value }}\"
\n

Een gevalideerde Postcode is een ander type dan een ruwe string.

\n } @else {\n

err

\n
{{ $any(parsed()).error }}
\n }\n
\n
\n
\n\n \n
\n 4 · Form als state machine\n

Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de huidige.

\n
\n
\n

Fout — losse booleans

\n
\n        

Niets verhindert \"submitting\" mét validatiefouten of een successcherm met errors.

\n
\n
\n

Goed — één tagged union

\n
\n @for (n of ['Editing','Submitting','Submitted','Failed']; track n) {\n {{ n }}\n }\n
\n \n
\n
\n
\n\n \n
\n 5 · Vragenlijst met vaste stappen — \"vragen tonen, niet stappen toevoegen\"\n

\n Het aantal stappen ligt vast (STEPS); vervolgvragen verschijnen binnen een stap\n op basis van eerdere antwoorden. Antwoord \"ja\" op buitenland of vul weinig uren in, en er komt een\n extra vraag bij in dezelfde stap — de voortgang \"van N\" blijft gelijk.\n

\n
\n
\n

Vaste stappen

\n
\n @for (s of iw.steps; track s; let last = $last) {\n {{ s }}\n @if (!last) { }\n }\n
\n

De stappen zijn altijd dezelfde; alleen de vragen binnen een stap verschijnen of verdwijnen.

\n
\n
\n

De wizard

\n \n
\n
\n
\n
\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -2032,7 +2876,7 @@ "description": "

Teaching showcase: each section pairs the impossible-state-permitting "before"\nwith the "after" where the type system rules it out. Composition-only.

\n", "rawdescription": "\nTeaching showcase: each section pairs the impossible-state-permitting \"before\"\nwith the \"after\" where the type system rules it out. Composition-only.", "type": "component", - "sourceCode": "import { Component, computed, signal } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport type { Resource } from '@angular/core';\nimport { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';\nimport { HeadingComponent } from '@shared/ui/heading/heading.component';\nimport { TextInputComponent } from '@shared/ui/text-input/text-input.component';\nimport { ASYNC } from '@shared/ui/async/async.component';\nimport { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';\nimport { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';\nimport { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';\nimport { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';\nimport { Registration } from '@registratie/domain/registration';\nimport { parsePostcode } from '@registratie/domain/value-objects/postcode';\n\n/** Minimal fake Resource so can be driven through every state without HTTP. */\nfunction fakeResource(status: string, value?: T, error?: Error): Resource {\n return { value: () => value as T, status: () => status, error: () => error, hasValue: () => value !== undefined, reload: () => {} } as unknown as Resource;\n}\n\n/** Teaching showcase: each section pairs the impossible-state-permitting \"before\"\n with the \"after\" where the type system rules it out. Composition-only. */\n@Component({\n selector: 'app-concepts-page',\n imports: [\n FormsModule, PageShellComponent, HeadingComponent, TextInputComponent,\n ...ASYNC, SkeletonComponent, RegistrationSummaryComponent, HerregistratieWizardComponent, IntakeWizardComponent,\n ],\n styles: [`\n .section { margin: 0 0 3rem }\n .lead { color: var(--rhc-color-grijs-700); max-width: 46rem; margin: 0.25rem 0 1.25rem }\n .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 1.5rem; align-items: start }\n .card { border: 1px solid var(--rhc-color-grijs-200, #e5e5e5); border-radius: 10px; padding: 1.25rem; background: #fff }\n .card--bad { border-color: var(--rhc-color-rood-300, #f0b4b4) }\n .card--good { border-color: var(--rhc-color-groen-300, #b4e0b4) }\n .tag { display: inline-flex; align-items: center; gap: 0.4rem; font-weight: 700; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 0.75rem }\n .tag::before { content: ''; width: 0.6rem; height: 0.6rem; border-radius: 50% }\n .tag.bad { color: var(--rhc-color-rood-600, #a30000) } .tag.bad::before { background: var(--rhc-color-rood-500, #d52b1e) }\n .tag.good { color: var(--rhc-color-groen-700, #277337) } .tag.good::before { background: var(--rhc-color-groen-500, #39870c) }\n .tag.plain { color: var(--rhc-color-grijs-700) } .tag.plain::before { display: none }\n pre { background: #1e2430; color: #e6e9ef; padding: 1rem; border-radius: 8px; overflow: auto; font-size: 0.82rem; line-height: 1.55; margin: 0 }\n pre .k { color: #c792ea } pre .s { color: #c3e88d } pre .c { color: #7e8aa0; font-style: italic }\n .note { font-size: 0.9rem; color: var(--rhc-color-grijs-700); margin: 0.75rem 0 0 }\n /* live state diagram */\n .machine { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0 0 1rem }\n .node { padding: 0.4rem 0.8rem; border-radius: 999px; border: 1px solid var(--rhc-color-grijs-300, #ccc); font-size: 0.82rem; color: var(--rhc-color-grijs-700); transition: all .15s }\n .node.on { background: var(--rhc-color-hemelblauw-100, #e5f1fb); border-color: var(--rhc-color-hemelblauw-500, #007bc7); color: var(--rhc-color-hemelblauw-700, #00567d); font-weight: 700 }\n .steplist { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin: 0 0 1rem }\n .pill { padding: 0.3rem 0.7rem; border-radius: 8px; background: var(--rhc-color-grijs-100, #f3f3f3); font-size: 0.8rem }\n .pill.extra { background: var(--rhc-color-geel-100, #fff6d6); border: 1px dashed var(--rhc-color-geel-600, #c79a00) }\n .arrow { color: var(--rhc-color-grijs-400, #999) }\n `],\n template: `\n \n

\n Vijf functionele patronen die atomic design makkelijker maakt om te tonen — telkens\n \"fout\" (de oude vorm liet het toe) naast \"goed\" (het type maakt het onmogelijk).\n

\n\n \n
\n 1 · Discriminated unions\n

Laat elke variant precies de gegevens dragen die kloppen — niets meer.

\n
\n
\n

Fout — vlakke interface

\n
\n            

Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.

\n
\n
\n

Goed — sum type

\n \n

De variant Doorgehaald kent geen herregistratiedatum, dus de rij bestaat simpelweg niet.

\n
\n
\n
\n\n \n
\n 2 · RemoteData fold\n

Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.

\n
\n
\n

Vier toestanden, één molecuul

\n

Loading

\n {{ v }}\n

Empty

\n {{ v }}\n

Failure

\n {{ v }}\n

Success

\n
    @for (i of v; track i) {
  • {{ i }}
  • }
\n
\n
\n

De exhaustieve fold

\n
\n            

Een nieuwe variant toevoegen breekt de compile via assertNever tot je hem afhandelt.

\n
\n
\n
\n\n \n
\n 3 · Parse, don't validate\n

Na het parsen onthoudt het type dat de waarde geldig is.

\n
\n
\n

Smart constructor → Result

\n \n
\n
\n @if (parsed().ok) {\n

ok

\n
Postcode = \"{{ $any(parsed()).value }}\"
\n

Een gevalideerde Postcode is een ander type dan een ruwe string.

\n } @else {\n

err

\n
{{ $any(parsed()).error }}
\n }\n
\n
\n
\n\n \n
\n 4 · Form als state machine\n

Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de huidige.

\n
\n
\n

Fout — losse booleans

\n
\n            

Niets verhindert \"submitting\" mét validatiefouten of een successcherm met errors.

\n
\n
\n

Goed — één tagged union

\n
\n @for (n of ['Editing','Submitting','Submitted','Failed']; track n) {\n {{ n }}\n }\n
\n \n
\n
\n
\n\n \n
\n 5 · Vertakkende vragenlijst — \"afleiden, niet opslaan\"\n

\n Welke stappen bestaan is geen opgeslagen toestand maar een pure functie van de antwoorden\n (visibleSteps(answers)). Antwoord \"ja\" op buitenland of vul weinig uren in, en de\n lijst hieronder groeit mee.\n

\n
\n
\n

Live afgeleide stappen

\n
\n @for (s of iw.steps(); track s; let last = $last) {\n {{ s }}\n @if (!last) { }\n }\n
\n

De gestippelde stappen verschijnen alleen door eerdere antwoorden. De voortgang \"van N\" verandert mee.

\n
\n
\n

De wizard

\n \n
\n
\n
\n
\n `,\n})\nexport class ConceptsPage {\n isEmpty = (v: string[]) => !v || v.length === 0;\n\n doorgehaald: Registration = {\n bigNummer: '19012345601', naam: 'Dr. A. (Anna) de Vries', beroep: 'Arts',\n registratiedatum: '2012-09-01', geboortedatum: '1985-03-14',\n status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' },\n };\n\n loadingRes = fakeResource('loading');\n emptyRes = fakeResource('resolved', []);\n errorRes = fakeResource('error', undefined, new Error('Demo'));\n successRes = fakeResource('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']);\n\n raw = signal('');\n parsed = computed(() => parsePostcode(this.raw()));\n\n unionBad = `interface Registration {\n status: 'Geregistreerd' | 'Doorgehaald';\n herregistratieDatum: string; // altijd aanwezig 😬\n}`;\n\n foldCode = `foldRemote(rd, {\n loading: () => spinner,\n empty: () => 'geen data',\n failure: (e) => alert(e),\n success: (v) => render(v),\n}); // mist er één → compile-fout`;\n\n machineBad = `submitting = signal(false);\nsubmitted = signal(false);\nerrors = signal<...>({});\n// submitting === true && errors.size > 0 ? 🤷`;\n}\n", + "sourceCode": "import { Component, computed, signal } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport type { Resource } from '@angular/core';\nimport { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';\nimport { HeadingComponent } from '@shared/ui/heading/heading.component';\nimport { TextInputComponent } from '@shared/ui/text-input/text-input.component';\nimport { ASYNC } from '@shared/ui/async/async.component';\nimport { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';\nimport { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';\nimport { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';\nimport { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';\nimport { Registration } from '@registratie/domain/registration';\nimport { parsePostcode } from '@registratie/domain/value-objects/postcode';\n\n/** Minimal fake Resource so can be driven through every state without HTTP. */\nfunction fakeResource(status: string, value?: T, error?: Error): Resource {\n return { value: () => value as T, status: () => status, error: () => error, hasValue: () => value !== undefined, reload: () => {} } as unknown as Resource;\n}\n\n/** Teaching showcase: each section pairs the impossible-state-permitting \"before\"\n with the \"after\" where the type system rules it out. Composition-only. */\n@Component({\n selector: 'app-concepts-page',\n imports: [\n FormsModule, PageShellComponent, HeadingComponent, TextInputComponent,\n ...ASYNC, SkeletonComponent, RegistrationSummaryComponent, HerregistratieWizardComponent, IntakeWizardComponent,\n ],\n styles: [`\n .section { margin: 0 0 3rem }\n .lead { color: var(--rhc-color-grijs-700); max-width: 46rem; margin: 0.25rem 0 1.25rem }\n .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 1.5rem; align-items: start }\n .card { border: 1px solid var(--rhc-color-grijs-200, #e5e5e5); border-radius: 10px; padding: 1.25rem; background: #fff }\n .card--bad { border-color: var(--rhc-color-rood-300, #f0b4b4) }\n .card--good { border-color: var(--rhc-color-groen-300, #b4e0b4) }\n .tag { display: inline-flex; align-items: center; gap: 0.4rem; font-weight: 700; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 0.75rem }\n .tag::before { content: ''; width: 0.6rem; height: 0.6rem; border-radius: 50% }\n .tag.bad { color: var(--rhc-color-rood-600, #a30000) } .tag.bad::before { background: var(--rhc-color-rood-500, #d52b1e) }\n .tag.good { color: var(--rhc-color-groen-700, #277337) } .tag.good::before { background: var(--rhc-color-groen-500, #39870c) }\n .tag.plain { color: var(--rhc-color-grijs-700) } .tag.plain::before { display: none }\n pre { background: #1e2430; color: #e6e9ef; padding: 1rem; border-radius: 8px; overflow: auto; font-size: 0.82rem; line-height: 1.55; margin: 0 }\n pre .k { color: #c792ea } pre .s { color: #c3e88d } pre .c { color: #7e8aa0; font-style: italic }\n .note { font-size: 0.9rem; color: var(--rhc-color-grijs-700); margin: 0.75rem 0 0 }\n /* live state diagram */\n .machine { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0 0 1rem }\n .node { padding: 0.4rem 0.8rem; border-radius: 999px; border: 1px solid var(--rhc-color-grijs-300, #ccc); font-size: 0.82rem; color: var(--rhc-color-grijs-700); transition: all .15s }\n .node.on { background: var(--rhc-color-hemelblauw-100, #e5f1fb); border-color: var(--rhc-color-hemelblauw-500, #007bc7); color: var(--rhc-color-hemelblauw-700, #00567d); font-weight: 700 }\n .steplist { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin: 0 0 1rem }\n .pill { padding: 0.3rem 0.7rem; border-radius: 8px; background: var(--rhc-color-grijs-100, #f3f3f3); font-size: 0.8rem }\n .pill.extra { background: var(--rhc-color-geel-100, #fff6d6); border: 1px dashed var(--rhc-color-geel-600, #c79a00) }\n .arrow { color: var(--rhc-color-grijs-400, #999) }\n `],\n template: `\n \n

\n Vijf functionele patronen die atomic design makkelijker maakt om te tonen — telkens\n \"fout\" (de oude vorm liet het toe) naast \"goed\" (het type maakt het onmogelijk).\n

\n\n \n
\n 1 · Discriminated unions\n

Laat elke variant precies de gegevens dragen die kloppen — niets meer.

\n
\n
\n

Fout — vlakke interface

\n
\n            

Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.

\n
\n
\n

Goed — sum type

\n \n

De variant Doorgehaald kent geen herregistratiedatum, dus de rij bestaat simpelweg niet.

\n
\n
\n
\n\n \n
\n 2 · RemoteData fold\n

Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.

\n
\n
\n

Vier toestanden, één molecuul

\n

Loading

\n {{ v }}\n

Empty

\n {{ v }}\n

Failure

\n {{ v }}\n

Success

\n
    @for (i of v; track i) {
  • {{ i }}
  • }
\n
\n
\n

De exhaustieve fold

\n
\n            

Een nieuwe variant toevoegen breekt de compile via assertNever tot je hem afhandelt.

\n
\n
\n
\n\n \n
\n 3 · Parse, don't validate\n

Na het parsen onthoudt het type dat de waarde geldig is.

\n
\n
\n

Smart constructor → Result

\n \n
\n
\n @if (parsed().ok) {\n

ok

\n
Postcode = \"{{ $any(parsed()).value }}\"
\n

Een gevalideerde Postcode is een ander type dan een ruwe string.

\n } @else {\n

err

\n
{{ $any(parsed()).error }}
\n }\n
\n
\n
\n\n \n
\n 4 · Form als state machine\n

Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de huidige.

\n
\n
\n

Fout — losse booleans

\n
\n            

Niets verhindert \"submitting\" mét validatiefouten of een successcherm met errors.

\n
\n
\n

Goed — één tagged union

\n
\n @for (n of ['Editing','Submitting','Submitted','Failed']; track n) {\n {{ n }}\n }\n
\n \n
\n
\n
\n\n \n
\n 5 · Vragenlijst met vaste stappen — \"vragen tonen, niet stappen toevoegen\"\n

\n Het aantal stappen ligt vast (STEPS); vervolgvragen verschijnen binnen een stap\n op basis van eerdere antwoorden. Antwoord \"ja\" op buitenland of vul weinig uren in, en er komt een\n extra vraag bij in dezelfde stap — de voortgang \"van N\" blijft gelijk.\n

\n
\n
\n

Vaste stappen

\n
\n @for (s of iw.steps; track s; let last = $last) {\n {{ s }}\n @if (!last) { }\n }\n
\n

De stappen zijn altijd dezelfde; alleen de vragen binnen een stap verschijnen of verdwijnen.

\n
\n
\n

De wizard

\n \n
\n
\n
\n
\n `,\n})\nexport class ConceptsPage {\n isEmpty = (v: string[]) => !v || v.length === 0;\n\n doorgehaald: Registration = {\n bigNummer: '19012345601', naam: 'Dr. A. (Anna) de Vries', beroep: 'Arts',\n registratiedatum: '2012-09-01', geboortedatum: '1985-03-14',\n status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' },\n };\n\n loadingRes = fakeResource('loading');\n emptyRes = fakeResource('resolved', []);\n errorRes = fakeResource('error', undefined, new Error('Demo'));\n successRes = fakeResource('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']);\n\n raw = signal('');\n parsed = computed(() => parsePostcode(this.raw()));\n\n unionBad = `interface Registration {\n status: 'Geregistreerd' | 'Doorgehaald';\n herregistratieDatum: string; // altijd aanwezig 😬\n}`;\n\n foldCode = `foldRemote(rd, {\n loading: () => spinner,\n empty: () => 'geen data',\n failure: (e) => alert(e),\n success: (v) => render(v),\n}); // mist er één → compile-fout`;\n\n machineBad = `submitting = signal(false);\nsubmitted = signal(false);\nerrors = signal<...>({});\n// submitting === true && errors.size > 0 ? 🤷`;\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "\n .section { margin: 0 0 3rem }\n .lead { color: var(--rhc-color-grijs-700); max-width: 46rem; margin: 0.25rem 0 1.25rem }\n .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 1.5rem; align-items: start }\n .card { border: 1px solid var(--rhc-color-grijs-200, #e5e5e5); border-radius: 10px; padding: 1.25rem; background: #fff }\n .card--bad { border-color: var(--rhc-color-rood-300, #f0b4b4) }\n .card--good { border-color: var(--rhc-color-groen-300, #b4e0b4) }\n .tag { display: inline-flex; align-items: center; gap: 0.4rem; font-weight: 700; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 0.75rem }\n .tag::before { content: ''; width: 0.6rem; height: 0.6rem; border-radius: 50% }\n .tag.bad { color: var(--rhc-color-rood-600, #a30000) } .tag.bad::before { background: var(--rhc-color-rood-500, #d52b1e) }\n .tag.good { color: var(--rhc-color-groen-700, #277337) } .tag.good::before { background: var(--rhc-color-groen-500, #39870c) }\n .tag.plain { color: var(--rhc-color-grijs-700) } .tag.plain::before { display: none }\n pre { background: #1e2430; color: #e6e9ef; padding: 1rem; border-radius: 8px; overflow: auto; font-size: 0.82rem; line-height: 1.55; margin: 0 }\n pre .k { color: #c792ea } pre .s { color: #c3e88d } pre .c { color: #7e8aa0; font-style: italic }\n .note { font-size: 0.9rem; color: var(--rhc-color-grijs-700); margin: 0.75rem 0 0 }\n /* live state diagram */\n .machine { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0 0 1rem }\n .node { padding: 0.4rem 0.8rem; border-radius: 999px; border: 1px solid var(--rhc-color-grijs-300, #ccc); font-size: 0.82rem; color: var(--rhc-color-grijs-700); transition: all .15s }\n .node.on { background: var(--rhc-color-hemelblauw-100, #e5f1fb); border-color: var(--rhc-color-hemelblauw-500, #007bc7); color: var(--rhc-color-hemelblauw-700, #00567d); font-weight: 700 }\n .steplist { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin: 0 0 1rem }\n .pill { padding: 0.3rem 0.7rem; border-radius: 8px; background: var(--rhc-color-grijs-100, #f3f3f3); font-size: 0.8rem }\n .pill.extra { background: var(--rhc-color-geel-100, #fff6d6); border: 1px dashed var(--rhc-color-geel-600, #c79a00) }\n .arrow { color: var(--rhc-color-grijs-400, #999) }\n \n", @@ -2040,7 +2884,7 @@ }, { "name": "DashboardPage", - "id": "component-DashboardPage-9c8e05a1223f1fa7aa977bcae5c26df7680a5a86891cd2ceb4c36e67c4c89752b060e1ca473bf27eff342208371a1bfc6a565718d678a77e501f3a8adefc5876", + "id": "component-DashboardPage-ddf78634bc64a1bb22179e940266e3f66193d863c79e6dd2b9322cbbbcaf1ace61e0c99e90ffc2f87633af10da911231bf12249e484ed1a834c2d6e13a643a75", "file": "src/app/registratie/ui/dashboard.page.ts", "encapsulation": [], "entryComponents": [], @@ -2050,7 +2894,7 @@ "selector": "app-dashboard-page", "styleUrls": [], "styles": [], - "template": "\n @if (store.pendingHerregistratie()) {\n Uw herregistratie-aanvraag is in behandeling.\n }\n\n \n \n \n \n
\n Persoonsgegevens (BRP)\n
\n \n \n \n
\n
\n
\n \n \n \n
\n\n
\n Specialismen en aantekeningen\n \n \n \n \n \n \n \n \n

U heeft nog geen specialismen of aantekeningen.

\n
\n
\n
\n\n

\n Gegevens bekijken of een wijziging doorgeven →\n

\n

\n Herregistratie aanvragen →\n

\n

\n Herregistratie-intake (vragenlijst met vertakkingen) →\n

\n

\n Functionele patronen (impossible states) →\n

\n
\n", + "template": "\n @if (store.pendingHerregistratie()) {\n Uw herregistratie-aanvraag is in behandeling.\n }\n\n \n \n \n \n
\n Persoonsgegevens (BRP)\n
\n \n \n \n
\n
\n
\n \n \n \n
\n\n
\n Specialismen en aantekeningen\n \n \n \n \n \n \n \n \n

U heeft nog geen specialismen of aantekeningen.

\n
\n
\n
\n\n

\n Inschrijven in het BIG-register (registratiewizard) →\n

\n

\n Gegevens bekijken of een wijziging doorgeven →\n

\n

\n Herregistratie aanvragen →\n

\n

\n Herregistratie-intake (vragenlijst met vertakkingen) →\n

\n

\n Functionele patronen (impossible states) →\n

\n
\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -2066,7 +2910,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 74, + "line": 77, "modifierKind": [ 124 ] @@ -2118,7 +2962,7 @@ "description": "", "rawdescription": "\n", "type": "component", - "sourceCode": "import { Component, inject } from '@angular/core';\nimport { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';\nimport { HeadingComponent } from '@shared/ui/heading/heading.component';\nimport { LinkComponent } from '@shared/ui/link/link.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';\nimport { DataRowComponent } from '@shared/ui/data-row/data-row.component';\nimport { ASYNC } from '@shared/ui/async/async.component';\nimport { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';\nimport { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';\nimport { BigProfileStore } from '@registratie/application/big-profile.store';\n\n@Component({\n selector: 'app-dashboard-page',\n imports: [\n PageShellComponent, HeadingComponent, LinkComponent, AlertComponent, SkeletonComponent,\n DataRowComponent, ...ASYNC, RegistrationSummaryComponent, RegistrationTableComponent,\n ],\n template: `\n \n @if (store.pendingHerregistratie()) {\n Uw herregistratie-aanvraag is in behandeling.\n }\n\n \n \n \n \n
\n Persoonsgegevens (BRP)\n
\n \n \n \n
\n
\n
\n \n \n \n
\n\n
\n Specialismen en aantekeningen\n \n \n \n \n \n \n \n \n

U heeft nog geen specialismen of aantekeningen.

\n
\n
\n
\n\n

\n Gegevens bekijken of een wijziging doorgeven →\n

\n

\n Herregistratie aanvragen →\n

\n

\n Herregistratie-intake (vragenlijst met vertakkingen) →\n

\n

\n Functionele patronen (impossible states) →\n

\n
\n `,\n})\nexport class DashboardPage {\n protected store = inject(BigProfileStore);\n}\n", + "sourceCode": "import { Component, inject } from '@angular/core';\nimport { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';\nimport { HeadingComponent } from '@shared/ui/heading/heading.component';\nimport { LinkComponent } from '@shared/ui/link/link.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';\nimport { DataRowComponent } from '@shared/ui/data-row/data-row.component';\nimport { ASYNC } from '@shared/ui/async/async.component';\nimport { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';\nimport { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';\nimport { BigProfileStore } from '@registratie/application/big-profile.store';\n\n@Component({\n selector: 'app-dashboard-page',\n imports: [\n PageShellComponent, HeadingComponent, LinkComponent, AlertComponent, SkeletonComponent,\n DataRowComponent, ...ASYNC, RegistrationSummaryComponent, RegistrationTableComponent,\n ],\n template: `\n \n @if (store.pendingHerregistratie()) {\n Uw herregistratie-aanvraag is in behandeling.\n }\n\n \n \n \n \n
\n Persoonsgegevens (BRP)\n
\n \n \n \n
\n
\n
\n \n \n \n
\n\n
\n Specialismen en aantekeningen\n \n \n \n \n \n \n \n \n

U heeft nog geen specialismen of aantekeningen.

\n
\n
\n
\n\n

\n Inschrijven in het BIG-register (registratiewizard) →\n

\n

\n Gegevens bekijken of een wijziging doorgeven →\n

\n

\n Herregistratie aanvragen →\n

\n

\n Herregistratie-intake (vragenlijst met vertakkingen) →\n

\n

\n Functionele patronen (impossible states) →\n

\n
\n `,\n})\nexport class DashboardPage {\n protected store = inject(BigProfileStore);\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -2126,7 +2970,7 @@ }, { "name": "DataRowComponent", - "id": "component-DataRowComponent-bf012e0b5e277c5c6d53311a7649373316677ab4f6d5ba0b313f5eaeb5a061ea55cb08b0af237c487dcc3be5b15014d5c2b21b6f6903ff7689881fc8607eb1b2", + "id": "component-DataRowComponent-042344abd735238be6230f6fdad24c6403897f90c814e06f27d2f67c4ced32aa3d1da211a95b8c99aa782ff1665da487a93d19f21ba509ab29cd5e640fedc405", "file": "src/app/shared/ui/data-row/data-row.component.ts", "encapsulation": [], "entryComponents": [], @@ -2135,7 +2979,9 @@ "providers": [], "selector": "app-data-row", "styleUrls": [], - "styles": [], + "styles": [ + ":host:last-child .rhc-data-summary__item { border-block-end-style: none; }" + ], "template": "
\n
{{ key() }}
\n
{{ value() }}
\n
\n", "templateUrl": [], "viewProviders": [], @@ -2149,7 +2995,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 15, + "line": 18, "required": true }, { @@ -2161,7 +3007,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 16, + "line": 19, "required": false } ], @@ -2177,15 +3023,15 @@ "description": "

Molecule: one key/value row inside an RHC data-summary.\nWrap several of these in .rhc-data-summary (see registration-summary).

\n", "rawdescription": "\nMolecule: one key/value row inside an RHC data-summary.\nWrap several of these in .rhc-data-summary (see registration-summary).", "type": "component", - "sourceCode": "import { Component, input } from '@angular/core';\n\n/** Molecule: one key/value row inside an RHC data-summary.\n Wrap several of these in .rhc-data-summary (see registration-summary). */\n@Component({\n selector: 'app-data-row',\n template: `\n
\n
{{ key() }}
\n
{{ value() }}
\n
\n `,\n})\nexport class DataRowComponent {\n key = input.required();\n value = input('');\n}\n", + "sourceCode": "import { Component, input } from '@angular/core';\n\n/** Molecule: one key/value row inside an RHC data-summary.\n Wrap several of these in .rhc-data-summary (see registration-summary). */\n@Component({\n selector: 'app-data-row',\n // The RHC item draws a divider below every row; drop it on the last one so the\n // list ends cleanly instead of looking like a trailing empty row.\n styles: [`:host:last-child .rhc-data-summary__item { border-block-end-style: none; }`],\n template: `\n
\n
{{ key() }}
\n
{{ value() }}
\n
\n `,\n})\nexport class DataRowComponent {\n key = input.required();\n value = input('');\n}\n", "assetsDirs": [], "styleUrlsData": "", - "stylesData": "", + "stylesData": ":host:last-child .rhc-data-summary__item { border-block-end-style: none; }\n", "extends": [] }, { "name": "FormFieldComponent", - "id": "component-FormFieldComponent-9f7fb7fac967056161f8440bccbd87825bc2897463f63a612a12ac6154cfaa4e17a2a6469fbe936deba74319ac6029c6aa024abfc07095b226f5980e654cb12c", + "id": "component-FormFieldComponent-116bcd8039c2d08b60a7dee8bdc465c6a0a04fa3e03d4b371c12d759113e2b369673acd0d7928b9ea47966065d618ed483fc316fdcdb2c740c3891a732f377e9", "file": "src/app/shared/ui/form-field/form-field.component.ts", "encapsulation": [], "entryComponents": [], @@ -2195,7 +3041,7 @@ "selector": "app-form-field", "styleUrls": [], "styles": [], - "template": "
\n \n @if (description()) {\n
{{ description() }}
\n }\n \n @if (error()) {\n
{{ error() }}
\n }\n
\n", + "template": "
\n \n @if (description()) {\n
{{ description() }}
\n }\n \n @if (error()) {\n
{{ error() }}
\n }\n
\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -2257,7 +3103,7 @@ "description": "

Molecule: form field = label + projected control + optional error/description.\nReused by both the login form and the change-request form.

\n", "rawdescription": "\nMolecule: form field = label + projected control + optional error/description.\nReused by both the login form and the change-request form.", "type": "component", - "sourceCode": "import { Component, input } from '@angular/core';\n\n/** Molecule: form field = label + projected control + optional error/description.\n Reused by both the login form and the change-request form. */\n@Component({\n selector: 'app-form-field',\n template: `\n
\n \n @if (description()) {\n
{{ description() }}
\n }\n \n @if (error()) {\n
{{ error() }}
\n }\n
\n `,\n})\nexport class FormFieldComponent {\n label = input.required();\n fieldId = input.required();\n description = input();\n error = input();\n}\n", + "sourceCode": "import { Component, input } from '@angular/core';\n\n/** Molecule: form field = label + projected control + optional error/description.\n Reused by both the login form and the change-request form. */\n@Component({\n selector: 'app-form-field',\n template: `\n
\n \n @if (description()) {\n
{{ description() }}
\n }\n \n @if (error()) {\n
{{ error() }}
\n }\n
\n `,\n})\nexport class FormFieldComponent {\n label = input.required();\n fieldId = input.required();\n description = input();\n error = input();\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -2317,7 +3163,7 @@ }, { "name": "HerregistratiePage", - "id": "component-HerregistratiePage-320bc6fae5182789d36effc63292c612ce5b125b9cf1bb77b7bdc1be837ca444255723e8a2a48eb9abcada973007eec4badbaf82e60644b869c5d66ffd68d97d", + "id": "component-HerregistratiePage-b279a94020bfc4f5742e1214bdcfcf8c1725f0ceb12f42e4483a0194cf1b392e4f277e0661b62eb7a52b156b13806bd12a8732248d37f5bfcc5a68f0e1c61341", "file": "src/app/herregistratie/ui/herregistratie.page.ts", "encapsulation": [], "entryComponents": [], @@ -2336,14 +3182,14 @@ "propertiesClass": [ { "name": "eligibility", - "defaultValue": "computed(() =>\n map(this.store.profile(), (p) => isHerregistratieEligible(p.registration, new Date())),\n )", + "defaultValue": "computed(() =>\n map(this.store.decisions(), (d) => d.eligibleForHerregistratie),\n )", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "", - "line": 39, + "line": 40, "modifierKind": [ 124 ] @@ -2386,10 +3232,10 @@ "type": "component" } ], - "description": "

A whole new page built from existing building blocks. Eligibility is a pure\ndomain rule (registration.policy) read from the shared profile state.

\n", - "rawdescription": "\nA whole new page built from existing building blocks. Eligibility is a pure\ndomain rule (registration.policy) read from the shared profile state.", + "description": "

A whole new page built from existing building blocks. Eligibility is a\nSERVER-computed decision read from the aggregated view — the frontend renders\nit, it does not recompute the rule.

\n", + "rawdescription": "\nA whole new page built from existing building blocks. Eligibility is a\nSERVER-computed decision read from the aggregated view — the frontend renders\nit, it does not recompute the rule.", "type": "component", - "sourceCode": "import { Component, computed, inject } from '@angular/core';\nimport { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { ASYNC } from '@shared/ui/async/async.component';\nimport { map } from '@shared/application/remote-data';\nimport { BigProfileStore } from '@registratie/application/big-profile.store';\nimport { isHerregistratieEligible } from '@registratie/domain/registration.policy';\nimport { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';\n\n/** A whole new page built from existing building blocks. Eligibility is a pure\n domain rule (registration.policy) read from the shared profile state. */\n@Component({\n selector: 'app-herregistratie-page',\n imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent],\n template: `\n \n \n \n @if (eligible) {\n \n Uw huidige registratie verloopt binnenkort. Vraag tijdig herregistratie aan.\n \n
\n \n
\n } @else {\n \n Voor uw huidige registratiestatus is herregistratie niet mogelijk.\n \n }\n
\n
\n
\n `,\n})\nexport class HerregistratiePage {\n private store = inject(BigProfileStore);\n // Derive a boolean RemoteData from the combined profile via map (pure).\n protected eligibility = computed(() =>\n map(this.store.profile(), (p) => isHerregistratieEligible(p.registration, new Date())),\n );\n}\n", + "sourceCode": "import { Component, computed, inject } from '@angular/core';\nimport { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { ASYNC } from '@shared/ui/async/async.component';\nimport { map } from '@shared/application/remote-data';\nimport { BigProfileStore } from '@registratie/application/big-profile.store';\nimport { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';\n\n/** A whole new page built from existing building blocks. Eligibility is a\n SERVER-computed decision read from the aggregated view — the frontend renders\n it, it does not recompute the rule. */\n@Component({\n selector: 'app-herregistratie-page',\n imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent],\n template: `\n \n \n \n @if (eligible) {\n \n Uw huidige registratie verloopt binnenkort. Vraag tijdig herregistratie aan.\n \n
\n \n
\n } @else {\n \n Voor uw huidige registratiestatus is herregistratie niet mogelijk.\n \n }\n
\n
\n
\n `,\n})\nexport class HerregistratiePage {\n private store = inject(BigProfileStore);\n // The eligibility decision comes from the server (decisions block), not a\n // client-side rule. The UI just reads the boolean.\n protected eligibility = computed(() =>\n map(this.store.decisions(), (d) => d.eligibleForHerregistratie),\n );\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -2397,7 +3243,7 @@ }, { "name": "HerregistratieWizardComponent", - "id": "component-HerregistratieWizardComponent-95ad9f587488b2044f1c353c07a0cc97c2d9e77df8204260ec2c543c6c6d85799a062e6565b85faa9e143b1cfe8d72dfe964f3b0ed8edb52b48fd06bde9c41d3", + "id": "component-HerregistratieWizardComponent-7f88cdc3fbae79e0e373e2178954406f5236da9c11f6ac2ee9504a64055abe457d7e3b259572c6d0a75229acbf08cd404efab0fbcac07510ff5bf34523a2b3e7", "file": "src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts", "encapsulation": [], "entryComponents": [], @@ -2407,7 +3253,7 @@ "selector": "app-herregistratie-wizard", "styleUrls": [], "styles": [], - "template": "@switch (state().tag) {\n @case ('Editing') {\n

Stap {{ step() }} van 2

\n
\n @if (step() === 1) {\n \n \n \n \n \n \n Volgende\n } @else {\n \n \n \n
\n Vorige\n Herregistratie aanvragen\n
\n }\n
\n }\n @case ('Submitting') {\n Aanvraag wordt verwerkt…\n }\n @case ('Submitted') {\n Uw aanvraag tot herregistratie is ontvangen.\n }\n @case ('Failed') {\n Indienen mislukt: {{ failedError() }}\n
\n Opnieuw proberen\n
\n }\n}\n", + "template": "@switch (state().tag) {\n @case ('Editing') {\n

Stap {{ step() }} van 2

\n
\n @if (step() === 1) {\n \n \n \n \n \n \n
\n Volgende\n Annuleren\n
\n } @else {\n \n \n \n
\n Vorige\n Herregistratie aanvragen\n Annuleren\n
\n }\n
\n }\n @case ('Submitting') {\n Aanvraag wordt verwerkt…\n }\n @case ('Submitted') {\n Uw aanvraag tot herregistratie is ontvangen.\n }\n @case ('Failed') {\n Indienen mislukt: {{ failedError() }}\n
\n Opnieuw proberen\n
\n }\n}\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -2421,7 +3267,7 @@ "indexKey": "", "optional": false, "description": "

Optional seed so Storybook / the showcase can mount any state directly.

\n", - "line": 69, + "line": 73, "rawdescription": "\nOptional seed so Storybook / the showcase can mount any state directly.", "required": false } @@ -2437,7 +3283,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 72, + "line": 76, "modifierKind": [ 124 ] @@ -2451,7 +3297,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 76, + "line": 80, "modifierKind": [ 124 ] @@ -2465,7 +3311,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 74, + "line": 78, "modifierKind": [ 123 ] @@ -2479,7 +3325,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 78, + "line": 82, "modifierKind": [ 124 ] @@ -2493,7 +3339,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 79, + "line": 83, "modifierKind": [ 124 ] @@ -2507,7 +3353,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 77, + "line": 81, "modifierKind": [ 124 ] @@ -2521,7 +3367,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 80, + "line": 84, "modifierKind": [ 124 ] @@ -2535,7 +3381,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 65, + "line": 69, "modifierKind": [ 123 ] @@ -2549,7 +3395,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 71, + "line": 75, "modifierKind": [ 148 ] @@ -2563,7 +3409,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 75, + "line": 79, "modifierKind": [ 124 ] @@ -2577,7 +3423,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 66, + "line": 70, "modifierKind": [ 123 ] @@ -2590,7 +3436,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 86, + "line": 90, "deprecated": false, "deprecationMessage": "" }, @@ -2600,17 +3446,29 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 93, + "line": 97, "deprecated": false, "deprecationMessage": "" }, + { + "name": "restart", + "args": [], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 103, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\nReset the wizard to a fresh, empty start.", + "description": "

Reset the wizard to a fresh, empty start.

\n" + }, { "name": "runIfSubmitting", "args": [], "optional": false, "returnType": "any", "typeParameters": [], - "line": 100, + "line": 109, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nThe effect: when we entered Submitting, call the backend command, flip the\noptimistic cross-page flag, then dispatch the result (and commit/rollback).", @@ -2655,7 +3513,7 @@ "description": "

Organism: multi-step herregistratie wizard. ALL state lives in one signal\ndriven by the pure reduce function (see herregistratie.machine.ts) via an\nElm-style store. The UI just sends messages and folds over the state's tag —\nno booleans like submitting/submitted that could contradict each other.\nSubmitting also flips an optimistic flag on the shared BigProfileStore, so\nthe dashboard shows "in behandeling" immediately.

\n", "rawdescription": "\nOrganism: multi-step herregistratie wizard. ALL state lives in one signal\ndriven by the pure `reduce` function (see herregistratie.machine.ts) via an\nElm-style store. The UI just sends messages and folds over the state's tag —\nno booleans like `submitting`/`submitted` that could contradict each other.\nSubmitting also flips an optimistic flag on the shared BigProfileStore, so\nthe dashboard shows \"in behandeling\" immediately.", "type": "component", - "sourceCode": "import { Component, computed, inject, input } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { FormFieldComponent } from '@shared/ui/form-field/form-field.component';\nimport { TextInputComponent } from '@shared/ui/text-input/text-input.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { SpinnerComponent } from '@shared/ui/spinner/spinner.component';\nimport { createStore } from '@shared/application/store';\nimport { BigProfileStore } from '@registratie/application/big-profile.store';\nimport { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine';\nimport { submitHerregistratie } from '@herregistratie/application/submit-herregistratie';\n\n/** Organism: multi-step herregistratie wizard. ALL state lives in one signal\n driven by the pure `reduce` function (see herregistratie.machine.ts) via an\n Elm-style store. The UI just sends messages and folds over the state's tag —\n no booleans like `submitting`/`submitted` that could contradict each other.\n Submitting also flips an optimistic flag on the shared BigProfileStore, so\n the dashboard shows \"in behandeling\" immediately. */\n@Component({\n selector: 'app-herregistratie-wizard',\n imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, AlertComponent, SpinnerComponent],\n template: `\n @switch (state().tag) {\n @case ('Editing') {\n

Stap {{ step() }} van 2

\n
\n @if (step() === 1) {\n \n \n \n \n \n \n Volgende\n } @else {\n \n \n \n
\n Vorige\n Herregistratie aanvragen\n
\n }\n
\n }\n @case ('Submitting') {\n Aanvraag wordt verwerkt…\n }\n @case ('Submitted') {\n Uw aanvraag tot herregistratie is ontvangen.\n }\n @case ('Failed') {\n Indienen mislukt: {{ failedError() }}\n
\n Opnieuw proberen\n
\n }\n }\n `,\n})\nexport class HerregistratieWizardComponent {\n private profile = inject(BigProfileStore);\n private store = createStore(initial, reduce);\n\n /** Optional seed so Storybook / the showcase can mount any state directly. */\n seed = input(initial);\n\n readonly state = this.store.model; // public so the showcase can highlight the live state\n protected dispatch = this.store.dispatch;\n\n private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract) : null));\n protected step = computed(() => this.editing()?.step ?? 1);\n protected draft = computed(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });\n protected errUren = computed(() => this.editing()?.errors.uren ?? '');\n protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');\n protected errPunten = computed(() => this.editing()?.errors.punten ?? '');\n protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract).error : ''));\n\n constructor() {\n queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));\n }\n\n onPrimary() {\n const s = this.state();\n if (s.tag !== 'Editing') return;\n this.dispatch(s.step === 1 ? { tag: 'Next' } : { tag: 'Submit' });\n this.runIfSubmitting();\n }\n\n onRetry() {\n this.dispatch({ tag: 'Retry' });\n this.runIfSubmitting();\n }\n\n /** The effect: when we entered Submitting, call the backend command, flip the\n optimistic cross-page flag, then dispatch the result (and commit/rollback). */\n private async runIfSubmitting() {\n const s = this.state();\n if (s.tag !== 'Submitting') return;\n this.profile.beginHerregistratie();\n const r = await submitHerregistratie(s.data);\n if (r.ok) {\n this.dispatch({ tag: 'SubmitConfirmed' });\n this.profile.confirmHerregistratie();\n } else {\n this.dispatch({ tag: 'SubmitFailed', error: r.error });\n this.profile.rollbackHerregistratie();\n }\n }\n}\n", + "sourceCode": "import { Component, computed, inject, input } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { FormFieldComponent } from '@shared/ui/form-field/form-field.component';\nimport { TextInputComponent } from '@shared/ui/text-input/text-input.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { SpinnerComponent } from '@shared/ui/spinner/spinner.component';\nimport { createStore } from '@shared/application/store';\nimport { BigProfileStore } from '@registratie/application/big-profile.store';\nimport { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine';\nimport { submitHerregistratie } from '@herregistratie/application/submit-herregistratie';\n\n/** Organism: multi-step herregistratie wizard. ALL state lives in one signal\n driven by the pure `reduce` function (see herregistratie.machine.ts) via an\n Elm-style store. The UI just sends messages and folds over the state's tag —\n no booleans like `submitting`/`submitted` that could contradict each other.\n Submitting also flips an optimistic flag on the shared BigProfileStore, so\n the dashboard shows \"in behandeling\" immediately. */\n@Component({\n selector: 'app-herregistratie-wizard',\n imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, AlertComponent, SpinnerComponent],\n template: `\n @switch (state().tag) {\n @case ('Editing') {\n

Stap {{ step() }} van 2

\n
\n @if (step() === 1) {\n \n \n \n \n \n \n
\n Volgende\n Annuleren\n
\n } @else {\n \n \n \n
\n Vorige\n Herregistratie aanvragen\n Annuleren\n
\n }\n
\n }\n @case ('Submitting') {\n Aanvraag wordt verwerkt…\n }\n @case ('Submitted') {\n Uw aanvraag tot herregistratie is ontvangen.\n }\n @case ('Failed') {\n Indienen mislukt: {{ failedError() }}\n
\n Opnieuw proberen\n
\n }\n }\n `,\n})\nexport class HerregistratieWizardComponent {\n private profile = inject(BigProfileStore);\n private store = createStore(initial, reduce);\n\n /** Optional seed so Storybook / the showcase can mount any state directly. */\n seed = input(initial);\n\n readonly state = this.store.model; // public so the showcase can highlight the live state\n protected dispatch = this.store.dispatch;\n\n private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract) : null));\n protected step = computed(() => this.editing()?.step ?? 1);\n protected draft = computed(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });\n protected errUren = computed(() => this.editing()?.errors.uren ?? '');\n protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');\n protected errPunten = computed(() => this.editing()?.errors.punten ?? '');\n protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract).error : ''));\n\n constructor() {\n queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));\n }\n\n onPrimary() {\n const s = this.state();\n if (s.tag !== 'Editing') return;\n this.dispatch(s.step === 1 ? { tag: 'Next' } : { tag: 'Submit' });\n this.runIfSubmitting();\n }\n\n onRetry() {\n this.dispatch({ tag: 'Retry' });\n this.runIfSubmitting();\n }\n\n /** Reset the wizard to a fresh, empty start. */\n restart() {\n this.dispatch({ tag: 'Seed', state: initial });\n }\n\n /** The effect: when we entered Submitting, call the backend command, flip the\n optimistic cross-page flag, then dispatch the result (and commit/rollback). */\n private async runIfSubmitting() {\n const s = this.state();\n if (s.tag !== 'Submitting') return;\n this.profile.beginHerregistratie();\n const r = await submitHerregistratie(s.data);\n if (r.ok) {\n this.dispatch({ tag: 'SubmitConfirmed' });\n this.profile.confirmHerregistratie();\n } else {\n this.dispatch({ tag: 'SubmitFailed', error: r.error });\n this.profile.rollbackHerregistratie();\n }\n }\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -2665,7 +3523,7 @@ "deprecated": false, "deprecationMessage": "", "args": [], - "line": 80 + "line": 84 }, "extends": [] }, @@ -2719,7 +3577,7 @@ }, { "name": "IntakeWizardComponent", - "id": "component-IntakeWizardComponent-f9c390987674a1103c9b4b44873432dbacb4550534e7dd1fa207ac5add0926a003ecdef05cb4a7252221298d5f7c5735b07e34ce9704f012f9699aa70756f2ae", + "id": "component-IntakeWizardComponent-52bade37a06698f6b30c6175d4d62aa13679d7470643ea97895ebd96115ee352d9bc64cd17cc3fff68cc12fbbb65770febc3224779d3088980aaf1435efaa2ac", "file": "src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts", "encapsulation": [], "entryComponents": [], @@ -2729,7 +3587,7 @@ "selector": "app-intake-wizard", "styleUrls": [], "styles": [], - "template": "@switch (state().tag) {\n @case ('Answering') {\n

Stap {{ cursor() + 1 }} van {{ steps().length }}

\n
\n @switch (step()) {\n @case ('buitenland') {\n \n \n \n }\n @case ('buitenlandDetails') {\n \n \n \n \n \n \n }\n @case ('uren') {\n \n \n \n }\n @case ('scholing') {\n \n \n \n }\n @case ('punten') {\n \n \n \n }\n @case ('review') {\n Controleer uw antwoorden en dien de aanvraag in.\n
\n
Buiten NL gewerkt
{{ answers().buitenlandGewerkt ?? '—' }}
\n @if (answers().buitenlandGewerkt === 'ja') {\n
Land
{{ answers().land }}
\n
Buitenlandse uren
{{ answers().buitenlandseUren }}
\n }\n
Uren NL
{{ answers().uren }}
\n @if (steps().includes('scholing')) {\n
Aanvullende scholing
{{ answers().scholingGevolgd }}
\n }\n
Nascholingspunten
{{ answers().punten }}
\n
\n }\n }\n
\n @if (cursor() > 0) {\n Vorige\n }\n {{ step() === 'review' ? 'Aanvraag indienen' : 'Volgende' }}\n
\n
\n }\n @case ('Submitting') {\n Aanvraag wordt verwerkt…\n }\n @case ('Submitted') {\n Uw aanvraag tot herregistratie is ontvangen.\n
\n Opnieuw beginnen\n
\n }\n @case ('Failed') {\n Indienen mislukt: {{ failedError() }}\n
\n Opnieuw proberen\n
\n }\n}\n", + "template": "@switch (state().tag) {\n @case ('Answering') {\n

Stap {{ cursor() + 1 }} van {{ steps.length }}

\n
\n @switch (step()) {\n @case ('buitenland') {\n \n \n \n @if (answers().buitenlandGewerkt === 'ja') {\n \n \n \n \n \n \n }\n }\n @case ('werk') {\n \n \n \n @if (scholingZichtbaar()) {\n \n \n \n }\n @if (answers().scholingGevolgd === 'ja') {\n \n \n \n }\n }\n @case ('review') {\n Controleer uw antwoorden en dien de aanvraag in.\n
\n
Buiten NL gewerkt
{{ answers().buitenlandGewerkt ?? '—' }}
\n @if (answers().buitenlandGewerkt === 'ja') {\n
Land
{{ answers().land }}
\n
Buitenlandse uren
{{ answers().buitenlandseUren }}
\n }\n
Uren NL
{{ answers().uren }}
\n @if (scholingZichtbaar()) {\n
Aanvullende scholing
{{ answers().scholingGevolgd }}
\n }\n @if (answers().scholingGevolgd === 'ja') {\n
Nascholingspunten
{{ answers().punten }}
\n }\n
\n }\n }\n
\n @if (cursor() > 0) {\n Vorige\n }\n {{ step() === 'review' ? 'Aanvraag indienen' : 'Volgende' }}\n Annuleren\n
\n
\n }\n @case ('Submitting') {\n Aanvraag wordt verwerkt…\n }\n @case ('Submitted') {\n Uw aanvraag tot herregistratie is ontvangen.\n
\n Opnieuw beginnen\n
\n }\n @case ('Failed') {\n Indienen mislukt: {{ failedError() }}\n
\n Opnieuw proberen\n
\n }\n}\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -2743,7 +3601,7 @@ "indexKey": "", "optional": false, "description": "

Optional seed so Storybook / the showcase can mount any state directly.

\n", - "line": 116, + "line": 129, "rawdescription": "\nOptional seed so Storybook / the showcase can mount any state directly.", "required": false } @@ -2759,7 +3617,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 122, + "line": 135, "modifierKind": [ 123 ] @@ -2773,7 +3631,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 126, + "line": 139, "modifierKind": [ 124 ] @@ -2787,7 +3645,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 125, + "line": 138, "modifierKind": [ 124 ] @@ -2801,7 +3659,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 120, + "line": 133, "modifierKind": [ 148 ] @@ -2815,7 +3673,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 130, + "line": 147, "modifierKind": [ 124 ] @@ -2829,7 +3687,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 128, + "line": 145, "modifierKind": [ 124 ] @@ -2843,11 +3701,25 @@ "indexKey": "", "optional": false, "description": "", - "line": 118, + "line": 131, "modifierKind": [ 148 ] }, + { + "name": "policyRes", + "defaultValue": "httpResource(() => 'mock/intake-policy.json', {\n defaultValue: { scholingThreshold: SCHOLING_THRESHOLD_DEFAULT },\n })", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 124, + "modifierKind": [ + 123 + ] + }, { "name": "profile", "defaultValue": "inject(BigProfileStore)", @@ -2857,11 +3729,41 @@ "indexKey": "", "optional": false, "description": "", - "line": 112, + "line": 119, "modifierKind": [ 123 ] }, + { + "name": "scholingThreshold", + "defaultValue": "computed(() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "

Server-owned threshold from the policy endpoint (mirrored into machine state).

\n", + "line": 142, + "rawdescription": "\nServer-owned threshold from the policy endpoint (mirrored into machine state).", + "modifierKind": [ + 124 + ] + }, + { + "name": "scholingZichtbaar", + "defaultValue": "computed(() => lageUren(this.answers(), this.scholingThreshold()))", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "

Whether the inline scholing question is shown (and required) in the 'werk' step.

\n", + "line": 144, + "rawdescription": "\nWhether the inline scholing question is shown (and required) in the 'werk' step.", + "modifierKind": [ + 124 + ] + }, { "name": "set", "defaultValue": "() => {...}", @@ -2871,7 +3773,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 131, + "line": 148, "modifierKind": [ 124 ] @@ -2885,36 +3787,36 @@ "indexKey": "", "optional": false, "description": "", - "line": 119, + "line": 132, "modifierKind": [ 148 ] }, { "name": "step", - "defaultValue": "computed(() => this.steps()[Math.min(this.cursor(), this.steps().length - 1)])", + "defaultValue": "computed(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)])", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "", - "line": 127, + "line": 140, "modifierKind": [ 124 ] }, { "name": "steps", - "defaultValue": "computed(() => visibleSteps(this.answering()?.answers ?? {}))", + "defaultValue": "STEPS", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, - "description": "

Public so the showcase can render the live step list next to the wizard.

\n", - "line": 124, - "rawdescription": "\nPublic so the showcase can render the live step list next to the wizard.", + "description": "

Public so the showcase can render the (fixed) step list next to the wizard.

\n", + "line": 137, + "rawdescription": "\nPublic so the showcase can render the (fixed) step list next to the wizard.", "modifierKind": [ 148 ] @@ -2928,7 +3830,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 113, + "line": 120, "modifierKind": [ 123 ] @@ -2941,7 +3843,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 155, + "line": 179, "deprecated": false, "deprecationMessage": "" }, @@ -2951,7 +3853,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 162, + "line": 186, "deprecated": false, "deprecationMessage": "" }, @@ -2961,7 +3863,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 167, + "line": 191, "deprecated": false, "deprecationMessage": "" }, @@ -2971,7 +3873,7 @@ "optional": false, "returnType": "IntakeState | null", "typeParameters": [], - "line": 145, + "line": 169, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -2984,7 +3886,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 173, + "line": 197, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nThe effect: when we enter Submitting, call the backend, flip the optimistic\ncross-page flag, then dispatch the outcome (and commit/rollback).", @@ -3033,7 +3935,7 @@ "description": "

Organism: a BRANCHING intake questionnaire. All state lives in one signal\ndriven by the pure reduce (intake.machine.ts). Which step renders is derived\nfrom the answers via visibleSteps, never stored — so editing an earlier\nanswer immediately changes the remaining steps. Answers are persisted to\nlocalStorage so a page reload keeps the user's progress.

\n", "rawdescription": "\nOrganism: a BRANCHING intake questionnaire. All state lives in one signal\ndriven by the pure `reduce` (intake.machine.ts). Which step renders is derived\nfrom the answers via `visibleSteps`, never stored — so editing an earlier\nanswer immediately changes the remaining steps. Answers are persisted to\nlocalStorage so a page reload keeps the user's progress.", "type": "component", - "sourceCode": "import { Component, computed, effect, inject, input } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { FormFieldComponent } from '@shared/ui/form-field/form-field.component';\nimport { TextInputComponent } from '@shared/ui/text-input/text-input.component';\nimport { RadioGroupComponent } from '@shared/ui/radio-group/radio-group.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { SpinnerComponent } from '@shared/ui/spinner/spinner.component';\nimport { createStore } from '@shared/application/store';\nimport { BigProfileStore } from '@registratie/application/big-profile.store';\nimport {\n IntakeState,\n IntakeMsg,\n Answers,\n StepId,\n initial,\n reduce,\n visibleSteps,\n} from '@herregistratie/domain/intake.machine';\nimport { submitIntake } from '@herregistratie/application/submit-intake';\n\nconst STORAGE_KEY = 'intake-v1'; // ponytail: bump the suffix if the Answers shape changes; no migration.\nconst JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];\n\n/** Organism: a BRANCHING intake questionnaire. All state lives in one signal\n driven by the pure `reduce` (intake.machine.ts). Which step renders is derived\n from the answers via `visibleSteps`, never stored — so editing an earlier\n answer immediately changes the remaining steps. Answers are persisted to\n localStorage so a page reload keeps the user's progress. */\n@Component({\n selector: 'app-intake-wizard',\n imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, SpinnerComponent],\n template: `\n @switch (state().tag) {\n @case ('Answering') {\n

Stap {{ cursor() + 1 }} van {{ steps().length }}

\n
\n @switch (step()) {\n @case ('buitenland') {\n \n \n \n }\n @case ('buitenlandDetails') {\n \n \n \n \n \n \n }\n @case ('uren') {\n \n \n \n }\n @case ('scholing') {\n \n \n \n }\n @case ('punten') {\n \n \n \n }\n @case ('review') {\n Controleer uw antwoorden en dien de aanvraag in.\n
\n
Buiten NL gewerkt
{{ answers().buitenlandGewerkt ?? '—' }}
\n @if (answers().buitenlandGewerkt === 'ja') {\n
Land
{{ answers().land }}
\n
Buitenlandse uren
{{ answers().buitenlandseUren }}
\n }\n
Uren NL
{{ answers().uren }}
\n @if (steps().includes('scholing')) {\n
Aanvullende scholing
{{ answers().scholingGevolgd }}
\n }\n
Nascholingspunten
{{ answers().punten }}
\n
\n }\n }\n
\n @if (cursor() > 0) {\n Vorige\n }\n {{ step() === 'review' ? 'Aanvraag indienen' : 'Volgende' }}\n
\n
\n }\n @case ('Submitting') {\n Aanvraag wordt verwerkt…\n }\n @case ('Submitted') {\n Uw aanvraag tot herregistratie is ontvangen.\n
\n Opnieuw beginnen\n
\n }\n @case ('Failed') {\n Indienen mislukt: {{ failedError() }}\n
\n Opnieuw proberen\n
\n }\n }\n `,\n})\nexport class IntakeWizardComponent {\n private profile = inject(BigProfileStore);\n private store = createStore(initial, reduce);\n\n /** Optional seed so Storybook / the showcase can mount any state directly. */\n seed = input(initial);\n\n readonly jaNee = JA_NEE;\n readonly state = this.store.model;\n readonly dispatch = this.store.dispatch;\n\n private answering = computed(() => (this.state().tag === 'Answering' ? (this.state() as Extract) : null));\n /** Public so the showcase can render the live step list next to the wizard. */\n readonly steps = computed(() => visibleSteps(this.answering()?.answers ?? {}));\n protected cursor = computed(() => this.answering()?.cursor ?? 0);\n protected answers = computed(() => this.answering()?.answers ?? {});\n protected step = computed(() => this.steps()[Math.min(this.cursor(), this.steps().length - 1)]);\n protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract).error : ''));\n\n protected err = (k: StepId) => this.answering()?.errors[k] ?? '';\n protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value });\n\n constructor() {\n // An explicit seed (stories) wins; otherwise resume from localStorage.\n const seeded = this.seed();\n queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) }));\n // Persist only while answering; clear once the flow is done.\n effect(() => {\n const s = this.state();\n if (s.tag === 'Answering') localStorage.setItem(STORAGE_KEY, JSON.stringify(s));\n else localStorage.removeItem(STORAGE_KEY);\n });\n }\n\n private restore(): IntakeState | null {\n const raw = localStorage.getItem(STORAGE_KEY);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as IntakeState;\n } catch {\n return null; // ponytail: corrupt entry -> start fresh, no migration.\n }\n }\n\n onPrimary() {\n const s = this.state();\n if (s.tag !== 'Answering') return;\n this.dispatch(this.step() === 'review' ? { tag: 'Submit' } : { tag: 'Next' });\n this.runIfSubmitting();\n }\n\n onRetry() {\n this.dispatch({ tag: 'Retry' });\n this.runIfSubmitting();\n }\n\n restart() {\n this.dispatch({ tag: 'Seed', state: initial });\n }\n\n /** The effect: when we enter Submitting, call the backend, flip the optimistic\n cross-page flag, then dispatch the outcome (and commit/rollback). */\n private async runIfSubmitting() {\n const s = this.state();\n if (s.tag !== 'Submitting') return;\n this.profile.beginHerregistratie();\n const r = await submitIntake(s.data);\n if (r.ok) {\n this.dispatch({ tag: 'SubmitConfirmed' });\n this.profile.confirmHerregistratie();\n } else {\n this.dispatch({ tag: 'SubmitFailed', error: r.error });\n this.profile.rollbackHerregistratie();\n }\n }\n}\n", + "sourceCode": "import { Component, computed, effect, inject, input, untracked } from '@angular/core';\nimport { httpResource } from '@angular/common/http';\nimport { FormsModule } from '@angular/forms';\nimport { FormFieldComponent } from '@shared/ui/form-field/form-field.component';\nimport { TextInputComponent } from '@shared/ui/text-input/text-input.component';\nimport { RadioGroupComponent } from '@shared/ui/radio-group/radio-group.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { SpinnerComponent } from '@shared/ui/spinner/spinner.component';\nimport { createStore } from '@shared/application/store';\nimport { BigProfileStore } from '@registratie/application/big-profile.store';\nimport {\n IntakeState,\n IntakeMsg,\n Answers,\n StepId,\n initial,\n reduce,\n STEPS,\n lageUren,\n SCHOLING_THRESHOLD_DEFAULT,\n} from '@herregistratie/domain/intake.machine';\nimport { IntakePolicyDto } from '@herregistratie/contracts/intake-policy.dto';\nimport { submitIntake } from '@herregistratie/application/submit-intake';\n\nconst STORAGE_KEY = 'intake-v3'; // ponytail: bump the suffix if the persisted state shape changes; no migration.\nconst JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];\n\n/** Organism: a BRANCHING intake questionnaire. All state lives in one signal\n driven by the pure `reduce` (intake.machine.ts). Which step renders is derived\n from the answers via `visibleSteps`, never stored — so editing an earlier\n answer immediately changes the remaining steps. Answers are persisted to\n localStorage so a page reload keeps the user's progress. */\n@Component({\n selector: 'app-intake-wizard',\n imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, SpinnerComponent],\n template: `\n @switch (state().tag) {\n @case ('Answering') {\n

Stap {{ cursor() + 1 }} van {{ steps.length }}

\n
\n @switch (step()) {\n @case ('buitenland') {\n \n \n \n @if (answers().buitenlandGewerkt === 'ja') {\n \n \n \n \n \n \n }\n }\n @case ('werk') {\n \n \n \n @if (scholingZichtbaar()) {\n \n \n \n }\n @if (answers().scholingGevolgd === 'ja') {\n \n \n \n }\n }\n @case ('review') {\n Controleer uw antwoorden en dien de aanvraag in.\n
\n
Buiten NL gewerkt
{{ answers().buitenlandGewerkt ?? '—' }}
\n @if (answers().buitenlandGewerkt === 'ja') {\n
Land
{{ answers().land }}
\n
Buitenlandse uren
{{ answers().buitenlandseUren }}
\n }\n
Uren NL
{{ answers().uren }}
\n @if (scholingZichtbaar()) {\n
Aanvullende scholing
{{ answers().scholingGevolgd }}
\n }\n @if (answers().scholingGevolgd === 'ja') {\n
Nascholingspunten
{{ answers().punten }}
\n }\n
\n }\n }\n
\n @if (cursor() > 0) {\n Vorige\n }\n {{ step() === 'review' ? 'Aanvraag indienen' : 'Volgende' }}\n Annuleren\n
\n
\n }\n @case ('Submitting') {\n Aanvraag wordt verwerkt…\n }\n @case ('Submitted') {\n Uw aanvraag tot herregistratie is ontvangen.\n
\n Opnieuw beginnen\n
\n }\n @case ('Failed') {\n Indienen mislukt: {{ failedError() }}\n
\n Opnieuw proberen\n
\n }\n }\n `,\n})\nexport class IntakeWizardComponent {\n private profile = inject(BigProfileStore);\n private store = createStore(initial, reduce);\n\n // Server-owned policy: the scholing threshold is fetched, not hardcoded. The\n // backend stays the authority and re-validates on submit.\n private policyRes = httpResource(() => 'mock/intake-policy.json', {\n defaultValue: { scholingThreshold: SCHOLING_THRESHOLD_DEFAULT },\n });\n\n /** Optional seed so Storybook / the showcase can mount any state directly. */\n seed = input(initial);\n\n readonly jaNee = JA_NEE;\n readonly state = this.store.model;\n readonly dispatch = this.store.dispatch;\n\n private answering = computed(() => (this.state().tag === 'Answering' ? (this.state() as Extract) : null));\n /** Public so the showcase can render the (fixed) step list next to the wizard. */\n readonly steps = STEPS;\n protected cursor = computed(() => this.answering()?.cursor ?? 0);\n protected answers = computed(() => this.answering()?.answers ?? {});\n protected step = computed(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);\n /** Server-owned threshold from the policy endpoint (mirrored into machine state). */\n protected scholingThreshold = computed(() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT);\n /** Whether the inline scholing question is shown (and required) in the 'werk' step. */\n protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));\n protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract).error : ''));\n\n protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';\n protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value });\n\n constructor() {\n // An explicit seed (stories) wins; otherwise resume from localStorage.\n const seeded = this.seed();\n queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) }));\n // Persist only while answering; clear once the flow is done.\n effect(() => {\n const s = this.state();\n if (s.tag === 'Answering') localStorage.setItem(STORAGE_KEY, JSON.stringify(s));\n else localStorage.removeItem(STORAGE_KEY);\n });\n // Apply the server-owned threshold into machine state as it arrives. Track\n // only the policy value; untrack the dispatch (it reads the state signal\n // internally, which would otherwise make this effect loop on its own write).\n effect(() => {\n const scholingThreshold = this.policyRes.value().scholingThreshold;\n untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));\n });\n }\n\n private restore(): IntakeState | null {\n const raw = localStorage.getItem(STORAGE_KEY);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as IntakeState;\n } catch {\n return null; // ponytail: corrupt entry -> start fresh, no migration.\n }\n }\n\n onPrimary() {\n const s = this.state();\n if (s.tag !== 'Answering') return;\n this.dispatch(this.step() === 'review' ? { tag: 'Submit' } : { tag: 'Next' });\n this.runIfSubmitting();\n }\n\n onRetry() {\n this.dispatch({ tag: 'Retry' });\n this.runIfSubmitting();\n }\n\n restart() {\n this.dispatch({ tag: 'Seed', state: initial });\n }\n\n /** The effect: when we enter Submitting, call the backend, flip the optimistic\n cross-page flag, then dispatch the outcome (and commit/rollback). */\n private async runIfSubmitting() {\n const s = this.state();\n if (s.tag !== 'Submitting') return;\n this.profile.beginHerregistratie();\n const r = await submitIntake(s.data);\n if (r.ok) {\n this.dispatch({ tag: 'SubmitConfirmed' });\n this.profile.confirmHerregistratie();\n } else {\n this.dispatch({ tag: 'SubmitFailed', error: r.error });\n this.profile.rollbackHerregistratie();\n }\n }\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -3043,7 +3945,7 @@ "deprecated": false, "deprecationMessage": "", "args": [], - "line": 131 + "line": 148 }, "extends": [] }, @@ -3311,7 +4213,7 @@ }, { "name": "PageShellComponent", - "id": "component-PageShellComponent-e203d646c83aa0fbb8c86fd2fd55c90f86a13538dcec381ed765f73d2d49424819da915fe6cc8a91700f890997d9d2407b4376630d984875e90277fe97679453", + "id": "component-PageShellComponent-31358e27927ab9673066fef0e3dd43ab91c1d80ec87ecf99b63696dc8b050b0617895c809a2e76815b3ad8d4e0adfddd8ea0774c734bb7b08b0efa56bea0236c", "file": "src/app/shared/layout/page-shell/page-shell.component.ts", "encapsulation": [], "entryComponents": [], @@ -3321,9 +4223,9 @@ "selector": "app-page-shell", "styleUrls": [], "styles": [ - ":host{display:block}" + "\n :host{display:block}\n .body--narrow{max-inline-size:var(--app-form-narrow)}\n .crumb{margin-block-end:var(--rhc-space-max-xl)}\n .intro{margin-block-end:var(--rhc-space-max-2xl)}\n " ], - "template": "
\n @if (backLink()) {\n

← {{ backLabel() }}

\n }\n {{ heading() }}\n @if (intro()) {\n

{{ intro() }}

\n }\n \n
\n", + "template": "
\n @if (breadcrumb()) {\n \n }\n @if (backLink()) {\n

← {{ backLabel() }}

\n }\n {{ heading() }}\n @if (intro()) {\n

{{ intro() }}

\n }\n \n
\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -3336,7 +4238,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 29, + "line": 38, "required": false }, { @@ -3347,7 +4249,18 @@ "indexKey": "", "optional": false, "description": "", - "line": 28, + "line": 37, + "required": false + }, + { + "name": "breadcrumb", + "deprecated": false, + "deprecationMessage": "", + "type": "BreadcrumbItem[]", + "indexKey": "", + "optional": false, + "description": "", + "line": 40, "required": false }, { @@ -3358,7 +4271,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 26, + "line": 35, "required": true }, { @@ -3369,7 +4282,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 27, + "line": 36, "required": false }, { @@ -3381,7 +4294,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 30, + "line": 39, "required": false } ], @@ -3401,20 +4314,24 @@ { "name": "LinkComponent", "type": "component" + }, + { + "name": "BreadcrumbComponent", + "type": "component" } ], - "description": "

Template: standard page body — optional back-link, a heading, optional intro,\nand projected content. Rendered inside the persistent ShellComponent via the\nrouter outlet, so it owns only the content (not the header/footer chrome).

\n", - "rawdescription": "\nTemplate: standard page body — optional back-link, a heading, optional intro,\nand projected content. Rendered inside the persistent ShellComponent via the\nrouter outlet, so it owns only the content (not the header/footer chrome).", + "description": "

Template: standard page body — optional breadcrumb, optional back-link, a\nheading, optional intro, and projected content. Rendered inside the persistent\nShellComponent via the router outlet, so it owns only the content (not chrome).

\n", + "rawdescription": "\nTemplate: standard page body — optional breadcrumb, optional back-link, a\nheading, optional intro, and projected content. Rendered inside the persistent\nShellComponent via the router outlet, so it owns only the content (not chrome).", "type": "component", - "sourceCode": "import { Component, input } from '@angular/core';\nimport { HeadingComponent } from '@shared/ui/heading/heading.component';\nimport { LinkComponent } from '@shared/ui/link/link.component';\n\n/** Template: standard page body — optional back-link, a heading, optional intro,\n and projected content. Rendered inside the persistent ShellComponent via the\n router outlet, so it owns only the content (not the header/footer chrome). */\n@Component({\n selector: 'app-page-shell',\n imports: [HeadingComponent, LinkComponent],\n styles: [':host{display:block}'],\n template: `\n
\n @if (backLink()) {\n

← {{ backLabel() }}

\n }\n {{ heading() }}\n @if (intro()) {\n

{{ intro() }}

\n }\n \n
\n `,\n})\nexport class PageShellComponent {\n heading = input.required();\n intro = input();\n backLink = input();\n backLabel = input('Terug naar overzicht');\n width = input<'default' | 'narrow'>('default');\n}\n", + "sourceCode": "import { Component, input } from '@angular/core';\nimport { HeadingComponent } from '@shared/ui/heading/heading.component';\nimport { LinkComponent } from '@shared/ui/link/link.component';\nimport { BreadcrumbComponent, BreadcrumbItem } from '@shared/layout/breadcrumb/breadcrumb.component';\n\n/** Template: standard page body — optional breadcrumb, optional back-link, a\n heading, optional intro, and projected content. Rendered inside the persistent\n ShellComponent via the router outlet, so it owns only the content (not chrome). */\n@Component({\n selector: 'app-page-shell',\n imports: [HeadingComponent, LinkComponent, BreadcrumbComponent],\n styles: [`\n :host{display:block}\n .body--narrow{max-inline-size:var(--app-form-narrow)}\n .crumb{margin-block-end:var(--rhc-space-max-xl)}\n .intro{margin-block-end:var(--rhc-space-max-2xl)}\n `],\n template: `\n
\n @if (breadcrumb()) {\n \n }\n @if (backLink()) {\n

← {{ backLabel() }}

\n }\n {{ heading() }}\n @if (intro()) {\n

{{ intro() }}

\n }\n \n
\n `,\n})\nexport class PageShellComponent {\n heading = input.required();\n intro = input();\n backLink = input();\n backLabel = input('Terug naar overzicht');\n width = input<'default' | 'narrow'>('default');\n breadcrumb = input();\n}\n", "assetsDirs": [], "styleUrlsData": "", - "stylesData": ":host{display:block}\n", + "stylesData": "\n :host{display:block}\n .body--narrow{max-inline-size:var(--app-form-narrow)}\n .crumb{margin-block-end:var(--rhc-space-max-xl)}\n .intro{margin-block-end:var(--rhc-space-max-2xl)}\n \n", "extends": [] }, { "name": "RadioGroupComponent", - "id": "component-RadioGroupComponent-ac09f04d2bd5bb44d96600b5081778fdf878ad9137e373a0832855877706afbe37687f7937f568a890e541467637b067a6c8646dcb2005ab746f1a829d8d39a2", + "id": "component-RadioGroupComponent-a4c75390d3b62730cdd6f53a2ffcb3badde60f23c9104d57d8f8221d038f1dc96a229916ee28bbec4a54dd41e0e88c838ea46b35facfab0ddb5fea79fed37cc0", "file": "src/app/shared/ui/radio-group/radio-group.component.ts", "encapsulation": [], "entryComponents": [], @@ -3427,12 +4344,25 @@ ], "selector": "app-radio-group", "styleUrls": [], - "styles": [], - "template": "
\n @for (opt of options(); track opt.value) {\n \n }\n
\n", + "styles": [ + "\n .rhc-radio-option {\n display: flex;\n align-items: center;\n gap: var(--rhc-space-max-md);\n padding-block: var(--rhc-space-max-sm);\n }\n " + ], + "template": "\n @for (opt of options(); track opt.value) {\n \n }\n\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], "inputsClass": [ + { + "name": "invalid", + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 50, + "required": false + }, { "name": "name", "deprecated": false, @@ -3441,7 +4371,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 35, + "line": 49, "required": true }, { @@ -3452,7 +4382,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 34, + "line": 48, "required": true } ], @@ -3467,7 +4397,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 38 + "line": 53 }, { "name": "onChange", @@ -3478,7 +4408,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 39 + "line": 54 }, { "name": "onTouched", @@ -3489,7 +4419,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 40 + "line": 55 }, { "name": "value", @@ -3500,7 +4430,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 37 + "line": 52 } ], "methodsClass": [ @@ -3529,7 +4459,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 47, + "line": 62, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -3572,7 +4502,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 48, + "line": 63, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -3605,7 +4535,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 42, + "line": 57, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -3637,7 +4567,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 49, + "line": 64, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -3669,7 +4599,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 46, + "line": 61, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -3696,15 +4626,793 @@ "description": "

Atom: a radio group. Thin wrapper over the Utrecht/RHC radio CSS, wired as a\nform control so it works with ngModel just like the text-input atom.

\n", "rawdescription": "\nAtom: a radio group. Thin wrapper over the Utrecht/RHC radio CSS, wired as a\nform control so it works with ngModel just like the text-input atom.", "type": "component", - "sourceCode": "import { Component, forwardRef, input } from '@angular/core';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\n\nexport interface RadioOption {\n value: string;\n label: string;\n}\n\n/** Atom: a radio group. Thin wrapper over the Utrecht/RHC radio CSS, wired as a\n form control so it works with ngModel just like the text-input atom. */\n@Component({\n selector: 'app-radio-group',\n template: `\n
\n @for (opt of options(); track opt.value) {\n \n }\n
\n `,\n providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RadioGroupComponent), multi: true }],\n})\nexport class RadioGroupComponent implements ControlValueAccessor {\n options = input.required();\n name = input.required();\n\n value = '';\n disabled = false;\n onChange: (v: string) => void = () => {};\n onTouched: () => void = () => {};\n\n select(v: string) {\n this.value = v;\n this.onChange(v);\n }\n writeValue(v: string) { this.value = v ?? ''; }\n registerOnChange(fn: (v: string) => void) { this.onChange = fn; }\n registerOnTouched(fn: () => void) { this.onTouched = fn; }\n setDisabledState(d: boolean) { this.disabled = d; }\n}\n", + "sourceCode": "import { Component, forwardRef, input } from '@angular/core';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\n\nexport interface RadioOption {\n value: string;\n label: string;\n}\n\n/** Atom: a radio group. Thin wrapper over the Utrecht/RHC radio CSS, wired as a\n form control so it works with ngModel just like the text-input atom. */\n@Component({\n selector: 'app-radio-group',\n styles: [`\n .rhc-radio-option {\n display: flex;\n align-items: center;\n gap: var(--rhc-space-max-md);\n padding-block: var(--rhc-space-max-sm);\n }\n `],\n template: `\n \n @for (opt of options(); track opt.value) {\n \n }\n \n `,\n providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RadioGroupComponent), multi: true }],\n})\nexport class RadioGroupComponent implements ControlValueAccessor {\n options = input.required();\n name = input.required();\n invalid = input(false);\n\n value = '';\n disabled = false;\n onChange: (v: string) => void = () => {};\n onTouched: () => void = () => {};\n\n select(v: string) {\n this.value = v;\n this.onChange(v);\n }\n writeValue(v: string) { this.value = v ?? ''; }\n registerOnChange(fn: (v: string) => void) { this.onChange = fn; }\n registerOnTouched(fn: () => void) { this.onTouched = fn; }\n setDisabledState(d: boolean) { this.disabled = d; }\n}\n", "assetsDirs": [], "styleUrlsData": "", - "stylesData": "", + "stylesData": "\n .rhc-radio-option {\n display: flex;\n align-items: center;\n gap: var(--rhc-space-max-md);\n padding-block: var(--rhc-space-max-sm);\n }\n \n", "extends": [], "implements": [ "ControlValueAccessor" ] }, + { + "name": "RegistratiePage", + "id": "component-RegistratiePage-90adf901a35dfb1d68fde165ed8bbed0a6d8c111cc033d0dc464aa1787630e07ce0ca89d287ff0b9344571fff03b13caaac81e657c32dfdddbf699563a7d4cc3", + "file": "src/app/registratie/ui/registratie.page.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "app-registratie-page", + "styleUrls": [], + "styles": [], + "template": "\n \n In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het\n diploma waarmee u zich registreert, en een controle. Uw gegevens blijven bewaard\n als u de pagina herlaadt.\n \n
\n \n
\n\n", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [], + "outputsClass": [], + "propertiesClass": [], + "methodsClass": [], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [ + { + "name": "PageShellComponent", + "type": "component" + }, + { + "name": "AlertComponent", + "type": "component" + }, + { + "name": "RegistratieWizardComponent", + "type": "component" + } + ], + "description": "

Page: register in the BIG-register. Built entirely from existing building\nblocks (page shell + alert + the registratie-wizard organism).

\n", + "rawdescription": "\nPage: register in the BIG-register. Built entirely from existing building\nblocks (page shell + alert + the registratie-wizard organism).", + "type": "component", + "sourceCode": "import { Component } from '@angular/core';\nimport { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/registratie-wizard.component';\n\n/** Page: register in the BIG-register. Built entirely from existing building\n blocks (page shell + alert + the registratie-wizard organism). */\n@Component({\n selector: 'app-registratie-page',\n imports: [PageShellComponent, AlertComponent, RegistratieWizardComponent],\n template: `\n \n \n In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het\n diploma waarmee u zich registreert, en een controle. Uw gegevens blijven bewaard\n als u de pagina herlaadt.\n \n
\n \n
\n \n `,\n})\nexport class RegistratiePage {}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "", + "extends": [] + }, + { + "name": "RegistratieWizardComponent", + "id": "component-RegistratieWizardComponent-74dcf70dfe72a9b598de5861b8799d6a30f502bae7c2369ccee5759609bef6f4e6aaea03494efb1764da0a1d6437045516c78fb653c132564b7d49126f16fca5", + "file": "src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "app-registratie-wizard", + "styleUrls": [], + "styles": [], + "template": "@switch (state().tag) {\n @case ('Invullen') {\n \n

{{ stepTitle() }}

\n
\n @switch (step()) {\n @case ('adres') {\n @if (adresStatus() === 'laden') {\n \n } @else {\n @switch (adresStatus()) {\n @case ('gevonden') {\n Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.\n }\n @case ('geen') {\n We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.\n }\n @case ('fout') {\n We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in.\n }\n }\n \n \n \n \n \n \n \n \n \n \n \n \n @if (draft().correspondentie === 'email') {\n \n \n \n }\n }\n }\n @case ('beroep') {\n \n \n \n \n \n\n @if (handmatigActief()) {\n Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig beoordeeld.\n \n \n \n } @else if (draft().beroep) {\n
\n \n
\n }\n\n @for (q of actieveVragen($any(data)); track q.id) {\n \n @if (q.type === 'ja-nee') {\n \n } @else {\n \n }\n \n }\n
\n \n \n \n
\n }\n @case ('controle') {\n Controleer uw gegevens en dien de registratie in.\n
\n \n \n \n @if (draft().correspondentie === 'email') {\n \n }\n \n \n @for (item of samenvattingVragen(); track item.vraag) {\n \n }\n
\n
\n Adres wijzigen\n Diploma wijzigen\n
\n }\n }\n
\n @if (cursor() > 0) {\n Vorige\n }\n {{ step() === 'controle' ? 'Registratie indienen' : 'Volgende' }}\n Annuleren\n
\n \n }\n @case ('Indienen') {\n Uw registratie wordt verwerkt…\n }\n @case ('Ingediend') {\n Uw registratie is ontvangen. Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.\n
\n Nieuwe registratie starten\n
\n }\n @case ('Mislukt') {\n Het indienen is niet gelukt: {{ failedError() }}\n
\n Opnieuw proberen\n
\n }\n}\n", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [ + { + "name": "seed", + "defaultValue": "initial", + "deprecated": false, + "deprecationMessage": "", + "type": "RegistratieState", + "indexKey": "", + "optional": false, + "description": "

Optional seed so Storybook / tests can mount any state directly.

\n", + "line": 184, + "rawdescription": "\nOptional seed so Storybook / tests can mount any state directly.", + "required": false + } + ], + "outputsClass": [], + "propertiesClass": [ + { + "name": "actieveVragen", + "defaultValue": "() => {...}", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "

The policy questions that apply to the current choice (server-decided).

\n", + "line": 258, + "rawdescription": "\nThe policy questions that apply to the current choice (server-decided).", + "modifierKind": [ + 124 + ] + }, + { + "name": "adresHerkomstLabel", + "defaultValue": "computed(() => ({ brp: 'Automatisch uit de BRP', handmatig: 'Handmatig ingevoerd' }[this.draft().adresHerkomst ?? 'handmatig']))", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 207, + "modifierKind": [ + 124 + ] + }, + { + "name": "adresRes", + "defaultValue": "this.brp.adresResource()", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 180, + "modifierKind": [ + 124 + ] + }, + { + "name": "adresSamenvatting", + "defaultValue": "computed(() => {\n const d = this.draft();\n return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')].filter(Boolean).join(', ');\n })", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 202, + "modifierKind": [ + 124 + ] + }, + { + "name": "adresStatus", + "defaultValue": "computed<'laden' | 'gevonden' | 'geen' | 'fout'>(() => {\n const st = this.adresRes.status();\n if (st === 'loading' || st === 'reloading') return 'laden';\n if (st === 'error') return 'fout';\n const json = this.adresRes.value();\n const parsed = json !== undefined ? parseBrpAddress(json) : null;\n return parsed && parsed.ok && parsed.value.gevonden ? 'gevonden' : 'geen';\n })", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "

BRP lookup outcome. A failure or "geen adres" never blocks the wizard — both\nfall back to manual entry (PRD §7). 'geen' covers both no-address-found and a\nmalformed response; 'fout' is an unreachable BRP.

\n", + "line": 214, + "rawdescription": "\nBRP lookup outcome. A failure or \"geen adres\" never blocks the wizard — both\nfall back to manual entry (PRD §7). 'geen' covers both no-address-found and a\nmalformed response; 'fout' is an unreachable BRP.", + "modifierKind": [ + 124 + ] + }, + { + "name": "beroepOptions", + "defaultValue": "() => {...}", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 255, + "modifierKind": [ + 124 + ] + }, + { + "name": "brp", + "defaultValue": "inject(BrpAdapter)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 176, + "modifierKind": [ + 123 + ] + }, + { + "name": "correspondentieLabel", + "defaultValue": "computed(() => ({ email: 'Per e-mail', post: 'Per post' }[this.draft().correspondentie ?? 'post']))", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 208, + "modifierKind": [ + 124 + ] + }, + { + "name": "cursor", + "defaultValue": "computed(() => this.invullen()?.cursor ?? 0)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 196, + "modifierKind": [ + 124 + ] + }, + { + "name": "diplomaHerkomstLabel", + "defaultValue": "computed(() => ({ duo: 'Geverifieerd via DUO', handmatig: 'Handmatig ingevoerd (wordt beoordeeld)' }[this.draft().diplomaHerkomst ?? 'handmatig']))", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 209, + "modifierKind": [ + 124 + ] + }, + { + "name": "diplomaKeuze", + "defaultValue": "computed(() => (this.handmatigActief() ? HANDMATIG : this.draft().diplomaId ?? ''))", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "

The radio selection: a diploma id, or the "not listed" sentinel in manual mode.

\n", + "line": 248, + "rawdescription": "\nThe radio selection: a diploma id, or the \"not listed\" sentinel in manual mode.", + "modifierKind": [ + 124 + ] + }, + { + "name": "diplomaOptions", + "defaultValue": "() => {...}", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 250, + "modifierKind": [ + 124 + ] + }, + { + "name": "diplomasRes", + "defaultValue": "this.duo.diplomasResource()", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 181, + "modifierKind": [ + 123 + ] + }, + { + "name": "dispatch", + "defaultValue": "this.store.dispatch", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 190, + "modifierKind": [ + 148 + ] + }, + { + "name": "draft", + "defaultValue": "computed(() => this.invullen()?.draft ?? { antwoorden: {} })", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 197, + "modifierKind": [ + 124 + ] + }, + { + "name": "duo", + "defaultValue": "inject(DuoAdapter)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 177, + "modifierKind": [ + 123 + ] + }, + { + "name": "duoData", + "defaultValue": "computed(() => {\n const rd = this.lookupRd();\n return rd.tag === 'Success' ? rd.value : null;\n })", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "

Parsed lookup as a plain value (or null) — used outside the beroep step (the\ncontrole summary) where the template variable isn't in scope.

\n", + "line": 233, + "rawdescription": "\nParsed lookup as a plain value (or null) — used outside the beroep step (the\ncontrole summary) where the template variable isn't in scope.", + "modifierKind": [ + 123 + ] + }, + { + "name": "err", + "defaultValue": "() => {...}", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 240, + "modifierKind": [ + 124 + ] + }, + { + "name": "failedError", + "defaultValue": "computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract).error : ''))", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 201, + "modifierKind": [ + 124 + ] + }, + { + "name": "handmatigActief", + "defaultValue": "computed(() => this.draft().diplomaHerkomst === 'handmatig')", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "

True while the user is entering a diploma manually (not in the DUO list).

\n", + "line": 246, + "rawdescription": "\nTrue while the user is entering a diploma manually (not in the DUO list).", + "modifierKind": [ + 124 + ] + }, + { + "name": "invullen", + "defaultValue": "computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract) : null))", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 195, + "modifierKind": [ + 123 + ] + }, + { + "name": "jaNee", + "defaultValue": "JA_NEE", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 238, + "modifierKind": [ + 148 + ] + }, + { + "name": "kanalen", + "defaultValue": "KANALEN", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 186, + "modifierKind": [ + 148 + ] + }, + { + "name": "lookupRd", + "defaultValue": "computed>(() => {\n const rd = fromResource(this.diplomasRes);\n if (rd.tag !== 'Success') return rd;\n const parsed = parseDuoLookup(rd.value);\n return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };\n })", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "

The DUO lookup (diplomas + manual fallback), validated at the trust boundary.

\n", + "line": 224, + "rawdescription": "\nThe DUO lookup (diplomas + manual fallback), validated at the trust boundary.", + "modifierKind": [ + 124 + ] + }, + { + "name": "referentie", + "defaultValue": "computed(() => (this.state().tag === 'Ingediend' ? (this.state() as Extract).referentie : ''))", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 200, + "modifierKind": [ + 124 + ] + }, + { + "name": "samenvattingVragen", + "defaultValue": "computed(() => {\n const data = this.duoData();\n const d = this.draft();\n if (!data) return [] as { vraag: string; antwoord: string }[];\n const alle = [...data.diplomas.flatMap((x) => x.policyQuestions), ...data.handmatig.policyQuestions];\n return (d.vraagIds ?? []).map((id) => ({ vraag: alle.find((q) => q.id === id)?.vraag ?? id, antwoord: d.antwoorden[id] ?? '' }));\n })", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "

Answered policy questions for the controle summary (question text + answer).

\n", + "line": 264, + "rawdescription": "\nAnswered policy questions for the controle summary (question text + answer).", + "modifierKind": [ + 124 + ] + }, + { + "name": "set", + "defaultValue": "() => {...}", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 242, + "modifierKind": [ + 124 + ] + }, + { + "name": "setKanaal", + "defaultValue": "() => {...}", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 243, + "modifierKind": [ + 124 + ] + }, + { + "name": "state", + "defaultValue": "this.store.model", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 189, + "modifierKind": [ + 148 + ] + }, + { + "name": "step", + "defaultValue": "computed(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)])", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 198, + "modifierKind": [ + 124 + ] + }, + { + "name": "stepHeading", + "defaultValue": "viewChild>('stepHeading')", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "

Focus target: the step heading receives focus on step change (a11y).

\n", + "line": 193, + "rawdescription": "\nFocus target: the step heading receives focus on step change (a11y).", + "modifierKind": [ + 123 + ] + }, + { + "name": "stepLabels", + "defaultValue": "['Adres', 'Beroep', 'Controle']", + "deprecated": false, + "deprecationMessage": "", + "type": "[]", + "indexKey": "", + "optional": false, + "description": "", + "line": 187, + "modifierKind": [ + 148 + ] + }, + { + "name": "stepTitle", + "defaultValue": "computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)])", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 199, + "modifierKind": [ + 124 + ] + }, + { + "name": "stepTitles", + "defaultValue": "['Adres en correspondentievoorkeur', 'Beroep op basis van uw diploma', 'Controleren en indienen']", + "deprecated": false, + "deprecationMessage": "", + "type": "[]", + "indexKey": "", + "optional": false, + "description": "", + "line": 188, + "modifierKind": [ + 123 + ] + }, + { + "name": "store", + "defaultValue": "createStore(initial, reduce)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 178, + "modifierKind": [ + 123 + ] + }, + { + "name": "vraagErr", + "defaultValue": "() => {...}", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 241, + "modifierKind": [ + 124 + ] + } + ], + "methodsClass": [ + { + "name": "onDiplomaKeuze", + "args": [ + { + "name": "data", + "type": "DuoLookupDto", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "id", + "type": "string", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 272, + "deprecated": false, + "deprecationMessage": "", + "modifierKind": [ + 124 + ], + "jsdoctags": [ + { + "name": "data", + "type": "DuoLookupDto", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "id", + "type": "string", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "onPrimary", + "args": [], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 332, + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "onRetry", + "args": [], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 339, + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "restart", + "args": [], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 346, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\nReset the wizard to a fresh start. Reload the BRP lookup so the address\nre-prefills, keeping the form and the \"vooraf ingevuld\" note consistent.", + "description": "

Reset the wizard to a fresh start. Reload the BRP lookup so the address\nre-prefills, keeping the form and the "vooraf ingevuld" note consistent.

\n" + }, + { + "name": "restore", + "args": [], + "optional": false, + "returnType": "RegistratieState | null", + "typeParameters": [], + "line": 322, + "deprecated": false, + "deprecationMessage": "", + "modifierKind": [ + 123 + ] + }, + { + "name": "runIfIndienen", + "args": [], + "optional": false, + "returnType": "any", + "typeParameters": [], + "line": 353, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\nThe effect: when we enter Indienen, call the backend, then dispatch the\noutcome (success carries the confirmation reference).", + "description": "

The effect: when we enter Indienen, call the backend, then dispatch the\noutcome (success carries the confirmation reference).

\n", + "modifierKind": [ + 123, + 134 + ] + } + ], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [ + { + "name": "FormsModule", + "type": "module" + }, + { + "name": "FormFieldComponent", + "type": "component" + }, + { + "name": "TextInputComponent", + "type": "component" + }, + { + "name": "RadioGroupComponent", + "type": "component" + }, + { + "name": "ButtonComponent", + "type": "component" + }, + { + "name": "AlertComponent", + "type": "component" + }, + { + "name": "SpinnerComponent", + "type": "component" + }, + { + "name": "SkeletonComponent", + "type": "component" + }, + { + "name": "DataRowComponent", + "type": "component" + }, + { + "name": "StepperComponent", + "type": "component" + }, + { + "name": "ASYNC" + } + ], + "description": "

Organism: the BIG-registration wizard. All state lives in one signal driven by\nthe pure reduce (registratie-wizard.machine.ts). The BRP address prefills the\ndraft via an effect; the DUO diploma list renders through ; choosing\na diploma reveals its server-derived beroep. The draft is persisted to\nlocalStorage so a reload keeps the user's progress. Built entirely from existing\natoms/molecules.

\n", + "rawdescription": "\nOrganism: the BIG-registration wizard. All state lives in one signal driven by\nthe pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the\ndraft via an effect; the DUO diploma list renders through ; choosing\na diploma reveals its server-derived beroep. The draft is persisted to\nlocalStorage so a reload keeps the user's progress. Built entirely from existing\natoms/molecules.", + "type": "component", + "sourceCode": "import { Component, ElementRef, computed, effect, inject, input, untracked, viewChild } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { FormFieldComponent } from '@shared/ui/form-field/form-field.component';\nimport { TextInputComponent } from '@shared/ui/text-input/text-input.component';\nimport { RadioGroupComponent } from '@shared/ui/radio-group/radio-group.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport { SpinnerComponent } from '@shared/ui/spinner/spinner.component';\nimport { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';\nimport { DataRowComponent } from '@shared/ui/data-row/data-row.component';\nimport { StepperComponent } from '@shared/ui/stepper/stepper.component';\nimport { ASYNC } from '@shared/ui/async/async.component';\nimport { createStore } from '@shared/application/store';\nimport { RemoteData, fromResource } from '@shared/application/remote-data';\nimport { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';\nimport { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';\nimport { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';\nimport {\n RegistratieState,\n RegistratieMsg,\n Draft,\n DraftField,\n Correspondentie,\n StepId,\n initial,\n reduce,\n STEPS,\n} from '@registratie/domain/registratie-wizard.machine';\nimport { submitRegistratie } from '@registratie/application/submit-registratie';\n\nconst STORAGE_KEY = 'registratie-v1'; // ponytail: bump the suffix if the persisted shape changes; no migration.\nconst KANALEN = [{ value: 'email', label: 'E-mail' }, { value: 'post', label: 'Post' }];\nconst JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];\nconst HANDMATIG = '__handmatig__'; // sentinel option: \"my diploma isn't listed\"\n\n/** Organism: the BIG-registration wizard. All state lives in one signal driven by\n the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the\n draft via an effect; the DUO diploma list renders through ; choosing\n a diploma reveals its server-derived beroep. The draft is persisted to\n localStorage so a reload keeps the user's progress. Built entirely from existing\n atoms/molecules. */\n@Component({\n selector: 'app-registratie-wizard',\n imports: [\n FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,\n AlertComponent, SpinnerComponent, SkeletonComponent, DataRowComponent, StepperComponent, ...ASYNC,\n ],\n template: `\n @switch (state().tag) {\n @case ('Invullen') {\n \n

{{ stepTitle() }}

\n
\n @switch (step()) {\n @case ('adres') {\n @if (adresStatus() === 'laden') {\n \n } @else {\n @switch (adresStatus()) {\n @case ('gevonden') {\n Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.\n }\n @case ('geen') {\n We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.\n }\n @case ('fout') {\n We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in.\n }\n }\n \n \n \n \n \n \n \n \n \n \n \n \n @if (draft().correspondentie === 'email') {\n \n \n \n }\n }\n }\n @case ('beroep') {\n \n \n \n \n \n\n @if (handmatigActief()) {\n Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig beoordeeld.\n \n \n \n } @else if (draft().beroep) {\n
\n \n
\n }\n\n @for (q of actieveVragen($any(data)); track q.id) {\n \n @if (q.type === 'ja-nee') {\n \n } @else {\n \n }\n \n }\n
\n \n \n \n
\n }\n @case ('controle') {\n Controleer uw gegevens en dien de registratie in.\n
\n \n \n \n @if (draft().correspondentie === 'email') {\n \n }\n \n \n @for (item of samenvattingVragen(); track item.vraag) {\n \n }\n
\n
\n Adres wijzigen\n Diploma wijzigen\n
\n }\n }\n
\n @if (cursor() > 0) {\n Vorige\n }\n {{ step() === 'controle' ? 'Registratie indienen' : 'Volgende' }}\n Annuleren\n
\n \n }\n @case ('Indienen') {\n Uw registratie wordt verwerkt…\n }\n @case ('Ingediend') {\n Uw registratie is ontvangen. Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.\n
\n Nieuwe registratie starten\n
\n }\n @case ('Mislukt') {\n Het indienen is niet gelukt: {{ failedError() }}\n
\n Opnieuw proberen\n
\n }\n }\n `,\n})\nexport class RegistratieWizardComponent {\n private brp = inject(BrpAdapter);\n private duo = inject(DuoAdapter);\n private store = createStore(initial, reduce);\n\n protected adresRes = this.brp.adresResource();\n private diplomasRes = this.duo.diplomasResource();\n\n /** Optional seed so Storybook / tests can mount any state directly. */\n seed = input(initial);\n\n readonly kanalen = KANALEN;\n readonly stepLabels = ['Adres', 'Beroep', 'Controle']; // short labels for the stepper\n private stepTitles = ['Adres en correspondentievoorkeur', 'Beroep op basis van uw diploma', 'Controleren en indienen'];\n readonly state = this.store.model;\n readonly dispatch = this.store.dispatch;\n\n /** Focus target: the step heading receives focus on step change (a11y). */\n private stepHeading = viewChild>('stepHeading');\n\n private invullen = computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract) : null));\n protected cursor = computed(() => this.invullen()?.cursor ?? 0);\n protected draft = computed(() => this.invullen()?.draft ?? { antwoorden: {} });\n protected step = computed(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);\n protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]);\n protected referentie = computed(() => (this.state().tag === 'Ingediend' ? (this.state() as Extract).referentie : ''));\n protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract).error : ''));\n protected adresSamenvatting = computed(() => {\n const d = this.draft();\n return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')].filter(Boolean).join(', ');\n });\n // Readable labels for the controle summary (instead of raw enum values).\n protected adresHerkomstLabel = computed(() => ({ brp: 'Automatisch uit de BRP', handmatig: 'Handmatig ingevoerd' }[this.draft().adresHerkomst ?? 'handmatig']));\n protected correspondentieLabel = computed(() => ({ email: 'Per e-mail', post: 'Per post' }[this.draft().correspondentie ?? 'post']));\n protected diplomaHerkomstLabel = computed(() => ({ duo: 'Geverifieerd via DUO', handmatig: 'Handmatig ingevoerd (wordt beoordeeld)' }[this.draft().diplomaHerkomst ?? 'handmatig']));\n\n /** BRP lookup outcome. A failure or \"geen adres\" never blocks the wizard — both\n fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a\n malformed response; 'fout' is an unreachable BRP. */\n protected adresStatus = computed<'laden' | 'gevonden' | 'geen' | 'fout'>(() => {\n const st = this.adresRes.status();\n if (st === 'loading' || st === 'reloading') return 'laden';\n if (st === 'error') return 'fout';\n const json = this.adresRes.value();\n const parsed = json !== undefined ? parseBrpAddress(json) : null;\n return parsed && parsed.ok && parsed.value.gevonden ? 'gevonden' : 'geen';\n });\n\n /** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */\n protected lookupRd = computed>(() => {\n const rd = fromResource(this.diplomasRes);\n if (rd.tag !== 'Success') return rd;\n const parsed = parseDuoLookup(rd.value);\n return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };\n });\n\n /** Parsed lookup as a plain value (or null) — used outside the beroep step (the\n controle summary) where the template variable isn't in scope. */\n private duoData = computed(() => {\n const rd = this.lookupRd();\n return rd.tag === 'Success' ? rd.value : null;\n });\n\n readonly jaNee = JA_NEE;\n\n protected err = (k: DraftField | 'correspondentie' | 'diploma') => this.invullen()?.errors[k] ?? '';\n protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? '';\n protected set = (key: DraftField, value: string) => this.dispatch({ tag: 'SetField', key, value });\n protected setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });\n\n /** True while the user is entering a diploma manually (not in the DUO list). */\n protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig');\n /** The radio selection: a diploma id, or the \"not listed\" sentinel in manual mode. */\n protected diplomaKeuze = computed(() => (this.handmatigActief() ? HANDMATIG : this.draft().diplomaId ?? ''));\n\n protected diplomaOptions = (data: DuoLookupDto) => [\n ...data.diplomas.map((d) => ({ value: d.id, label: `${d.naam} — ${d.instelling} (${d.jaar})` })),\n { value: HANDMATIG, label: 'Mijn diploma staat er niet bij' },\n ];\n\n protected beroepOptions = (data: DuoLookupDto) => data.handmatig.beroepen.map((b) => ({ value: b, label: b }));\n\n /** The policy questions that apply to the current choice (server-decided). */\n protected actieveVragen = (data: DuoLookupDto): PolicyQuestionDto[] => {\n if (this.handmatigActief()) return data.handmatig.policyQuestions;\n return data.diplomas.find((d) => d.id === this.draft().diplomaId)?.policyQuestions ?? [];\n };\n\n /** Answered policy questions for the controle summary (question text + answer). */\n protected samenvattingVragen = computed(() => {\n const data = this.duoData();\n const d = this.draft();\n if (!data) return [] as { vraag: string; antwoord: string }[];\n const alle = [...data.diplomas.flatMap((x) => x.policyQuestions), ...data.handmatig.policyQuestions];\n return (d.vraagIds ?? []).map((id) => ({ vraag: alle.find((q) => q.id === id)?.vraag ?? id, antwoord: d.antwoorden[id] ?? '' }));\n });\n\n protected onDiplomaKeuze(data: DuoLookupDto, id: string) {\n if (id === HANDMATIG) {\n this.dispatch({ tag: 'KiesHandmatig', vraagIds: data.handmatig.policyQuestions.map((q) => q.id) });\n return;\n }\n const d = data.diplomas.find((x) => x.id === id);\n if (d) this.dispatch({ tag: 'KiesDiploma', diplomaId: d.id, beroep: d.beroep, vraagIds: d.policyQuestions.map((q) => q.id) });\n }\n\n constructor() {\n // An explicit seed (stories) wins; otherwise resume from localStorage.\n const seeded = this.seed();\n queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) }));\n // Persist only while filling in; clear once the flow is done.\n effect(() => {\n const s = this.state();\n if (s.tag === 'Invullen') localStorage.setItem(STORAGE_KEY, JSON.stringify(s));\n else localStorage.removeItem(STORAGE_KEY);\n });\n // Prefill the address from the BRP lookup as it arrives. Track only the resource\n // value; untrack the dispatch (it reads the state signal, which would otherwise\n // make this effect loop on its own write). Don't clobber edits/restored data.\n effect(() => {\n const json = this.adresRes.value();\n if (!json) return;\n const parsed = parseBrpAddress(json);\n untracked(() => {\n const s = this.state();\n if (s.tag !== 'Invullen' || s.draft.straat) return;\n // ponytail: slice 2 handles gevonden === false -> manual entry stays handmatig.\n if (parsed.ok && parsed.value.gevonden && parsed.value.adres) {\n const a = parsed.value.adres;\n this.dispatch({ tag: 'PrefillAdres', straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats });\n }\n });\n });\n // A11y: move focus to the step heading when the STEP changes (depends only on\n // cursor(), which is value-stable across keystrokes — so typing never steals\n // focus). Skip the first run so we don't grab focus on initial load.\n let firstStep = true;\n effect(() => {\n this.cursor();\n if (firstStep) { firstStep = false; return; }\n untracked(() => {\n if (this.state().tag !== 'Invullen') return;\n queueMicrotask(() => this.stepHeading()?.nativeElement.focus());\n });\n });\n }\n\n private restore(): RegistratieState | null {\n const raw = localStorage.getItem(STORAGE_KEY);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as RegistratieState;\n } catch {\n return null; // ponytail: corrupt entry -> start fresh, no migration.\n }\n }\n\n onPrimary() {\n const s = this.state();\n if (s.tag !== 'Invullen') return;\n this.dispatch(this.step() === 'controle' ? { tag: 'Submit' } : { tag: 'Next' });\n this.runIfIndienen();\n }\n\n onRetry() {\n this.dispatch({ tag: 'Retry' });\n this.runIfIndienen();\n }\n\n /** Reset the wizard to a fresh start. Reload the BRP lookup so the address\n re-prefills, keeping the form and the \"vooraf ingevuld\" note consistent. */\n restart() {\n this.dispatch({ tag: 'Seed', state: initial });\n this.adresRes.reload();\n }\n\n /** The effect: when we enter Indienen, call the backend, then dispatch the\n outcome (success carries the confirmation reference). */\n private async runIfIndienen() {\n const s = this.state();\n if (s.tag !== 'Indienen') return;\n const r = await submitRegistratie(s.data);\n if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });\n else this.dispatch({ tag: 'SubmitFailed', error: r.error });\n }\n}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "", + "constructorObj": { + "name": "constructor", + "description": "", + "deprecated": false, + "deprecationMessage": "", + "args": [], + "line": 279 + }, + "extends": [] + }, { "name": "RegistrationDetailPage", "id": "component-RegistrationDetailPage-5aa270b47adfa8bd0334225a51df3a1656468e109d14ca9967660455a734884f4fb358b0f2a6c97e19b4df859f4c5e9773d5dd9c728dbcd785a3d43a60472811", @@ -3933,7 +5641,7 @@ }, { "name": "ShellComponent", - "id": "component-ShellComponent-a66788510e6c68711bf080318372cd2c0d790797488b8ff39c17623325904a91e1ac98f1d49d6f6f81f416cc64206126a4d0f1ce50d66403bdd3fb6b6f924279", + "id": "component-ShellComponent-9395255b45520c41ee6d43e2baf02d475e80d4ba8451334ef421b7debb14942f207c8e905bdac73b80b2d2b6f1b26b8ec7b341e2504bf31f8c0c326c59dbadac", "file": "src/app/shared/layout/shell/shell.component.ts", "encapsulation": [], "entryComponents": [], @@ -3943,9 +5651,9 @@ "selector": "app-shell", "styleUrls": [], "styles": [ - ":host{display:block}" + "\n :host{display:block}\n .skip{position:absolute;left:var(--app-skip-link-offset)}\n .layout{min-block-size:100vh;align-items:stretch}\n .main{flex:1;inline-size:100%;box-sizing:border-box}\n .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}\n " ], - "template": "Naar de inhoud\n
\n \n
\n
\n \n
\n
\n \n
\n", + "template": "Naar de inhoud\n
\n \n
\n
\n \n
\n
\n \n
\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -3974,15 +5682,15 @@ "description": "

Template: persistent app chrome. Header + footer mount once; only the routed\ncontent inside changes (and cross-fades — see styles.scss).

\n", "rawdescription": "\nTemplate: persistent app chrome. Header + footer mount once; only the routed\ncontent inside changes (and cross-fades — see styles.scss).", "type": "component", - "sourceCode": "import { Component } from '@angular/core';\nimport { RouterOutlet } from '@angular/router';\nimport { SiteHeaderComponent } from '@shared/layout/site-header/site-header.component';\nimport { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.component';\n\n/** Template: persistent app chrome. Header + footer mount once; only the routed\n content inside changes (and cross-fades — see styles.scss). */\n@Component({\n selector: 'app-shell',\n imports: [RouterOutlet, SiteHeaderComponent, SiteFooterComponent],\n styles: [':host{display:block}'],\n template: `\n Naar de inhoud\n
\n \n
\n
\n \n
\n
\n \n
\n `,\n})\nexport class ShellComponent {}\n", + "sourceCode": "import { Component } from '@angular/core';\nimport { RouterOutlet } from '@angular/router';\nimport { SiteHeaderComponent } from '@shared/layout/site-header/site-header.component';\nimport { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.component';\n\n/** Template: persistent app chrome. Header + footer mount once; only the routed\n content inside changes (and cross-fades — see styles.scss). */\n@Component({\n selector: 'app-shell',\n imports: [RouterOutlet, SiteHeaderComponent, SiteFooterComponent],\n styles: [`\n :host{display:block}\n .skip{position:absolute;left:var(--app-skip-link-offset)}\n .layout{min-block-size:100vh;align-items:stretch}\n .main{flex:1;inline-size:100%;box-sizing:border-box}\n .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}\n `],\n template: `\n Naar de inhoud\n
\n \n
\n
\n \n
\n
\n \n
\n `,\n})\nexport class ShellComponent {}\n", "assetsDirs": [], "styleUrlsData": "", - "stylesData": ":host{display:block}\n", + "stylesData": "\n :host{display:block}\n .skip{position:absolute;left:var(--app-skip-link-offset)}\n .layout{min-block-size:100vh;align-items:stretch}\n .main{flex:1;inline-size:100%;box-sizing:border-box}\n .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}\n \n", "extends": [] }, { "name": "SiteFooterComponent", - "id": "component-SiteFooterComponent-90391287c6e8f3ad80c034a7a6fdabdd0514fd9c0dcf549a8e4a2d3bc3d885fcd5a45a2eeeb24fa8a4bbcf2cfe865093d712765d9d1ed11b4bfca26d1fb35153", + "id": "component-SiteFooterComponent-57d90cda3179b0a39d4bc90cc725913dfa228828585709666584e469de964abc65dbf2b93f2155fc42fc071f61db4649924a05b2fc8e3af31a53fa8143744162", "file": "src/app/shared/layout/site-footer/site-footer.component.ts", "encapsulation": [], "entryComponents": [], @@ -3992,9 +5700,9 @@ "selector": "app-site-footer", "styleUrls": [], "styles": [ - ":host{display:block}" + "\n :host{display:block}\n .bar{background:var(--rhc-color-donkerblauw-700);color:var(--rhc-color-wit);margin-block-start:var(--rhc-space-max-5xl);inline-size:100%}\n .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}\n .end{margin-inline-start:auto}\n " ], - "template": "
\n
\n BIG-register\n CIBG — Ministerie van Volksgezondheid, Welzijn en Sport\n Demo / POC — geen echte gegevens\n
\n
\n", + "template": "
\n
\n BIG-register\n CIBG — Ministerie van Volksgezondheid, Welzijn en Sport\n Demo / POC — geen echte gegevens\n
\n
\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -4011,15 +5719,15 @@ "description": "

Organism: site footer.

\n", "rawdescription": "\nOrganism: site footer.", "type": "component", - "sourceCode": "import { Component } from '@angular/core';\n\n/** Organism: site footer. */\n@Component({\n selector: 'app-site-footer',\n styles: [':host{display:block}'],\n template: `\n
\n
\n BIG-register\n CIBG — Ministerie van Volksgezondheid, Welzijn en Sport\n Demo / POC — geen echte gegevens\n
\n
\n `,\n})\nexport class SiteFooterComponent {}\n", + "sourceCode": "import { Component } from '@angular/core';\n\n/** Organism: site footer. */\n@Component({\n selector: 'app-site-footer',\n styles: [`\n :host{display:block}\n .bar{background:var(--rhc-color-donkerblauw-700);color:var(--rhc-color-wit);margin-block-start:var(--rhc-space-max-5xl);inline-size:100%}\n .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}\n .end{margin-inline-start:auto}\n `],\n template: `\n
\n
\n BIG-register\n CIBG — Ministerie van Volksgezondheid, Welzijn en Sport\n Demo / POC — geen echte gegevens\n
\n
\n `,\n})\nexport class SiteFooterComponent {}\n", "assetsDirs": [], "styleUrlsData": "", - "stylesData": ":host{display:block}\n", + "stylesData": "\n :host{display:block}\n .bar{background:var(--rhc-color-donkerblauw-700);color:var(--rhc-color-wit);margin-block-start:var(--rhc-space-max-5xl);inline-size:100%}\n .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}\n .end{margin-inline-start:auto}\n \n", "extends": [] }, { "name": "SiteHeaderComponent", - "id": "component-SiteHeaderComponent-710f1c109f26ae5f1888aa77ac682c58c9534fbba74aeae04b903e169f674c1ce04b8ae9e379a936c8ddef16e53c500e93d132021bcdd4ef743a601e05808f66", + "id": "component-SiteHeaderComponent-f5e80a4225213863b1448920f4a6300aec28b9ca4de6a6a0acad2e69a6aa0359cdd8194aa71fd6f8fcff7e0200b6715201f09241ac3bc32314c5af3392a4639e", "file": "src/app/shared/layout/site-header/site-header.component.ts", "encapsulation": [], "entryComponents": [], @@ -4029,9 +5737,9 @@ "selector": "app-site-header", "styleUrls": [], "styles": [ - ":host{display:block}" + "\n :host{display:block}\n .bar{background:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit);inline-size:100%}\n .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}\n .brand{display:flex;align-items:center;gap:var(--rhc-space-max-lg);color:inherit;text-decoration:none}\n .mark{display:inline-block;inline-size:var(--rhc-space-max-md);block-size:var(--rhc-space-max-4xl);background:var(--rhc-color-wit)}\n .name{font-weight:var(--rhc-text-font-weight-bold);line-height:var(--rhc-text-line-height-sm)}\n .sub{font-weight:var(--rhc-text-font-weight-regular);font-size:var(--rhc-text-font-size-sm)}\n .portal{margin-inline-start:auto;font-weight:var(--rhc-text-font-weight-semi-bold)}\n " ], - "template": "
\n \n
\n", + "template": "
\n \n
\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -4044,7 +5752,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 27, + "line": 36, "required": false } ], @@ -4064,15 +5772,15 @@ "description": "

Organism: site header with Rijksoverheid-style wordmark + title.\nponytail: text wordmark instead of the licensed Rijksoverheid logo.

\n", "rawdescription": "\nOrganism: site header with Rijksoverheid-style wordmark + title.\nponytail: text wordmark instead of the licensed Rijksoverheid logo.", "type": "component", - "sourceCode": "import { Component, input } from '@angular/core';\nimport { RouterLink } from '@angular/router';\n\n/** Organism: site header with Rijksoverheid-style wordmark + title.\n ponytail: text wordmark instead of the licensed Rijksoverheid logo. */\n@Component({\n selector: 'app-site-header',\n imports: [RouterLink],\n // :host display:block so the full-bleed bar fills the flex column (custom\n // elements default to display:inline, which collapsed the bar to its content).\n styles: [':host{display:block}'],\n template: `\n
\n \n
\n `,\n})\nexport class SiteHeaderComponent {\n subtitle = input('Mijn omgeving');\n}\n", + "sourceCode": "import { Component, input } from '@angular/core';\nimport { RouterLink } from '@angular/router';\n\n/** Organism: site header with Rijksoverheid-style wordmark + title.\n ponytail: text wordmark instead of the licensed Rijksoverheid logo. */\n@Component({\n selector: 'app-site-header',\n imports: [RouterLink],\n // :host display:block so the full-bleed bar fills the flex column (custom\n // elements default to display:inline, which collapsed the bar to its content).\n styles: [`\n :host{display:block}\n .bar{background:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit);inline-size:100%}\n .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}\n .brand{display:flex;align-items:center;gap:var(--rhc-space-max-lg);color:inherit;text-decoration:none}\n .mark{display:inline-block;inline-size:var(--rhc-space-max-md);block-size:var(--rhc-space-max-4xl);background:var(--rhc-color-wit)}\n .name{font-weight:var(--rhc-text-font-weight-bold);line-height:var(--rhc-text-line-height-sm)}\n .sub{font-weight:var(--rhc-text-font-weight-regular);font-size:var(--rhc-text-font-size-sm)}\n .portal{margin-inline-start:auto;font-weight:var(--rhc-text-font-weight-semi-bold)}\n `],\n template: `\n
\n \n
\n `,\n})\nexport class SiteHeaderComponent {\n subtitle = input('Mijn omgeving');\n}\n", "assetsDirs": [], "styleUrlsData": "", - "stylesData": ":host{display:block}\n", + "stylesData": "\n :host{display:block}\n .bar{background:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit);inline-size:100%}\n .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}\n .brand{display:flex;align-items:center;gap:var(--rhc-space-max-lg);color:inherit;text-decoration:none}\n .mark{display:inline-block;inline-size:var(--rhc-space-max-md);block-size:var(--rhc-space-max-4xl);background:var(--rhc-color-wit)}\n .name{font-weight:var(--rhc-text-font-weight-bold);line-height:var(--rhc-text-line-height-sm)}\n .sub{font-weight:var(--rhc-text-font-weight-regular);font-size:var(--rhc-text-font-size-sm)}\n .portal{margin-inline-start:auto;font-weight:var(--rhc-text-font-weight-semi-bold)}\n \n", "extends": [] }, { "name": "SkeletonComponent", - "id": "component-SkeletonComponent-608d7384a7cd5705153c042fd961a63d69e4f61409276c2aec354b33db4199192d161fa81305090c262e18ab0a019913f469e85603ccefa6fba5c0dbc40d4f72", + "id": "component-SkeletonComponent-d2768b972782fa90a72f8142960ff79ff9f04fc345e50775c982bcb5f646c5b14d64c013ee0cd03616032c9789e8d6cb3829cee2d296fb2c573d693d0d419fc7", "file": "src/app/shared/ui/skeleton/skeleton.component.ts", "encapsulation": [], "entryComponents": [], @@ -4082,9 +5790,9 @@ "selector": "app-skeleton", "styleUrls": [], "styles": [ - "\n :host{display:block}\n .sk{background:linear-gradient(90deg,#e8ebee 25%,#f3f5f6 37%,#e8ebee 63%);\n background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:4px;\n margin-block-end:0.6rem}\n @keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}\n " + "\n :host{display:block}\n .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%);\n background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:var(--rhc-border-radius-md);\n margin-block-end:var(--rhc-space-max-lg)}\n @keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}\n " ], - "template": "@if (visible()) {\n @for (l of lines(); track $index) {\n
\n }\n}\n", + "template": "@if (visible()) {\n @for (l of lines(); track $index) {\n
\n }\n}\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -4209,10 +5917,10 @@ "description": "

Atom: skeleton placeholder (grey shimmer). Delay-gated so it never flashes\non fast responses. Render count lines shaped roughly like the content.

\n", "rawdescription": "\nAtom: skeleton placeholder (grey shimmer). Delay-gated so it never flashes\non fast responses. Render `count` lines shaped roughly like the content.", "type": "component", - "sourceCode": "import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/core';\n\n/** Atom: skeleton placeholder (grey shimmer). Delay-gated so it never flashes\n on fast responses. Render `count` lines shaped roughly like the content. */\n@Component({\n selector: 'app-skeleton',\n styles: [`\n :host{display:block}\n .sk{background:linear-gradient(90deg,#e8ebee 25%,#f3f5f6 37%,#e8ebee 63%);\n background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:4px;\n margin-block-end:0.6rem}\n @keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}\n `],\n template: `\n @if (visible()) {\n @for (l of lines(); track $index) {\n
\n }\n }\n `,\n})\nexport class SkeletonComponent implements OnInit, OnDestroy {\n width = input('100%');\n height = input('1rem');\n count = input(1);\n delay = input(150);\n protected visible = signal(false);\n protected lines = computed(() => Array(this.count()).fill(0));\n private timer?: ReturnType;\n\n ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); }\n ngOnDestroy() { clearTimeout(this.timer); }\n}\n", + "sourceCode": "import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/core';\n\n/** Atom: skeleton placeholder (grey shimmer). Delay-gated so it never flashes\n on fast responses. Render `count` lines shaped roughly like the content. */\n@Component({\n selector: 'app-skeleton',\n styles: [`\n :host{display:block}\n .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%);\n background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:var(--rhc-border-radius-md);\n margin-block-end:var(--rhc-space-max-lg)}\n @keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}\n `],\n template: `\n @if (visible()) {\n @for (l of lines(); track $index) {\n
\n }\n }\n `,\n})\nexport class SkeletonComponent implements OnInit, OnDestroy {\n width = input('100%');\n height = input('1rem');\n count = input(1);\n delay = input(150);\n protected visible = signal(false);\n protected lines = computed(() => Array(this.count()).fill(0));\n private timer?: ReturnType;\n\n ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); }\n ngOnDestroy() { clearTimeout(this.timer); }\n}\n", "assetsDirs": [], "styleUrlsData": "", - "stylesData": "\n :host{display:block}\n .sk{background:linear-gradient(90deg,#e8ebee 25%,#f3f5f6 37%,#e8ebee 63%);\n background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:4px;\n margin-block-end:0.6rem}\n @keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}\n \n", + "stylesData": "\n :host{display:block}\n .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%);\n background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:var(--rhc-border-radius-md);\n margin-block-end:var(--rhc-space-max-lg)}\n @keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}\n \n", "extends": [], "implements": [ "OnInit", @@ -4221,7 +5929,7 @@ }, { "name": "SpinnerComponent", - "id": "component-SpinnerComponent-4ac83777af1737425f1eec0d4c22a830297cca57c3140ef014842789163b2c2873312a9ae4e9dc7554241ac3b3dc339b7bab22e02fd8a4fe748a985264126ca4", + "id": "component-SpinnerComponent-a2815cf269d64067cc661956599274ab670cf13aa1a86ca3945a1ab725dc3b48a1e481d171799205b25682e3ed47cada574109891f4c56ea3bd92991d0f883e7", "file": "src/app/shared/ui/spinner/spinner.component.ts", "encapsulation": [], "entryComponents": [], @@ -4231,7 +5939,7 @@ "selector": "app-spinner", "styleUrls": [], "styles": [ - "\n :host{display:block}\n .sp{width:2rem;height:2rem;border-radius:50%;\n border:3px solid var(--rhc-color-grijs-300,#cad0d6);\n border-block-start-color:var(--rhc-color-lintblauw-700,#154273);\n animation:sp 0.8s linear infinite;margin:1.5rem auto}\n @keyframes sp{to{transform:rotate(360deg)}}\n " + "\n :host{display:block}\n .sp{inline-size:var(--rhc-space-max-3xl);block-size:var(--rhc-space-max-3xl);border-radius:var(--rhc-border-radius-round);\n border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-300);\n border-block-start-color:var(--rhc-color-lintblauw-700);\n animation:sp 0.8s linear infinite;margin:var(--rhc-space-max-2xl) auto}\n @keyframes sp{to{transform:rotate(360deg)}}\n " ], "template": "@if (visible()) {\n
\n}\n", "templateUrl": [], @@ -4311,10 +6019,10 @@ "description": "

Atom: spinner that only appears after delay ms — fast responses never\nflash a spinner, slow ones get feedback.

\n", "rawdescription": "\nAtom: spinner that only appears after `delay` ms — fast responses never\nflash a spinner, slow ones get feedback.", "type": "component", - "sourceCode": "import { Component, OnDestroy, OnInit, input, signal } from '@angular/core';\n\n/** Atom: spinner that only appears after `delay` ms — fast responses never\n flash a spinner, slow ones get feedback. */\n@Component({\n selector: 'app-spinner',\n styles: [`\n :host{display:block}\n .sp{width:2rem;height:2rem;border-radius:50%;\n border:3px solid var(--rhc-color-grijs-300,#cad0d6);\n border-block-start-color:var(--rhc-color-lintblauw-700,#154273);\n animation:sp 0.8s linear infinite;margin:1.5rem auto}\n @keyframes sp{to{transform:rotate(360deg)}}\n `],\n template: `\n @if (visible()) {\n
\n }\n `,\n})\nexport class SpinnerComponent implements OnInit, OnDestroy {\n delay = input(250);\n protected visible = signal(false);\n private timer?: ReturnType;\n\n ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); }\n ngOnDestroy() { clearTimeout(this.timer); }\n}\n", + "sourceCode": "import { Component, OnDestroy, OnInit, input, signal } from '@angular/core';\n\n/** Atom: spinner that only appears after `delay` ms — fast responses never\n flash a spinner, slow ones get feedback. */\n@Component({\n selector: 'app-spinner',\n styles: [`\n :host{display:block}\n .sp{inline-size:var(--rhc-space-max-3xl);block-size:var(--rhc-space-max-3xl);border-radius:var(--rhc-border-radius-round);\n border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-300);\n border-block-start-color:var(--rhc-color-lintblauw-700);\n animation:sp 0.8s linear infinite;margin:var(--rhc-space-max-2xl) auto}\n @keyframes sp{to{transform:rotate(360deg)}}\n `],\n template: `\n @if (visible()) {\n
\n }\n `,\n})\nexport class SpinnerComponent implements OnInit, OnDestroy {\n delay = input(250);\n protected visible = signal(false);\n private timer?: ReturnType;\n\n ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); }\n ngOnDestroy() { clearTimeout(this.timer); }\n}\n", "assetsDirs": [], "styleUrlsData": "", - "stylesData": "\n :host{display:block}\n .sp{width:2rem;height:2rem;border-radius:50%;\n border:3px solid var(--rhc-color-grijs-300,#cad0d6);\n border-block-start-color:var(--rhc-color-lintblauw-700,#154273);\n animation:sp 0.8s linear infinite;margin:1.5rem auto}\n @keyframes sp{to{transform:rotate(360deg)}}\n \n", + "stylesData": "\n :host{display:block}\n .sp{inline-size:var(--rhc-space-max-3xl);block-size:var(--rhc-space-max-3xl);border-radius:var(--rhc-border-radius-round);\n border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-300);\n border-block-start-color:var(--rhc-color-lintblauw-700);\n animation:sp 0.8s linear infinite;margin:var(--rhc-space-max-2xl) auto}\n @keyframes sp{to{transform:rotate(360deg)}}\n \n", "extends": [], "implements": [ "OnInit", @@ -4323,7 +6031,7 @@ }, { "name": "StatusBadgeComponent", - "id": "component-StatusBadgeComponent-4f1df6eb29e4abe21602ed3d03fe37881d253c2a9653306c7c95978022565da00b7c482db530f0eea482adf500359371cc588e7abcc8378d7e422cd7a18f00ad", + "id": "component-StatusBadgeComponent-d676e2f81945ad5a6ae182822c7739effac5631c595651ab0ece77aa868063dc41d50d27773dc7448d1c5267030644b434bbd609600127ba78b4237e371066c6", "file": "src/app/shared/ui/status-badge/status-badge.component.ts", "encapsulation": [], "entryComponents": [], @@ -4332,8 +6040,10 @@ "providers": [], "selector": "app-status-badge", "styleUrls": [], - "styles": [], - "template": "\n \n {{ label() }}\n\n", + "styles": [ + ".badge{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md)}" + ], + "template": "\n \n {{ label() }}\n\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -4346,7 +6056,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 17, + "line": 18, "required": true }, { @@ -4357,7 +6067,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 16, + "line": 17, "required": true } ], @@ -4373,15 +6083,75 @@ "description": "

Atom: a coloured dot + label. Purely presentational and domain-free — the\ncaller decides what colour and label mean (e.g. via registration.policy).\nThis keeps the shared UI kernel free of any domain knowledge.

\n", "rawdescription": "\nAtom: a coloured dot + label. Purely presentational and domain-free — the\ncaller decides what colour and label mean (e.g. via registration.policy).\nThis keeps the shared UI kernel free of any domain knowledge.", "type": "component", - "sourceCode": "import { Component, input } from '@angular/core';\n\n/** Atom: a coloured dot + label. Purely presentational and domain-free — the\n caller decides what colour and label mean (e.g. via registration.policy).\n This keeps the shared UI kernel free of any domain knowledge. */\n@Component({\n selector: 'app-status-badge',\n template: `\n \n \n {{ label() }}\n \n `,\n})\nexport class StatusBadgeComponent {\n label = input.required();\n color = input.required();\n}\n", + "sourceCode": "import { Component, input } from '@angular/core';\n\n/** Atom: a coloured dot + label. Purely presentational and domain-free — the\n caller decides what colour and label mean (e.g. via registration.policy).\n This keeps the shared UI kernel free of any domain knowledge. */\n@Component({\n selector: 'app-status-badge',\n styles: [`.badge{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md)}`],\n template: `\n \n \n {{ label() }}\n \n `,\n})\nexport class StatusBadgeComponent {\n label = input.required();\n color = input.required();\n}\n", "assetsDirs": [], "styleUrlsData": "", - "stylesData": "", + "stylesData": ".badge{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md)}\n", + "extends": [] + }, + { + "name": "StepperComponent", + "id": "component-StepperComponent-ca92d637825cdea2afd87a8afb4b5446cb07d6f3429ebfa9ebdbc9f84569dfff27fb0a8274cae1d5a87440ee7821930479154f40dd4d29cbcb441e10341ca9c8", + "file": "src/app/shared/ui/stepper/stepper.component.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "app-stepper", + "styleUrls": [], + "styles": [ + "\n :host{display:block}\n .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}\n .steps{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-xl);list-style:none;margin:0;padding:0}\n .step{display:flex;align-items:center;gap:var(--rhc-space-max-md);color:var(--rhc-color-foreground-subtle)}\n .num{\n display:grid;place-items:center;\n inline-size:var(--rhc-space-max-4xl);block-size:var(--rhc-space-max-4xl);\n border-radius:var(--rhc-border-radius-round);\n border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-400);\n font-weight:var(--rhc-text-font-weight-bold);\n }\n .step--done .num{background:var(--rhc-color-lintblauw-500);border-color:var(--rhc-color-lintblauw-500);color:var(--rhc-color-wit)}\n .step--current{color:var(--rhc-color-foreground-default)}\n .step--current .num{background:var(--rhc-color-lintblauw-700);border-color:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit)}\n .step--current .label{font-weight:var(--rhc-text-font-weight-semi-bold)}\n " + ], + "template": "\n", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [ + { + "name": "current", + "deprecated": false, + "deprecationMessage": "", + "type": "number", + "indexKey": "", + "optional": false, + "description": "", + "line": 45, + "required": true + }, + { + "name": "steps", + "deprecated": false, + "deprecationMessage": "", + "type": "string[]", + "indexKey": "", + "optional": false, + "description": "", + "line": 44, + "required": true + } + ], + "outputsClass": [], + "propertiesClass": [], + "methodsClass": [], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [], + "description": "

Molecule: a horizontal step indicator for a multi-step flow. Visual progress\nplus accessible semantics — an ordered list with aria-current="step" on the\nactive step and a visually-hidden "Stap X van Y" summary. Domain-free.

\n", + "rawdescription": "\nMolecule: a horizontal step indicator for a multi-step flow. Visual progress\nplus accessible semantics — an ordered list with `aria-current=\"step\"` on the\nactive step and a visually-hidden \"Stap X van Y\" summary. Domain-free.", + "type": "component", + "sourceCode": "import { Component, input } from '@angular/core';\n\n/** Molecule: a horizontal step indicator for a multi-step flow. Visual progress\n plus accessible semantics — an ordered list with `aria-current=\"step\"` on the\n active step and a visually-hidden \"Stap X van Y\" summary. Domain-free. */\n@Component({\n selector: 'app-stepper',\n styles: [`\n :host{display:block}\n .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}\n .steps{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-xl);list-style:none;margin:0;padding:0}\n .step{display:flex;align-items:center;gap:var(--rhc-space-max-md);color:var(--rhc-color-foreground-subtle)}\n .num{\n display:grid;place-items:center;\n inline-size:var(--rhc-space-max-4xl);block-size:var(--rhc-space-max-4xl);\n border-radius:var(--rhc-border-radius-round);\n border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-400);\n font-weight:var(--rhc-text-font-weight-bold);\n }\n .step--done .num{background:var(--rhc-color-lintblauw-500);border-color:var(--rhc-color-lintblauw-500);color:var(--rhc-color-wit)}\n .step--current{color:var(--rhc-color-foreground-default)}\n .step--current .num{background:var(--rhc-color-lintblauw-700);border-color:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit)}\n .step--current .label{font-weight:var(--rhc-text-font-weight-semi-bold)}\n `],\n template: `\n \n `,\n})\nexport class StepperComponent {\n steps = input.required();\n current = input.required();\n}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "\n :host{display:block}\n .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}\n .steps{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-xl);list-style:none;margin:0;padding:0}\n .step{display:flex;align-items:center;gap:var(--rhc-space-max-md);color:var(--rhc-color-foreground-subtle)}\n .num{\n display:grid;place-items:center;\n inline-size:var(--rhc-space-max-4xl);block-size:var(--rhc-space-max-4xl);\n border-radius:var(--rhc-border-radius-round);\n border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-400);\n font-weight:var(--rhc-text-font-weight-bold);\n }\n .step--done .num{background:var(--rhc-color-lintblauw-500);border-color:var(--rhc-color-lintblauw-500);color:var(--rhc-color-wit)}\n .step--current{color:var(--rhc-color-foreground-default)}\n .step--current .num{background:var(--rhc-color-lintblauw-700);border-color:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit)}\n .step--current .label{font-weight:var(--rhc-text-font-weight-semi-bold)}\n \n", "extends": [] }, { "name": "TextInputComponent", - "id": "component-TextInputComponent-9d0cd6c2d7892958e9a016b570670f0186d410ea16a22925126f9eeea1aa178f081a4e5647af633f8a0bc1c458422a9f30169f159f930e9048702995fbb827e6", + "id": "component-TextInputComponent-712f6ffbc84c3ae2868d0a7dbb2c885712e01d4e340d36f57db0ed7d49d33dbcbab13fb8d0655c3e52f4557af2ed88b899e48167c10c5d9a7cec6bc3cd55d5ae", "file": "src/app/shared/ui/text-input/text-input.component.ts", "encapsulation": [], "entryComponents": [], @@ -4395,7 +6165,7 @@ "selector": "app-text-input", "styleUrls": [], "styles": [], - "template": "\n", + "template": "\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -4408,7 +6178,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 25, + "line": 27, "required": false }, { @@ -4419,7 +6189,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 24, + "line": 26, "required": false }, { @@ -4430,7 +6200,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 23, + "line": 25, "required": false }, { @@ -4442,7 +6212,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 22, + "line": 24, "required": false } ], @@ -4457,7 +6227,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 28 + "line": 30 }, { "name": "onChange", @@ -4468,7 +6238,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 29 + "line": 31 }, { "name": "onTouched", @@ -4479,7 +6249,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 30 + "line": 32 }, { "name": "value", @@ -4490,7 +6260,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 27 + "line": 29 } ], "methodsClass": [ @@ -4509,7 +6279,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 32, + "line": 34, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -4551,7 +6321,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 37, + "line": 39, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -4594,7 +6364,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 38, + "line": 40, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -4627,7 +6397,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 39, + "line": 41, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -4659,7 +6429,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 36, + "line": 38, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -4686,7 +6456,7 @@ "description": "

Atom: text input. Utrecht textbox wired up as a form control (ngModel/reactive).

\n", "rawdescription": "\nAtom: text input. Utrecht textbox wired up as a form control (ngModel/reactive).", "type": "component", - "sourceCode": "import { Component, forwardRef, input } from '@angular/core';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\n\n/** Atom: text input. Utrecht textbox wired up as a form control (ngModel/reactive). */\n@Component({\n selector: 'app-text-input',\n template: `\n \n `,\n providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TextInputComponent), multi: true }],\n})\nexport class TextInputComponent implements ControlValueAccessor {\n type = input<'text' | 'password' | 'email'>('text');\n placeholder = input('');\n invalid = input(false);\n inputId = input();\n\n value = '';\n disabled = false;\n onChange: (v: string) => void = () => {};\n onTouched: () => void = () => {};\n\n onInput(e: Event) {\n this.value = (e.target as HTMLInputElement).value;\n this.onChange(this.value);\n }\n writeValue(v: string) { this.value = v ?? ''; }\n registerOnChange(fn: (v: string) => void) { this.onChange = fn; }\n registerOnTouched(fn: () => void) { this.onTouched = fn; }\n setDisabledState(d: boolean) { this.disabled = d; }\n}\n", + "sourceCode": "import { Component, forwardRef, input } from '@angular/core';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\n\n/** Atom: text input. Utrecht textbox wired up as a form control (ngModel/reactive). */\n@Component({\n selector: 'app-text-input',\n template: `\n \n `,\n providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TextInputComponent), multi: true }],\n})\nexport class TextInputComponent implements ControlValueAccessor {\n type = input<'text' | 'password' | 'email'>('text');\n placeholder = input('');\n invalid = input(false);\n inputId = input();\n\n value = '';\n disabled = false;\n onChange: (v: string) => void = () => {};\n onTouched: () => void = () => {};\n\n onInput(e: Event) {\n this.value = (e.target as HTMLInputElement).value;\n this.onChange(this.value);\n }\n writeValue(v: string) { this.value = v ?? ''; }\n registerOnChange(fn: (v: string) => void) { this.onChange = fn; }\n registerOnTouched(fn: () => void) { this.onTouched = fn; }\n setDisabledState(d: boolean) { this.disabled = d; }\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -4733,6 +6503,26 @@ "rawdescription": "Route guard: only let authenticated users in; otherwise redirect to /login.", "description": "

Route guard: only let authenticated users in; otherwise redirect to /login.

\n" }, + { + "name": "EMPTY", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/infrastructure/duo.adapter.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "DuoLookupDto", + "defaultValue": "{ diplomas: [], handmatig: { beroepen: [], policyQuestions: [] } }" + }, + { + "name": "emptyDraft", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "Draft", + "defaultValue": "{ antwoorden: {} }" + }, { "name": "err", "ctype": "miscellaneous", @@ -4743,6 +6533,16 @@ "type": "unknown", "defaultValue": "(error: E): Result => ({ ok: false, error })" }, + { + "name": "HANDMATIG", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "defaultValue": "'__handmatig__'" + }, { "name": "initial", "ctype": "miscellaneous", @@ -4761,7 +6561,17 @@ "deprecated": false, "deprecationMessage": "", "type": "IntakeState", - "defaultValue": "{ tag: 'Answering', answers: {}, cursor: 0, errors: {} }" + "defaultValue": "{ tag: 'Answering', answers: {}, cursor: 0, errors: {}, scholingThreshold: SCHOLING_THRESHOLD_DEFAULT }" + }, + { + "name": "initial", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "RegistratieState", + "defaultValue": "{ tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {} }" }, { "name": "JA_NEE", @@ -4774,16 +6584,24 @@ "defaultValue": "[{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }]" }, { - "name": "LAGE_UREN_DREMPEL", + "name": "JA_NEE", "ctype": "miscellaneous", "subtype": "variable", - "file": "src/app/herregistratie/domain/intake.machine.ts", + "file": "src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts", "deprecated": false, "deprecationMessage": "", - "type": "number", - "defaultValue": "1000", - "rawdescription": "Below this many NL-hours we ask whether extra scholing was followed.", - "description": "

Below this many NL-hours we ask whether extra scholing was followed.

\n" + "type": "[]", + "defaultValue": "[{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }]" + }, + { + "name": "KANALEN", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "[]", + "defaultValue": "[{ value: 'email', label: 'E-mail' }, { value: 'post', label: 'Post' }]" }, { "name": "ok", @@ -4803,7 +6621,7 @@ "deprecated": false, "deprecationMessage": "", "type": "Routes", - "defaultValue": "[\n {\n path: '',\n component: ShellComponent, // persistent header/footer; only children swap\n children: [\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n { path: 'login', loadComponent: () => \"import('@auth/ui/login.page').then(m => m.LoginPage)\" },\n { path: 'dashboard', canActivate: [authGuard], loadComponent: () => \"import('@registratie/ui/dashboard.page').then(m => m.DashboardPage)\" },\n { path: 'registratie', canActivate: [authGuard], loadComponent: () => \"import('@registratie/ui/registration-detail.page').then(m => m.RegistrationDetailPage)\" },\n { path: 'herregistratie', canActivate: [authGuard], loadComponent: () => \"import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage)\" },\n { path: 'intake', canActivate: [authGuard], loadComponent: () => \"import('@herregistratie/ui/intake.page').then(m => m.IntakePage)\" },\n { path: 'concepts', loadComponent: () => \"import('./showcase/concepts.page').then(m => m.ConceptsPage)\" },\n { path: '**', redirectTo: 'login' },\n ],\n },\n]" + "defaultValue": "[\n {\n path: '',\n component: ShellComponent, // persistent header/footer; only children swap\n children: [\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n { path: 'login', loadComponent: () => \"import('@auth/ui/login.page').then(m => m.LoginPage)\" },\n { path: 'dashboard', canActivate: [authGuard], loadComponent: () => \"import('@registratie/ui/dashboard.page').then(m => m.DashboardPage)\" },\n { path: 'registratie', canActivate: [authGuard], loadComponent: () => \"import('@registratie/ui/registration-detail.page').then(m => m.RegistrationDetailPage)\" },\n { path: 'registreren', canActivate: [authGuard], loadComponent: () => \"import('@registratie/ui/registratie.page').then(m => m.RegistratiePage)\" },\n { path: 'herregistratie', canActivate: [authGuard], loadComponent: () => \"import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage)\" },\n { path: 'intake', canActivate: [authGuard], loadComponent: () => \"import('@herregistratie/ui/intake.page').then(m => m.IntakePage)\" },\n { path: 'concepts', loadComponent: () => \"import('./showcase/concepts.page').then(m => m.ConceptsPage)\" },\n { path: '**', redirectTo: 'login' },\n ],\n },\n]" }, { "name": "scenarioInterceptor", @@ -4817,6 +6635,52 @@ "rawdescription": "Demo-only: rewrites the timing/outcome of mock data requests based on\n?scenario= so loading / empty / error states can be shown on demand.\nReal requests are untouched.", "description": "

Demo-only: rewrites the timing/outcome of mock data requests based on\n?scenario= so loading / empty / error states can be shown on demand.\nReal requests are untouched.

\n" }, + { + "name": "SCHOLING_THRESHOLD_DEFAULT", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/herregistratie/domain/intake.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "number", + "defaultValue": "1000", + "rawdescription": "Demo fallback only — the real threshold is a SERVER-OWNED policy value fetched\nat runtime (see IntakePolicyDto / SetPolicy). ponytail: default is the offline\nfallback; the server value wins.", + "description": "

Demo fallback only — the real threshold is a SERVER-OWNED policy value fetched\nat runtime (see IntakePolicyDto / SetPolicy). ponytail: default is the offline\nfallback; the server value wins.

\n" + }, + { + "name": "STEPS", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/herregistratie/domain/intake.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "StepId[]", + "defaultValue": "['buitenland', 'werk', 'review']", + "rawdescription": "The fixed step list. Number of steps never changes; questions reveal inline.", + "description": "

The fixed step list. Number of steps never changes; questions reveal inline.

\n" + }, + { + "name": "STEPS", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "StepId[]", + "defaultValue": "['adres', 'beroep', 'controle']", + "rawdescription": "The fixed step list. Number of steps never changes; questions reveal inline.", + "description": "

The fixed step list. Number of steps never changes; questions reveal inline.

\n" + }, + { + "name": "STORAGE_KEY", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/auth/application/session.store.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "defaultValue": "'session-v1'" + }, { "name": "STORAGE_KEY", "ctype": "miscellaneous", @@ -4825,7 +6689,17 @@ "deprecated": false, "deprecationMessage": "", "type": "string", - "defaultValue": "'intake-v1'" + "defaultValue": "'intake-v3'" + }, + { + "name": "STORAGE_KEY", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "defaultValue": "'registratie-v1'" }, { "name": "VALID", @@ -4968,6 +6842,35 @@ } ] }, + { + "name": "back", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "createStore", "file": "src/app/shared/application/store.ts", @@ -5028,7 +6931,7 @@ "subtype": "function", "deprecated": false, "deprecationMessage": "", - "description": "

Which step the cursor currently points at (clamped to the live step list).

\n", + "description": "

Which step the cursor currently points at (clamped to the fixed list).

\n", "args": [ { "name": "s", @@ -5050,6 +6953,79 @@ } ] }, + { + "name": "currentStep", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Which step the cursor currently points at (clamped to the fixed list).

\n", + "args": [ + { + "name": "s", + "type": "Extract", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "StepId", + "jsdoctags": [ + { + "name": "s", + "type": "Extract", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "declareerBeroep", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Declare the beroep for a manually-entered diploma (chosen from a fixed list).

\n", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "beroep", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "beroep", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "fakeResource", "file": "src/app/showcase/concepts.page.ts", @@ -5199,6 +7175,50 @@ } ] }, + { + "name": "gaNaarStap", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Jump back to an earlier step to correct data (controle → step N). Forward\njumps are not allowed (would skip validation). Preserves the draft.

\n", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "cursor", + "type": "number", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "cursor", + "type": "number", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "herregistratieDeadline", "file": "src/app/registratie/domain/registration.policy.ts", @@ -5262,7 +7282,7 @@ "subtype": "function", "deprecated": false, "deprecationMessage": "", - "description": "

A registration may apply for herregistratie only while active and within the\nwindow before its deadline. A struck-off or suspended registration may not.

\n", + "description": "

A registration may apply for herregistratie only while active and within the\nwindow before its deadline. A struck-off or suspended registration may not.\nSERVER-OWNED RULE: this now runs on the backend (BFF), which ships the result\nas decisions.eligibleForHerregistratie in the dashboard view. Kept here as\nthe reference implementation + unit test; the frontend no longer calls it.

\n", "args": [ { "name": "reg", @@ -5345,6 +7365,120 @@ } ] }, + { + "name": "kiesDiploma", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Pick a DUO diploma; the beroep is derived from it and the applicable policy\nquestions (vraagIds) come with it (both server-computed, passed in).

\n", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "diplomaId", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "beroep", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "vraagIds", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "diplomaId", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "beroep", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "vraagIds", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "kiesHandmatig", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Switch to manual diploma entry: the diploma isn't in DUO, so the MAXIMAL\npolicy-question set applies and the entry is flagged handmatig/unverified. The\nberoep is declared separately (declareerBeroep).

\n", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "vraagIds", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "vraagIds", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "lageUren", "file": "src/app/herregistratie/domain/intake.machine.ts", @@ -5352,13 +7486,20 @@ "subtype": "function", "deprecated": false, "deprecationMessage": "", - "description": "", + "description": "

True when NL-hours are low enough that the scholing question must be answered.\nThe threshold is passed in (server-owned), not hardcoded.

\n", "args": [ { "name": "a", "type": "Answers", "deprecated": false, "deprecationMessage": "" + }, + { + "name": "scholingThreshold", + "type": "unknown", + "deprecated": false, + "deprecationMessage": "", + "defaultValue": "SCHOLING_THRESHOLD_DEFAULT" } ], "returnType": "boolean", @@ -5371,6 +7512,16 @@ "tagName": { "text": "param" } + }, + { + "name": "scholingThreshold", + "type": "unknown", + "deprecated": false, + "deprecationMessage": "", + "defaultValue": "SCHOLING_THRESHOLD_DEFAULT", + "tagName": { + "text": "param" + } } ] }, @@ -5603,6 +7754,35 @@ } ] }, + { + "name": "next", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "parseBigNummer", "file": "src/app/registratie/domain/value-objects/big-nummer.ts", @@ -5632,6 +7812,116 @@ } ] }, + { + "name": "parseBrpAddress", + "file": "src/app/registratie/infrastructure/brp.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Trust-boundary parse: validate the untrusted response shape. "Geen adres" is a\nvalid outcome (gevonden: false), not a malformed response. ponytail: hand-written;\nreach for a schema lib once the contract count grows.

\n", + "args": [ + { + "name": "json", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "json", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "parseDashboardView", + "file": "src/app/registratie/infrastructure/dashboard-view.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Trust-boundary parse: validate the untrusted response shape and map the DTO\nonto our own domain model. Hand-written on purpose — no Zod for a single\ncontract. ponytail: reach for a schema lib once the contract count grows.

\n", + "args": [ + { + "name": "json", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "json", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "parseDuoLookup", + "file": "src/app/registratie/infrastructure/duo.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Trust-boundary parse: validate the untrusted response shape (diplomas, each\nwith derived beroep + policy questions, plus the manual-entry fallback).

\n", + "args": [ + { + "name": "json", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "json", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "parseEmail", + "file": "src/app/registratie/domain/value-objects/email.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "raw", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "raw", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "parsePostcode", "file": "src/app/registratie/domain/value-objects/postcode.ts", @@ -5661,6 +7951,33 @@ } ] }, + { + "name": "parseQuestions", + "file": "src/app/registratie/infrastructure/duo.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "json", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "[] | null", + "jsdoctags": [ + { + "name": "json", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "parseUren", "file": "src/app/registratie/domain/value-objects/uren.ts", @@ -5690,6 +8007,80 @@ } ] }, + { + "name": "prefillAdres", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Prefill the address from a BRP lookup and flag its origin (PRD §7).

\n", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "straat", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "postcode", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "woonplaats", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "straat", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "postcode", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "woonplaats", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "reduce", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", @@ -5778,6 +8169,50 @@ } ] }, + { + "name": "reduce", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "m", + "type": "RegistratieMsg", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "m", + "type": "RegistratieMsg", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "resolve", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", @@ -5866,6 +8301,61 @@ } ] }, + { + "name": "resolve", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "r", + "type": "Result", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "r", + "type": "Result", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "restore", + "file": "src/app/auth/application/session.store.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Restore a persisted session (best-effort; corrupt entry → logged out).

\n", + "args": [], + "returnType": "Session | null" + }, { "name": "setAnswer", "file": "src/app/herregistratie/domain/intake.machine.ts", @@ -5923,6 +8413,109 @@ } ] }, + { + "name": "setAntwoord", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "vraagId", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "value", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "vraagId", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "value", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "setCorrespondentie", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "value", + "type": "Correspondentie", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "value", + "type": "Correspondentie", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "setField", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", @@ -5980,6 +8573,109 @@ } ] }, + { + "name": "setField", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "key", + "type": "DraftField", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "value", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "key", + "type": "DraftField", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "value", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "setPolicy", + "file": "src/app/herregistratie/domain/intake.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Apply a server-owned policy value (e.g. the scholing threshold).

\n", + "args": [ + { + "name": "s", + "type": "IntakeState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "scholingThreshold", + "type": "number", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "IntakeState", + "jsdoctags": [ + { + "name": "s", + "type": "IntakeState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "scholingThreshold", + "type": "number", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "statusColor", "file": "src/app/registratie/domain/registration.policy.ts", @@ -6096,6 +8792,35 @@ } ] }, + { + "name": "submit", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "submitHerregistratie", "file": "src/app/herregistratie/application/submit-herregistratie.ts", @@ -6154,6 +8879,35 @@ } ] }, + { + "name": "submitRegistratie", + "file": "src/app/registratie/application/submit-registratie.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Command: send the completed registration to the backend, which invokes the\ndomain command to create/finalize the registration aggregate. Returns a Result\nso the caller branches on success/failure without try/catch; on success it\nyields the confirmation reference (PRD §9). ponytail: faked with a timer; swap\nfor a real POST when there's an API. The "manually entered diploma" rule lets\nthe demo show the failure path.

\n", + "args": [ + { + "name": "data", + "type": "ValidRegistratie", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Promise>", + "jsdoctags": [ + { + "name": "data", + "type": "ValidRegistratie", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "validate", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", @@ -6197,9 +8951,15 @@ "type": "Answers", "deprecated": false, "deprecationMessage": "" + }, + { + "name": "scholingThreshold", + "type": "number", + "deprecated": false, + "deprecationMessage": "" } ], - "returnType": "Result>, ValidIntake>", + "returnType": "Result", "jsdoctags": [ { "name": "a", @@ -6209,6 +8969,44 @@ "tagName": { "text": "param" } + }, + { + "name": "scholingThreshold", + "type": "number", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "validateAll", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Parse the whole wizard into a ValidRegistratie (called on submit).

\n", + "args": [ + { + "name": "d", + "type": "Draft", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "d", + "type": "Draft", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } } ] }, @@ -6219,7 +9017,7 @@ "subtype": "function", "deprecated": false, "deprecationMessage": "", - "description": "

Validate ONE step's fields. Returns the per-field error keyed by StepId.

\n", + "description": "

Validate every question currently visible in ONE step. Errors keyed per field.

\n", "args": [ { "name": "step", @@ -6232,9 +9030,15 @@ "type": "Answers", "deprecated": false, "deprecationMessage": "" + }, + { + "name": "scholingThreshold", + "type": "number", + "deprecated": false, + "deprecationMessage": "" } ], - "returnType": "Result>, void>", + "returnType": "Result", "jsdoctags": [ { "name": "step", @@ -6253,30 +9057,54 @@ "tagName": { "text": "param" } + }, + { + "name": "scholingThreshold", + "type": "number", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } } ] }, { - "name": "visibleSteps", - "file": "src/app/herregistratie/domain/intake.machine.ts", + "name": "validateStep", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", "ctype": "miscellaneous", "subtype": "function", "deprecated": false, "deprecationMessage": "", - "description": "

THE branching, as one pure function. The step list is recomputed on every\ntransition, so changing an earlier answer immediately adds/removes later steps.

\n", + "description": "

Validate every question currently visible in ONE step. Errors keyed per field.

\n", "args": [ { - "name": "a", - "type": "Answers", + "name": "step", + "type": "StepId", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "d", + "type": "Draft", "deprecated": false, "deprecationMessage": "" } ], - "returnType": "StepId[]", + "returnType": "Result", "jsdoctags": [ { - "name": "a", - "type": "Answers", + "name": "step", + "type": "StepId", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "d", + "type": "Draft", "deprecated": false, "deprecationMessage": "", "tagName": { @@ -6298,6 +9126,17 @@ "description": "

A note is either a recognised specialism or a plain annotation — a closed set,\nnot an open string, so a typo can't slip through.

\n", "kind": 193 }, + { + "name": "AdresHerkomst", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"brp\" | \"handmatig\"", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

Where a piece of data came from — recorded on the aggregate (PRD §5).

\n", + "kind": 193 + }, { "name": "AlertType", "ctype": "miscellaneous", @@ -6331,6 +9170,50 @@ "description": "

Nominal typing: Brand<string, 'Postcode'> is assignable from a plain string\nonly through an explicit cast — so a smart constructor is the only minter.

\n", "kind": 194 }, + { + "name": "Correspondentie", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"email\" | \"post\"", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, + { + "name": "DiplomaHerkomst", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"duo\" | \"handmatig\"", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, + { + "name": "DraftField", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"straat\" | \"postcode\" | \"woonplaats\" | \"email\"", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

Text fields settable via SetField.

\n", + "kind": 193 + }, + { + "name": "Email", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "Brand", + "file": "src/app/registratie/domain/value-objects/email.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

Value object: an e-mail address. "Parse, don't validate" — an Email is a\ndistinct type from a raw string, mintable only via parseEmail, so holding one\nis proof it is well-formed. Format-only check (the FE keeps format validation\nfor instant feedback; the backend stays the authority — see ADR-0001).

\n", + "kind": 184 + }, { "name": "Err", "ctype": "miscellaneous", @@ -6342,11 +9225,22 @@ "description": "", "kind": 193 }, + { + "name": "Errors", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "Partial>", + "file": "src/app/herregistratie/domain/intake.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

Per-field error map: one message per question, since a step holds several.

\n", + "kind": 184 + }, { "name": "IntakeMsg", "ctype": "miscellaneous", "subtype": "typealias", - "rawtype": "literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type", + "rawtype": "literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type", "file": "src/app/herregistratie/domain/intake.machine.ts", "deprecated": false, "deprecationMessage": "", @@ -6372,7 +9266,7 @@ "file": "src/app/herregistratie/domain/intake.machine.ts", "deprecated": false, "deprecationMessage": "", - "description": "

A BRANCHING wizard. Unlike herregistratie.machine (fixed 2 steps), the set of\nsteps here is NOT stored — it's derived from the answers by visibleSteps.\nAnswer "buiten Nederland gewerkt? → ja" and two extra steps appear; report few\nhours and a scholing-question appears. The progress bar's denominator changes\nas you type. "Which question comes next" is a pure function, so it's trivial\nto test and impossible to get out of sync with the data.

\n", + "description": "

A FIXED 3-step wizard with progressive disclosure. The steps never change in\nnumber (always STEPS); instead, follow-up questions appear inline within a\nstep depending on earlier answers — answer "buiten Nederland gewerkt? → ja"\nand the country/hours questions reveal in the same step; report few hours and\nthe scholing-question reveals inside the 'werk' step. "Is this field required\nright now" is a pure function (validateStep/lageUren), so it's trivial to\ntest and impossible to get out of sync with the data.

\n", "kind": 193 }, { @@ -6386,6 +9280,28 @@ "description": "

Value object: a Dutch postcode. "Parse, don't validate" — a Postcode is a\ndistinct type from a raw string, mintable only via parsePostcode, so holding\none is proof it is well-formed.

\n", "kind": 184 }, + { + "name": "RegistratieMsg", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, + { + "name": "RegistratieState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type | literal type", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, { "name": "RegistrationStatus", "ctype": "miscellaneous", @@ -6445,11 +9361,22 @@ "name": "StepId", "ctype": "miscellaneous", "subtype": "typealias", - "rawtype": "\"buitenland\" | \"buitenlandDetails\" | \"uren\" | \"scholing\" | \"punten\" | \"review\"", + "rawtype": "\"buitenland\" | \"werk\" | \"review\"", "file": "src/app/herregistratie/domain/intake.machine.ts", "deprecated": false, "deprecationMessage": "", - "description": "

Every possible question, as a union — never a bare string.

\n", + "description": "

The three fixed steps. Each step groups one or more questions.

\n", + "kind": 193 + }, + { + "name": "StepId", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"adres\" | \"beroep\" | \"controle\"", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

A FIXED 3-step registration wizard. The steps never change in number (always\nSTEPS): (1) adres + correspondentievoorkeur, (2) beroep o.b.v. diploma,\n(3) controle & indienen. Follow-up questions appear inline within a step\n(e.g. choosing 'email' reveals the e-mail field). "Is this field required\nright now" is a pure function (validateStep), so it is trivial to test and\nimpossible to get out of sync with the data. Invariants live here, not in the\nUI: the wizard reaches Indienen only when a complete ValidRegistratie parses.

\n", "kind": 193 }, { @@ -6539,6 +9466,52 @@ "description": "

Route guard: only let authenticated users in; otherwise redirect to /login.

\n" } ], + "src/app/registratie/infrastructure/duo.adapter.ts": [ + { + "name": "EMPTY", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/infrastructure/duo.adapter.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "DuoLookupDto", + "defaultValue": "{ diplomas: [], handmatig: { beroepen: [], policyQuestions: [] } }" + } + ], + "src/app/registratie/domain/registratie-wizard.machine.ts": [ + { + "name": "emptyDraft", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "Draft", + "defaultValue": "{ antwoorden: {} }" + }, + { + "name": "initial", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "RegistratieState", + "defaultValue": "{ tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {} }" + }, + { + "name": "STEPS", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "StepId[]", + "defaultValue": "['adres', 'beroep', 'controle']", + "rawdescription": "The fixed step list. Number of steps never changes; questions reveal inline.", + "description": "

The fixed step list. Number of steps never changes; questions reveal inline.

\n" + } + ], "src/app/shared/kernel/fp.ts": [ { "name": "err", @@ -6561,6 +9534,48 @@ "defaultValue": "(value: T): Result => ({ ok: true, value })" } ], + "src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts": [ + { + "name": "HANDMATIG", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "defaultValue": "'__handmatig__'" + }, + { + "name": "JA_NEE", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "[]", + "defaultValue": "[{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }]" + }, + { + "name": "KANALEN", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "[]", + "defaultValue": "[{ value: 'email', label: 'E-mail' }, { value: 'post', label: 'Post' }]" + }, + { + "name": "STORAGE_KEY", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "defaultValue": "'registratie-v1'" + } + ], "src/app/herregistratie/domain/herregistratie.machine.ts": [ { "name": "initial", @@ -6582,10 +9597,10 @@ "deprecated": false, "deprecationMessage": "", "type": "IntakeState", - "defaultValue": "{ tag: 'Answering', answers: {}, cursor: 0, errors: {} }" + "defaultValue": "{ tag: 'Answering', answers: {}, cursor: 0, errors: {}, scholingThreshold: SCHOLING_THRESHOLD_DEFAULT }" }, { - "name": "LAGE_UREN_DREMPEL", + "name": "SCHOLING_THRESHOLD_DEFAULT", "ctype": "miscellaneous", "subtype": "variable", "file": "src/app/herregistratie/domain/intake.machine.ts", @@ -6593,8 +9608,20 @@ "deprecationMessage": "", "type": "number", "defaultValue": "1000", - "rawdescription": "Below this many NL-hours we ask whether extra scholing was followed.", - "description": "

Below this many NL-hours we ask whether extra scholing was followed.

\n" + "rawdescription": "Demo fallback only — the real threshold is a SERVER-OWNED policy value fetched\nat runtime (see IntakePolicyDto / SetPolicy). ponytail: default is the offline\nfallback; the server value wins.", + "description": "

Demo fallback only — the real threshold is a SERVER-OWNED policy value fetched\nat runtime (see IntakePolicyDto / SetPolicy). ponytail: default is the offline\nfallback; the server value wins.

\n" + }, + { + "name": "STEPS", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/herregistratie/domain/intake.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "StepId[]", + "defaultValue": "['buitenland', 'werk', 'review']", + "rawdescription": "The fixed step list. Number of steps never changes; questions reveal inline.", + "description": "

The fixed step list. Number of steps never changes; questions reveal inline.

\n" } ], "src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts": [ @@ -6616,7 +9643,7 @@ "deprecated": false, "deprecationMessage": "", "type": "string", - "defaultValue": "'intake-v1'" + "defaultValue": "'intake-v3'" } ], "src/app/app.routes.ts": [ @@ -6628,7 +9655,7 @@ "deprecated": false, "deprecationMessage": "", "type": "Routes", - "defaultValue": "[\n {\n path: '',\n component: ShellComponent, // persistent header/footer; only children swap\n children: [\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n { path: 'login', loadComponent: () => \"import('@auth/ui/login.page').then(m => m.LoginPage)\" },\n { path: 'dashboard', canActivate: [authGuard], loadComponent: () => \"import('@registratie/ui/dashboard.page').then(m => m.DashboardPage)\" },\n { path: 'registratie', canActivate: [authGuard], loadComponent: () => \"import('@registratie/ui/registration-detail.page').then(m => m.RegistrationDetailPage)\" },\n { path: 'herregistratie', canActivate: [authGuard], loadComponent: () => \"import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage)\" },\n { path: 'intake', canActivate: [authGuard], loadComponent: () => \"import('@herregistratie/ui/intake.page').then(m => m.IntakePage)\" },\n { path: 'concepts', loadComponent: () => \"import('./showcase/concepts.page').then(m => m.ConceptsPage)\" },\n { path: '**', redirectTo: 'login' },\n ],\n },\n]" + "defaultValue": "[\n {\n path: '',\n component: ShellComponent, // persistent header/footer; only children swap\n children: [\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n { path: 'login', loadComponent: () => \"import('@auth/ui/login.page').then(m => m.LoginPage)\" },\n { path: 'dashboard', canActivate: [authGuard], loadComponent: () => \"import('@registratie/ui/dashboard.page').then(m => m.DashboardPage)\" },\n { path: 'registratie', canActivate: [authGuard], loadComponent: () => \"import('@registratie/ui/registration-detail.page').then(m => m.RegistrationDetailPage)\" },\n { path: 'registreren', canActivate: [authGuard], loadComponent: () => \"import('@registratie/ui/registratie.page').then(m => m.RegistratiePage)\" },\n { path: 'herregistratie', canActivate: [authGuard], loadComponent: () => \"import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage)\" },\n { path: 'intake', canActivate: [authGuard], loadComponent: () => \"import('@herregistratie/ui/intake.page').then(m => m.IntakePage)\" },\n { path: 'concepts', loadComponent: () => \"import('./showcase/concepts.page').then(m => m.ConceptsPage)\" },\n { path: '**', redirectTo: 'login' },\n ],\n },\n]" } ], "src/app/shared/infrastructure/scenario.interceptor.ts": [ @@ -6645,6 +9672,18 @@ "description": "

Demo-only: rewrites the timing/outcome of mock data requests based on\n?scenario= so loading / empty / error states can be shown on demand.\nReal requests are untouched.

\n" } ], + "src/app/auth/application/session.store.ts": [ + { + "name": "STORAGE_KEY", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/auth/application/session.store.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "defaultValue": "'session-v1'" + } + ], "src/app/shared/infrastructure/scenario.ts": [ { "name": "VALID", @@ -7291,7 +10330,7 @@ "subtype": "function", "deprecated": false, "deprecationMessage": "", - "description": "

Which step the cursor currently points at (clamped to the live step list).

\n", + "description": "

Which step the cursor currently points at (clamped to the fixed list).

\n", "args": [ { "name": "s", @@ -7320,13 +10359,20 @@ "subtype": "function", "deprecated": false, "deprecationMessage": "", - "description": "", + "description": "

True when NL-hours are low enough that the scholing question must be answered.\nThe threshold is passed in (server-owned), not hardcoded.

\n", "args": [ { "name": "a", "type": "Answers", "deprecated": false, "deprecationMessage": "" + }, + { + "name": "scholingThreshold", + "type": "unknown", + "deprecated": false, + "deprecationMessage": "", + "defaultValue": "SCHOLING_THRESHOLD_DEFAULT" } ], "returnType": "boolean", @@ -7339,6 +10385,16 @@ "tagName": { "text": "param" } + }, + { + "name": "scholingThreshold", + "type": "unknown", + "deprecated": false, + "deprecationMessage": "", + "defaultValue": "SCHOLING_THRESHOLD_DEFAULT", + "tagName": { + "text": "param" + } } ] }, @@ -7516,6 +10572,50 @@ } ] }, + { + "name": "setPolicy", + "file": "src/app/herregistratie/domain/intake.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Apply a server-owned policy value (e.g. the scholing threshold).

\n", + "args": [ + { + "name": "s", + "type": "IntakeState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "scholingThreshold", + "type": "number", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "IntakeState", + "jsdoctags": [ + { + "name": "s", + "type": "IntakeState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "scholingThreshold", + "type": "number", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "submit", "file": "src/app/herregistratie/domain/intake.machine.ts", @@ -7559,9 +10659,15 @@ "type": "Answers", "deprecated": false, "deprecationMessage": "" + }, + { + "name": "scholingThreshold", + "type": "number", + "deprecated": false, + "deprecationMessage": "" } ], - "returnType": "Result>, ValidIntake>", + "returnType": "Result", "jsdoctags": [ { "name": "a", @@ -7571,6 +10677,15 @@ "tagName": { "text": "param" } + }, + { + "name": "scholingThreshold", + "type": "number", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } } ] }, @@ -7581,7 +10696,7 @@ "subtype": "function", "deprecated": false, "deprecationMessage": "", - "description": "

Validate ONE step's fields. Returns the per-field error keyed by StepId.

\n", + "description": "

Validate every question currently visible in ONE step. Errors keyed per field.

\n", "args": [ { "name": "step", @@ -7594,9 +10709,15 @@ "type": "Answers", "deprecated": false, "deprecationMessage": "" + }, + { + "name": "scholingThreshold", + "type": "number", + "deprecated": false, + "deprecationMessage": "" } ], - "returnType": "Result>, void>", + "returnType": "Result", "jsdoctags": [ { "name": "step", @@ -7615,30 +10736,727 @@ "tagName": { "text": "param" } + }, + { + "name": "scholingThreshold", + "type": "number", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + ], + "src/app/registratie/domain/registratie-wizard.machine.ts": [ + { + "name": "back", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } } ] }, { - "name": "visibleSteps", - "file": "src/app/herregistratie/domain/intake.machine.ts", + "name": "currentStep", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", "ctype": "miscellaneous", "subtype": "function", "deprecated": false, "deprecationMessage": "", - "description": "

THE branching, as one pure function. The step list is recomputed on every\ntransition, so changing an earlier answer immediately adds/removes later steps.

\n", + "description": "

Which step the cursor currently points at (clamped to the fixed list).

\n", "args": [ { - "name": "a", - "type": "Answers", + "name": "s", + "type": "Extract", "deprecated": false, "deprecationMessage": "" } ], - "returnType": "StepId[]", + "returnType": "StepId", "jsdoctags": [ { - "name": "a", - "type": "Answers", + "name": "s", + "type": "Extract", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "declareerBeroep", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Declare the beroep for a manually-entered diploma (chosen from a fixed list).

\n", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "beroep", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "beroep", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "gaNaarStap", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Jump back to an earlier step to correct data (controle → step N). Forward\njumps are not allowed (would skip validation). Preserves the draft.

\n", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "cursor", + "type": "number", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "cursor", + "type": "number", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "kiesDiploma", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Pick a DUO diploma; the beroep is derived from it and the applicable policy\nquestions (vraagIds) come with it (both server-computed, passed in).

\n", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "diplomaId", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "beroep", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "vraagIds", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "diplomaId", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "beroep", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "vraagIds", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "kiesHandmatig", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Switch to manual diploma entry: the diploma isn't in DUO, so the MAXIMAL\npolicy-question set applies and the entry is flagged handmatig/unverified. The\nberoep is declared separately (declareerBeroep).

\n", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "vraagIds", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "vraagIds", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "next", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "prefillAdres", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Prefill the address from a BRP lookup and flag its origin (PRD §7).

\n", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "straat", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "postcode", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "woonplaats", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "straat", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "postcode", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "woonplaats", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "reduce", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "m", + "type": "RegistratieMsg", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "m", + "type": "RegistratieMsg", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "resolve", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "r", + "type": "Result", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "r", + "type": "Result", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "setAntwoord", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "vraagId", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "value", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "vraagId", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "value", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "setCorrespondentie", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "value", + "type": "Correspondentie", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "value", + "type": "Correspondentie", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "setField", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "key", + "type": "DraftField", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "value", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "key", + "type": "DraftField", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "value", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "submit", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "RegistratieState", + "jsdoctags": [ + { + "name": "s", + "type": "RegistratieState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "validateAll", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Parse the whole wizard into a ValidRegistratie (called on submit).

\n", + "args": [ + { + "name": "d", + "type": "Draft", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "d", + "type": "Draft", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "validateStep", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Validate every question currently visible in ONE step. Errors keyed per field.

\n", + "args": [ + { + "name": "step", + "type": "StepId", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "d", + "type": "Draft", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "step", + "type": "StepId", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "d", + "type": "Draft", "deprecated": false, "deprecationMessage": "", "tagName": { @@ -7807,7 +11625,7 @@ "subtype": "function", "deprecated": false, "deprecationMessage": "", - "description": "

A registration may apply for herregistratie only while active and within the\nwindow before its deadline. A struck-off or suspended registration may not.

\n", + "description": "

A registration may apply for herregistratie only while active and within the\nwindow before its deadline. A struck-off or suspended registration may not.\nSERVER-OWNED RULE: this now runs on the backend (BFF), which ships the result\nas decisions.eligibleForHerregistratie in the dashboard view. Kept here as\nthe reference implementation + unit test; the frontend no longer calls it.

\n", "args": [ { "name": "reg", @@ -8009,6 +11827,151 @@ ] } ], + "src/app/registratie/infrastructure/brp.adapter.ts": [ + { + "name": "parseBrpAddress", + "file": "src/app/registratie/infrastructure/brp.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Trust-boundary parse: validate the untrusted response shape. "Geen adres" is a\nvalid outcome (gevonden: false), not a malformed response. ponytail: hand-written;\nreach for a schema lib once the contract count grows.

\n", + "args": [ + { + "name": "json", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "json", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + ], + "src/app/registratie/infrastructure/dashboard-view.adapter.ts": [ + { + "name": "parseDashboardView", + "file": "src/app/registratie/infrastructure/dashboard-view.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Trust-boundary parse: validate the untrusted response shape and map the DTO\nonto our own domain model. Hand-written on purpose — no Zod for a single\ncontract. ponytail: reach for a schema lib once the contract count grows.

\n", + "args": [ + { + "name": "json", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "json", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + ], + "src/app/registratie/infrastructure/duo.adapter.ts": [ + { + "name": "parseDuoLookup", + "file": "src/app/registratie/infrastructure/duo.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Trust-boundary parse: validate the untrusted response shape (diplomas, each\nwith derived beroep + policy questions, plus the manual-entry fallback).

\n", + "args": [ + { + "name": "json", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "json", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, + { + "name": "parseQuestions", + "file": "src/app/registratie/infrastructure/duo.adapter.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "json", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "[] | null", + "jsdoctags": [ + { + "name": "json", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + ], + "src/app/registratie/domain/value-objects/email.ts": [ + { + "name": "parseEmail", + "file": "src/app/registratie/domain/value-objects/email.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "raw", + "type": "string", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Result", + "jsdoctags": [ + { + "name": "raw", + "type": "string", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + ], "src/app/registratie/domain/value-objects/postcode.ts": [ { "name": "parsePostcode", @@ -8071,6 +12034,19 @@ ] } ], + "src/app/auth/application/session.store.ts": [ + { + "name": "restore", + "file": "src/app/auth/application/session.store.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Restore a persisted session (best-effort; corrupt entry → logged out).

\n", + "args": [], + "returnType": "Session | null" + } + ], "src/app/herregistratie/application/submit-herregistratie.ts": [ { "name": "submitHerregistratie", @@ -8132,6 +12108,37 @@ } ] } + ], + "src/app/registratie/application/submit-registratie.ts": [ + { + "name": "submitRegistratie", + "file": "src/app/registratie/application/submit-registratie.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Command: send the completed registration to the backend, which invokes the\ndomain command to create/finalize the registration aggregate. Returns a Result\nso the caller branches on success/failure without try/catch; on success it\nyields the confirmation reference (PRD §9). ponytail: faked with a timer; swap\nfor a real POST when there's an API. The "manually entered diploma" rule lets\nthe demo show the failure path.

\n", + "args": [ + { + "name": "data", + "type": "ValidRegistratie", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "Promise>", + "jsdoctags": [ + { + "name": "data", + "type": "ValidRegistratie", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } ] }, "groupedEnumerations": {}, @@ -8171,6 +12178,85 @@ "kind": 200 } ], + "src/app/registratie/domain/registratie-wizard.machine.ts": [ + { + "name": "AdresHerkomst", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"brp\" | \"handmatig\"", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

Where a piece of data came from — recorded on the aggregate (PRD §5).

\n", + "kind": 193 + }, + { + "name": "Correspondentie", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"email\" | \"post\"", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, + { + "name": "DiplomaHerkomst", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"duo\" | \"handmatig\"", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, + { + "name": "DraftField", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"straat\" | \"postcode\" | \"woonplaats\" | \"email\"", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

Text fields settable via SetField.

\n", + "kind": 193 + }, + { + "name": "RegistratieMsg", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, + { + "name": "RegistratieState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type | literal type", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, + { + "name": "StepId", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"adres\" | \"beroep\" | \"controle\"", + "file": "src/app/registratie/domain/registratie-wizard.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

A FIXED 3-step registration wizard. The steps never change in number (always\nSTEPS): (1) adres + correspondentievoorkeur, (2) beroep o.b.v. diploma,\n(3) controle & indienen. Follow-up questions appear inline within a step\n(e.g. choosing 'email' reveals the e-mail field). "Is this field required\nright now" is a pure function (validateStep), so it is trivial to test and\nimpossible to get out of sync with the data. Invariants live here, not in the\nUI: the wizard reaches Indienen only when a complete ValidRegistratie parses.

\n", + "kind": 193 + } + ], "src/app/shared/ui/alert/alert.component.ts": [ { "name": "AlertType", @@ -8221,6 +12307,19 @@ "kind": 193 } ], + "src/app/registratie/domain/value-objects/email.ts": [ + { + "name": "Email", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "Brand", + "file": "src/app/registratie/domain/value-objects/email.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

Value object: an e-mail address. "Parse, don't validate" — an Email is a\ndistinct type from a raw string, mintable only via parseEmail, so holding one\nis proof it is well-formed. Format-only check (the FE keeps format validation\nfor instant feedback; the backend stays the authority — see ADR-0001).

\n", + "kind": 184 + } + ], "src/app/registratie/application/big-profile.store.ts": [ { "name": "Err", @@ -8235,11 +12334,22 @@ } ], "src/app/herregistratie/domain/intake.machine.ts": [ + { + "name": "Errors", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "Partial>", + "file": "src/app/herregistratie/domain/intake.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

Per-field error map: one message per question, since a step holds several.

\n", + "kind": 184 + }, { "name": "IntakeMsg", "ctype": "miscellaneous", "subtype": "typealias", - "rawtype": "literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type", + "rawtype": "literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type | literal type", "file": "src/app/herregistratie/domain/intake.machine.ts", "deprecated": false, "deprecationMessage": "", @@ -8265,18 +12375,18 @@ "file": "src/app/herregistratie/domain/intake.machine.ts", "deprecated": false, "deprecationMessage": "", - "description": "

A BRANCHING wizard. Unlike herregistratie.machine (fixed 2 steps), the set of\nsteps here is NOT stored — it's derived from the answers by visibleSteps.\nAnswer "buiten Nederland gewerkt? → ja" and two extra steps appear; report few\nhours and a scholing-question appears. The progress bar's denominator changes\nas you type. "Which question comes next" is a pure function, so it's trivial\nto test and impossible to get out of sync with the data.

\n", + "description": "

A FIXED 3-step wizard with progressive disclosure. The steps never change in\nnumber (always STEPS); instead, follow-up questions appear inline within a\nstep depending on earlier answers — answer "buiten Nederland gewerkt? → ja"\nand the country/hours questions reveal in the same step; report few hours and\nthe scholing-question reveals inside the 'werk' step. "Is this field required\nright now" is a pure function (validateStep/lageUren), so it's trivial to\ntest and impossible to get out of sync with the data.

\n", "kind": 193 }, { "name": "StepId", "ctype": "miscellaneous", "subtype": "typealias", - "rawtype": "\"buitenland\" | \"buitenlandDetails\" | \"uren\" | \"scholing\" | \"punten\" | \"review\"", + "rawtype": "\"buitenland\" | \"werk\" | \"review\"", "file": "src/app/herregistratie/domain/intake.machine.ts", "deprecated": false, "deprecationMessage": "", - "description": "

Every possible question, as a union — never a bare string.

\n", + "description": "

The three fixed steps. Each step groups one or more questions.

\n", "kind": 193 } ], @@ -8395,6 +12505,11 @@ "kind": "route-path", "filename": "src/app/app.routes.ts" }, + { + "name": "registreren", + "kind": "route-path", + "filename": "src/app/app.routes.ts" + }, { "name": "herregistratie", "kind": "route-path", @@ -8465,9 +12580,29 @@ "type": "injectable", "linktype": "injectable", "name": "SessionStore", - "coveragePercent": 28, - "coverageCount": "2/7", - "status": "medium" + "coveragePercent": 25, + "coverageCount": "2/8", + "status": "low" + }, + { + "filePath": "src/app/auth/application/session.store.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "restore", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/auth/application/session.store.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "STORAGE_KEY", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" }, { "filePath": "src/app/auth/auth.guard.ts", @@ -8545,6 +12680,15 @@ "coverageCount": "1/1", "status": "very-good" }, + { + "filePath": "src/app/herregistratie/contracts/intake-policy.dto.ts", + "type": "interface", + "linktype": "interface", + "name": "IntakePolicyDto", + "coveragePercent": 100, + "coverageCount": "2/2", + "status": "very-good" + }, { "filePath": "src/app/herregistratie/domain/herregistratie.machine.ts", "type": "interface", @@ -8707,9 +12851,9 @@ "linktype": "miscellaneous", "linksubtype": "function", "name": "lageUren", - "coveragePercent": 0, - "coverageCount": "0/1", - "status": "low" + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" }, { "filePath": "src/app/herregistratie/domain/intake.machine.ts", @@ -8751,6 +12895,16 @@ "coverageCount": "0/1", "status": "low" }, + { + "filePath": "src/app/herregistratie/domain/intake.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "setPolicy", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, { "filePath": "src/app/herregistratie/domain/intake.machine.ts", "type": "function", @@ -8781,16 +12935,6 @@ "coverageCount": "1/1", "status": "very-good" }, - { - "filePath": "src/app/herregistratie/domain/intake.machine.ts", - "type": "function", - "linktype": "miscellaneous", - "linksubtype": "function", - "name": "visibleSteps", - "coveragePercent": 100, - "coverageCount": "1/1", - "status": "very-good" - }, { "filePath": "src/app/herregistratie/domain/intake.machine.ts", "type": "variable", @@ -8806,7 +12950,27 @@ "type": "variable", "linktype": "miscellaneous", "linksubtype": "variable", - "name": "LAGE_UREN_DREMPEL", + "name": "SCHOLING_THRESHOLD_DEFAULT", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/herregistratie/domain/intake.machine.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "STEPS", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/herregistratie/domain/intake.machine.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "Errors", "coveragePercent": 100, "coverageCount": "1/1", "status": "very-good" @@ -8856,8 +13020,8 @@ "type": "component", "linktype": "component", "name": "HerregistratieWizardComponent", - "coveragePercent": 17, - "coverageCount": "3/17", + "coveragePercent": 22, + "coverageCount": "4/18", "status": "low" }, { @@ -8874,8 +13038,8 @@ "type": "component", "linktype": "component", "name": "IntakeWizardComponent", - "coveragePercent": 19, - "coverageCount": "4/21", + "coveragePercent": 25, + "coverageCount": "6/24", "status": "low" }, { @@ -8912,8 +13076,8 @@ "type": "injectable", "linktype": "injectable", "name": "BigProfileStore", - "coveragePercent": 30, - "coverageCount": "4/13", + "coveragePercent": 42, + "coverageCount": "6/14", "status": "medium" }, { @@ -8926,6 +13090,88 @@ "coverageCount": "0/1", "status": "low" }, + { + "filePath": "src/app/registratie/application/submit-registratie.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "submitRegistratie", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/registratie/contracts/brp-address.dto.ts", + "type": "interface", + "linktype": "interface", + "name": "BrpAddressDto", + "coveragePercent": 33, + "coverageCount": "1/3", + "status": "medium" + }, + { + "filePath": "src/app/registratie/contracts/dashboard-view.dto.ts", + "type": "interface", + "linktype": "interface", + "name": "DashboardView", + "coveragePercent": 33, + "coverageCount": "1/3", + "status": "medium" + }, + { + "filePath": "src/app/registratie/contracts/dashboard-view.dto.ts", + "type": "interface", + "linktype": "interface", + "name": "DashboardViewDto", + "coveragePercent": 25, + "coverageCount": "1/4", + "status": "low" + }, + { + "filePath": "src/app/registratie/contracts/dashboard-view.dto.ts", + "type": "interface", + "linktype": "interface", + "name": "HerregistratieDecisions", + "coveragePercent": 33, + "coverageCount": "1/3", + "status": "medium" + }, + { + "filePath": "src/app/registratie/contracts/duo-diplomas.dto.ts", + "type": "interface", + "linktype": "interface", + "name": "DuoDiplomaDto", + "coveragePercent": 0, + "coverageCount": "0/7", + "status": "low" + }, + { + "filePath": "src/app/registratie/contracts/duo-diplomas.dto.ts", + "type": "interface", + "linktype": "interface", + "name": "DuoLookupDto", + "coveragePercent": 33, + "coverageCount": "1/3", + "status": "medium" + }, + { + "filePath": "src/app/registratie/contracts/duo-diplomas.dto.ts", + "type": "interface", + "linktype": "interface", + "name": "ManualDiplomaPolicyDto", + "coveragePercent": 0, + "coverageCount": "0/3", + "status": "low" + }, + { + "filePath": "src/app/registratie/contracts/duo-diplomas.dto.ts", + "type": "interface", + "linktype": "interface", + "name": "PolicyQuestionDto", + "coveragePercent": 0, + "coverageCount": "0/4", + "status": "low" + }, { "filePath": "src/app/registratie/domain/big-profile.ts", "type": "interface", @@ -8953,6 +13199,293 @@ "coverageCount": "0/4", "status": "low" }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "interface", + "linktype": "interface", + "name": "Draft", + "coveragePercent": 8, + "coverageCount": "1/12", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "interface", + "linktype": "interface", + "name": "Errors", + "coveragePercent": 12, + "coverageCount": "1/8", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "interface", + "linktype": "interface", + "name": "ValidRegistratie", + "coveragePercent": 11, + "coverageCount": "1/9", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "back", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "currentStep", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "declareerBeroep", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "gaNaarStap", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "kiesDiploma", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "kiesHandmatig", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "next", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "prefillAdres", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "reduce", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "resolve", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "setAntwoord", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "setCorrespondentie", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "setField", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "submit", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "validateAll", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "validateStep", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "emptyDraft", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "initial", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "STEPS", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "AdresHerkomst", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "Correspondentie", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "DiplomaHerkomst", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "DraftField", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "RegistratieMsg", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "RegistratieState", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/registratie-wizard.machine.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "StepId", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, { "filePath": "src/app/registratie/domain/registration.policy.ts", "type": "function", @@ -9071,6 +13604,26 @@ "coverageCount": "1/1", "status": "very-good" }, + { + "filePath": "src/app/registratie/domain/value-objects/email.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "parseEmail", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/value-objects/email.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "Email", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, { "filePath": "src/app/registratie/domain/value-objects/postcode.ts", "type": "function", @@ -9116,8 +13669,8 @@ "type": "injectable", "linktype": "injectable", "name": "BigRegisterAdapter", - "coveragePercent": 33, - "coverageCount": "1/3", + "coveragePercent": 50, + "coverageCount": "1/2", "status": "medium" }, { @@ -9129,6 +13682,74 @@ "coverageCount": "1/2", "status": "medium" }, + { + "filePath": "src/app/registratie/infrastructure/brp.adapter.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "parseBrpAddress", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/registratie/infrastructure/dashboard-view.adapter.ts", + "type": "injectable", + "linktype": "injectable", + "name": "DashboardViewAdapter", + "coveragePercent": 50, + "coverageCount": "1/2", + "status": "medium" + }, + { + "filePath": "src/app/registratie/infrastructure/dashboard-view.adapter.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "parseDashboardView", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/registratie/infrastructure/duo.adapter.ts", + "type": "injectable", + "linktype": "injectable", + "name": "DuoAdapter", + "coveragePercent": 50, + "coverageCount": "1/2", + "status": "medium" + }, + { + "filePath": "src/app/registratie/infrastructure/duo.adapter.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "parseDuoLookup", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/registratie/infrastructure/duo.adapter.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "parseQuestions", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/infrastructure/duo.adapter.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "EMPTY", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, { "filePath": "src/app/registratie/ui/change-request-form/change-request-form.component.ts", "type": "component", @@ -9156,6 +13777,64 @@ "coverageCount": "0/2", "status": "low" }, + { + "filePath": "src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts", + "type": "component", + "linktype": "component", + "name": "RegistratieWizardComponent", + "coveragePercent": 26, + "coverageCount": "12/45", + "status": "medium" + }, + { + "filePath": "src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "HANDMATIG", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "JA_NEE", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "KANALEN", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "STORAGE_KEY", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/ui/registratie.page.ts", + "type": "component", + "linktype": "component", + "name": "RegistratiePage", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, { "filePath": "src/app/registratie/ui/registration-detail.page.ts", "type": "component", @@ -9362,13 +14041,31 @@ "coverageCount": "1/1", "status": "very-good" }, + { + "filePath": "src/app/shared/layout/breadcrumb/breadcrumb.component.ts", + "type": "component", + "linktype": "component", + "name": "BreadcrumbComponent", + "coveragePercent": 50, + "coverageCount": "1/2", + "status": "medium" + }, + { + "filePath": "src/app/shared/layout/breadcrumb/breadcrumb.component.ts", + "type": "interface", + "linktype": "interface", + "name": "BreadcrumbItem", + "coveragePercent": 0, + "coverageCount": "0/3", + "status": "low" + }, { "filePath": "src/app/shared/layout/page-shell/page-shell.component.ts", "type": "component", "linktype": "component", "name": "PageShellComponent", - "coveragePercent": 16, - "coverageCount": "1/6", + "coveragePercent": 14, + "coverageCount": "1/7", "status": "low" }, { @@ -9532,8 +14229,8 @@ "type": "component", "linktype": "component", "name": "RadioGroupComponent", - "coveragePercent": 8, - "coverageCount": "1/12", + "coveragePercent": 7, + "coverageCount": "1/13", "status": "low" }, { @@ -9572,6 +14269,15 @@ "coverageCount": "1/3", "status": "medium" }, + { + "filePath": "src/app/shared/ui/stepper/stepper.component.ts", + "type": "component", + "linktype": "component", + "name": "StepperComponent", + "coveragePercent": 33, + "coverageCount": "1/3", + "status": "medium" + }, { "filePath": "src/app/shared/ui/text-input/text-input.component.ts", "type": "component", diff --git a/package.json b/package.json index 1cd442e..bedcd90 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "watch": "ng build --watch --configuration development", "test": "ng test", "storybook": "ng run atomic-design-poc:storybook", - "build-storybook": "ng run atomic-design-poc:build-storybook" + "build-storybook": "ng run atomic-design-poc:build-storybook", + "check:tokens": "if grep -rnE '#[0-9a-fA-F]{3,6}' src/app/registratie/ui src/app/shared/ui src/app/shared/layout --include='*.component.ts'; then echo 'FAIL: hardcoded hex colors found (use design tokens)'; exit 1; else echo 'OK: no hardcoded hex colors in wizard/atoms/chrome'; fi" }, "private": true, "packageManager": "npm@11.12.1", diff --git a/public/mock/brp.json b/public/mock/brp-address.json similarity index 61% rename from public/mock/brp.json rename to public/mock/brp-address.json index dda2dca..3b04788 100644 --- a/public/mock/brp.json +++ b/public/mock/brp-address.json @@ -1,6 +1,5 @@ { - "naam": "Dr. A. (Anna) de Vries", - "geboortedatum": "1985-03-14", + "gevonden": true, "adres": { "straat": "Lange Voorhout 9", "postcode": "2514 EA", diff --git a/public/mock/dashboard-view.json b/public/mock/dashboard-view.json new file mode 100644 index 0000000..7e8c3bc --- /dev/null +++ b/public/mock/dashboard-view.json @@ -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)." + } +} diff --git a/public/mock/duo-diplomas.json b/public/mock/duo-diplomas.json new file mode 100644 index 0000000..3a650ce --- /dev/null +++ b/public/mock/duo-diplomas.json @@ -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" } + ] + } +} diff --git a/public/mock/intake-policy.json b/public/mock/intake-policy.json new file mode 100644 index 0000000..5e78eb1 --- /dev/null +++ b/public/mock/intake-policy.json @@ -0,0 +1 @@ +{ "scholingThreshold": 1000 } diff --git a/public/mock/registration.json b/public/mock/registration.json deleted file mode 100644 index d377283..0000000 --- a/public/mock/registration.json +++ /dev/null @@ -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" } -} diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 21ca4ea..0ea79dd 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -11,6 +11,7 @@ export const routes: Routes = [ { path: 'login', loadComponent: () => import('@auth/ui/login.page').then(m => m.LoginPage) }, { path: 'dashboard', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/dashboard.page').then(m => m.DashboardPage) }, { path: 'registratie', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registration-detail.page').then(m => m.RegistrationDetailPage) }, + { path: 'registreren', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registratie.page').then(m => m.RegistratiePage) }, { path: 'herregistratie', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage) }, { path: 'intake', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/intake.page').then(m => m.IntakePage) }, { path: 'concepts', loadComponent: () => import('./showcase/concepts.page').then(m => m.ConceptsPage) }, diff --git a/src/app/auth/application/session.store.ts b/src/app/auth/application/session.store.ts index 2aec239..f5a540f 100644 --- a/src/app/auth/application/session.store.ts +++ b/src/app/auth/application/session.store.ts @@ -1,22 +1,45 @@ -import { Injectable, computed, inject, signal } from '@angular/core'; +import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { Result } from '@shared/kernel/fp'; import { Session } from '../domain/session'; import { DigidAdapter } from '../infrastructure/digid.adapter'; +const STORAGE_KEY = 'session-v1'; + +/** Restore a persisted session (best-effort; corrupt entry → logged out). */ +function restore(): Session | null { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as Session) : null; + } catch { + return null; + } +} + /** * Holds the current session for the whole app. Because it is providedIn:'root' * there is exactly one instance — every component that injects it sees the same * session signal, so logging in is instantly visible everywhere (the guard, the - * header, etc.). ponytail: in-memory only; a refresh logs you out. + * header, etc.). The session is mirrored to sessionStorage so a refresh or a + * deep-link to a protected route keeps you logged in; it clears when the tab + * closes. ponytail: sessionStorage, not localStorage — no cross-tab sync, which + * matches a single-session portal. */ @Injectable({ providedIn: 'root' }) export class SessionStore { private digid = inject(DigidAdapter); - private _session = signal(null); + private _session = signal(restore()); readonly session = this._session.asReadonly(); readonly isAuthenticated = computed(() => this._session() !== null); + constructor() { + effect(() => { + const s = this._session(); + if (s) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s)); + else sessionStorage.removeItem(STORAGE_KEY); + }); + } + /** Effectful command: authenticate, then store the session on success. */ async login(bsn: string): Promise> { const r = await this.digid.authenticate(bsn); diff --git a/src/app/herregistratie/contracts/intake-policy.dto.ts b/src/app/herregistratie/contracts/intake-policy.dto.ts new file mode 100644 index 0000000..9ca5ff6 --- /dev/null +++ b/src/app/herregistratie/contracts/intake-policy.dto.ts @@ -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; +} diff --git a/src/app/herregistratie/domain/intake.machine.spec.ts b/src/app/herregistratie/domain/intake.machine.spec.ts index b2eaf48..75bbdc8 100644 --- a/src/app/herregistratie/domain/intake.machine.spec.ts +++ b/src/app/herregistratie/domain/intake.machine.spec.ts @@ -3,7 +3,8 @@ import { ok, err } from '@shared/kernel/fp'; import { Answers, initial, - visibleSteps, + STEPS, + lageUren, currentStep, next, back, @@ -13,21 +14,34 @@ import { IntakeState, } from './intake.machine'; -const answering = (answers: Answers, cursor = 0): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {} }); +const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {}, scholingThreshold }); -describe('visibleSteps (the branching)', () => { - it('asks only buitenland/uren/punten/review by default', () => { - expect(visibleSteps({})).toEqual(['buitenland', 'uren', 'punten', 'review']); +describe('STEPS (fixed) and inline questions', () => { + it('always has the same three steps', () => { + expect(STEPS).toEqual(['buitenland', 'werk', 'review']); }); - it('adds the buitenlandDetails step when worked abroad', () => { - expect(visibleSteps({ buitenlandGewerkt: 'ja' })).toContain('buitenlandDetails'); - expect(visibleSteps({ buitenlandGewerkt: 'nee' })).not.toContain('buitenlandDetails'); + it('reveals the buitenland detail questions inline only when worked abroad', () => { + // No new step; instead these fields become required within the buitenland step. + expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked + expect((next(answering({ buitenlandGewerkt: 'ja' })) as any).errors.land).toBeTruthy(); + expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves) + expect((next(answering({ buitenlandGewerkt: 'nee' })) as any).cursor).toBe(1); }); - it('adds the scholing step only when NL-hours are below the threshold', () => { - expect(visibleSteps({ uren: '500' })).toContain('scholing'); - expect(visibleSteps({ uren: '4160' })).not.toContain('scholing'); + it('reveals the scholing question only when NL-hours are below the threshold', () => { + expect(lageUren({ uren: '500' })).toBe(true); + expect(lageUren({ uren: '4160' })).toBe(false); + }); + + it('uses the (server-owned) threshold passed in, not a hardcoded constant', () => { + // Same hours, different threshold → different visibility. Proves de-hardcoding. + expect(lageUren({ uren: '1500' }, 1000)).toBe(false); + expect(lageUren({ uren: '1500' }, 2000)).toBe(true); + // And the threshold from state flows through submit: + const lowThreshold = submit(answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000)); + expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked + expect((lowThreshold as any).errors.scholingGevolgd).toBeTruthy(); }); }); @@ -36,20 +50,18 @@ describe('navigation', () => { const s = next(initial); // buitenland unanswered expect(s.tag).toBe('Answering'); expect((s as any).cursor).toBe(0); - expect((s as any).errors.buitenland).toBeTruthy(); + expect((s as any).errors.buitenlandGewerkt).toBeTruthy(); }); it('Next advances once the step is valid', () => { const s = next(answering({ buitenlandGewerkt: 'nee' })); expect((s as any).cursor).toBe(1); - expect(currentStep(s as any)).toBe('uren'); + expect(currentStep(s as any)).toBe('werk'); }); - it('keeps the cursor in range when an answer collapses a branch', () => { - // Worked abroad, cursor sitting on the extra detail step (index 1)... - const collapsed = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }); - // ...the detail step is gone; cursor must not point past the new shorter list. - expect((collapsed as any).cursor).toBeLessThan(visibleSteps((collapsed as any).answers).length); + it('editing an answer leaves the cursor fixed (steps never collapse)', () => { + const edited = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }); + expect((edited as any).cursor).toBe(1); // cursor untouched; only inline questions change }); it('Back never goes below the first step', () => { @@ -58,21 +70,34 @@ describe('navigation', () => { }); describe('submit', () => { - const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160', punten: '200' }; + // High hours: no scholing question, so no punten is asked or collected. + const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160' }; it('reaches Submitting ONLY with valid answers', () => { - expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: 'x' })).tag).toBe('Answering'); + // Bad punten only blocks when scholing was followed (otherwise punten is ignored). + expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' })).tag).toBe('Answering'); const good = submit(answering(complete)); expect(good.tag).toBe('Submitting'); expect((good as any).data.uren).toBe(4160); + expect((good as any).data.punten).toBeUndefined(); // not collected without scholing + }); + + it('punten is required only when aanvullende scholing was gevolgd', () => { + // scholing = ja but punten missing -> blocked on punten. + const missing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' })); + expect(missing.tag).toBe('Answering'); + expect((missing as any).errors.punten).toBeTruthy(); + // scholing = nee -> punten not required, submits without it. + expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag).toBe('Submitting'); }); it('low hours requires the scholing answer before submit', () => { - const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', punten: '200' })); - expect(noScholing.tag).toBe('Answering'); // scholing step is required, unanswered + const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' })); + expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered const withScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' })); expect(withScholing.tag).toBe('Submitting'); expect((withScholing as any).data.aanvullendeScholing).toBe(true); + expect((withScholing as any).data.punten).toBe(200); }); it('resolve maps Submitting to Submitted / Failed', () => { @@ -85,16 +110,14 @@ describe('submit', () => { describe('reduce (message-driven happy path)', () => { it('drives abroad branch end to end', () => { let s: IntakeState = initial; + // Step 1: buitenland — the country/hours questions reveal inline (same step). s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' }); - s = reduce(s, { tag: 'Next' }); - expect(currentStep(s as any)).toBe('buitenlandDetails'); s = reduce(s, { tag: 'SetAnswer', key: 'land', value: 'België' }); s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandseUren', value: '800' }); s = reduce(s, { tag: 'Next' }); - expect(currentStep(s as any)).toBe('uren'); + expect(currentStep(s as any)).toBe('werk'); + // Step 2: werk — uren + punten (no inline scholing, hours are high). s = reduce(s, { tag: 'SetAnswer', key: 'uren', value: '4160' }); - s = reduce(s, { tag: 'Next' }); - expect(currentStep(s as any)).toBe('punten'); s = reduce(s, { tag: 'SetAnswer', key: 'punten', value: '200' }); s = reduce(s, { tag: 'Next' }); expect(currentStep(s as any)).toBe('review'); diff --git a/src/app/herregistratie/domain/intake.machine.ts b/src/app/herregistratie/domain/intake.machine.ts index d6db0eb..016d2e6 100644 --- a/src/app/herregistratie/domain/intake.machine.ts +++ b/src/app/herregistratie/domain/intake.machine.ts @@ -2,18 +2,19 @@ import { Result, ok, err, assertNever } from '@shared/kernel/fp'; import { Uren, parseUren } from '@registratie/domain/value-objects/uren'; /** - * A BRANCHING wizard. Unlike herregistratie.machine (fixed 2 steps), the set of - * steps here is NOT stored — it's *derived* from the answers by `visibleSteps`. - * Answer "buiten Nederland gewerkt? → ja" and two extra steps appear; report few - * hours and a scholing-question appears. The progress bar's denominator changes - * as you type. "Which question comes next" is a pure function, so it's trivial - * to test and impossible to get out of sync with the data. + * A FIXED 3-step wizard with progressive disclosure. The steps never change in + * number (always `STEPS`); instead, follow-up questions appear *inline within a + * step* depending on earlier answers — answer "buiten Nederland gewerkt? → ja" + * and the country/hours questions reveal in the same step; report few hours and + * the scholing-question reveals inside the 'werk' step. "Is this field required + * right now" is a pure function (`validateStep`/`lageUren`), so it's trivial to + * test and impossible to get out of sync with the data. */ export type JaNee = 'ja' | 'nee'; -/** Every possible question, as a union — never a bare string. */ -export type StepId = 'buitenland' | 'buitenlandDetails' | 'uren' | 'scholing' | 'punten' | 'review'; +/** The three fixed steps. Each step groups one or more questions. */ +export type StepId = 'buitenland' | 'werk' | 'review'; /** One record carried across every step (and persisted). All optional: the user fills it in gradually, and branches may never ask some fields. */ @@ -33,111 +34,115 @@ export interface ValidIntake { buitenlandseUren?: Uren; uren: Uren; aanvullendeScholing?: boolean; - punten: Uren; + punten?: Uren; // only collected when aanvullende scholing is gevolgd (scholingGevolgd === 'ja') } -/** Below this many NL-hours we ask whether extra scholing was followed. */ -const LAGE_UREN_DREMPEL = 1000; +/** Demo fallback only — the real threshold is a SERVER-OWNED policy value fetched + at runtime (see IntakePolicyDto / SetPolicy). ponytail: default is the offline + fallback; the server value wins. */ +export const SCHOLING_THRESHOLD_DEFAULT = 1000; -function lageUren(a: Answers): boolean { +/** True when NL-hours are low enough that the scholing question must be answered. + The threshold is passed in (server-owned), not hardcoded. */ +export function lageUren(a: Answers, scholingThreshold = SCHOLING_THRESHOLD_DEFAULT): boolean { const r = parseUren(a.uren ?? ''); - return r.ok && r.value < LAGE_UREN_DREMPEL; + return r.ok && r.value < scholingThreshold; } -/** - * THE branching, as one pure function. The step list is recomputed on every - * transition, so changing an earlier answer immediately adds/removes later steps. - */ -export function visibleSteps(a: Answers): StepId[] { - const steps: StepId[] = ['buitenland']; - if (a.buitenlandGewerkt === 'ja') steps.push('buitenlandDetails'); - steps.push('uren'); - if (lageUren(a)) steps.push('scholing'); - steps.push('punten', 'review'); - return steps; -} +/** The fixed step list. Number of steps never changes; questions reveal inline. */ +export const STEPS: StepId[] = ['buitenland', 'werk', 'review']; + +/** Per-field error map: one message per question, since a step holds several. */ +type Errors = Partial>; export type IntakeState = - | { tag: 'Answering'; answers: Answers; cursor: number; errors: Partial> } + | { tag: 'Answering'; answers: Answers; cursor: number; errors: Errors; scholingThreshold: number } | { tag: 'Submitting'; data: ValidIntake } | { tag: 'Submitted'; data: ValidIntake } | { tag: 'Failed'; data: ValidIntake; error: string }; -export const initial: IntakeState = { tag: 'Answering', answers: {}, cursor: 0, errors: {} }; +export const initial: IntakeState = { tag: 'Answering', answers: {}, cursor: 0, errors: {}, scholingThreshold: SCHOLING_THRESHOLD_DEFAULT }; -/** Which step the cursor currently points at (clamped to the live step list). */ +/** Which step the cursor currently points at (clamped to the fixed list). */ export function currentStep(s: Extract): StepId { - const steps = visibleSteps(s.answers); - return steps[Math.min(s.cursor, steps.length - 1)]; + return STEPS[Math.min(s.cursor, STEPS.length - 1)]; } -/** Validate ONE step's fields. Returns the per-field error keyed by StepId. */ -function validateStep(step: StepId, a: Answers): Result>, void> { +/** Validate every question currently visible in ONE step. Errors keyed per field. */ +function validateStep(step: StepId, a: Answers, scholingThreshold: number): Result { + const errors: Errors = {}; switch (step) { case 'buitenland': - return a.buitenlandGewerkt ? ok(undefined) : err({ buitenland: 'Maak een keuze.' }); - case 'buitenlandDetails': { - if (!a.land || a.land.trim() === '') return err({ buitenlandDetails: 'Vul een land in.' }); - const u = parseUren(a.buitenlandseUren ?? ''); - return u.ok ? ok(undefined) : err({ buitenlandDetails: u.error }); - } - case 'uren': { + if (!a.buitenlandGewerkt) errors.buitenlandGewerkt = 'Maak een keuze.'; + else if (a.buitenlandGewerkt === 'ja') { + if (!a.land || a.land.trim() === '') errors.land = 'Vul een land in.'; + const u = parseUren(a.buitenlandseUren ?? ''); + if (!u.ok) errors.buitenlandseUren = u.error; + } + break; + case 'werk': { const u = parseUren(a.uren ?? ''); - return u.ok ? ok(undefined) : err({ uren: u.error }); - } - case 'scholing': - return a.scholingGevolgd ? ok(undefined) : err({ scholing: 'Maak een keuze.' }); - case 'punten': { - const p = parseUren(a.punten ?? ''); - return p.ok ? ok(undefined) : err({ punten: p.error }); + if (!u.ok) errors.uren = u.error; + if (lageUren(a, scholingThreshold) && !a.scholingGevolgd) errors.scholingGevolgd = 'Maak een keuze.'; + // Nascholingspunten are only asked (and required) when scholing was followed. + if (a.scholingGevolgd === 'ja') { + const p = parseUren(a.punten ?? ''); + if (!p.ok) errors.punten = p.error; + } + break; } case 'review': - return ok(undefined); // review shows a summary; no own fields + break; // review shows a summary; no own fields default: return assertNever(step); } + return Object.keys(errors).length > 0 ? err(errors) : ok(undefined); } /** Parse the whole questionnaire into a ValidIntake (called on submit). */ -function validateAll(a: Answers): Result>, ValidIntake> { - const errors: Partial> = {}; - for (const step of visibleSteps(a)) { - const r = validateStep(step, a); +function validateAll(a: Answers, scholingThreshold: number): Result { + const errors: Errors = {}; + for (const step of STEPS) { + const r = validateStep(step, a, scholingThreshold); if (!r.ok) Object.assign(errors, r.error); } if (Object.keys(errors).length > 0) return err(errors); const uren = parseUren(a.uren ?? ''); - const punten = parseUren(a.punten ?? ''); - // visibleSteps guaranteed these parse, but keep the compiler happy. - if (!uren.ok || !punten.ok) return err(errors); + // validateStep guaranteed uren parses, but keep the compiler happy. + if (!uren.ok) return err(errors); const werktBuitenland = a.buitenlandGewerkt === 'ja'; const buitenland = parseUren(a.buitenlandseUren ?? ''); + // Punten are only collected when aanvullende scholing was gevolgd. + const punten = a.scholingGevolgd === 'ja' ? parseUren(a.punten ?? '') : undefined; return ok({ werktBuitenland, land: werktBuitenland ? a.land : undefined, buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined, uren: uren.value, - aanvullendeScholing: lageUren(a) ? a.scholingGevolgd === 'ja' : undefined, - punten: punten.value, + aanvullendeScholing: lageUren(a, scholingThreshold) ? a.scholingGevolgd === 'ja' : undefined, + punten: punten?.ok ? punten.value : undefined, }); } export function setAnswer(s: IntakeState, key: keyof Answers, value: string): IntakeState { if (s.tag !== 'Answering') return s; - const answers = { ...s.answers, [key]: value }; - // Editing an earlier answer can shrink the step list; clamp the cursor. - const cursor = Math.min(s.cursor, visibleSteps(answers).length - 1); - return { ...s, answers, cursor }; + // Steps are fixed, so editing an answer never moves the cursor — it only + // reveals/hides inline questions within the current step. + return { ...s, answers: { ...s.answers, [key]: value } }; } export function next(s: IntakeState): IntakeState { if (s.tag !== 'Answering') return s; - const r = validateStep(currentStep(s), s.answers); + const r = validateStep(currentStep(s), s.answers, s.scholingThreshold); if (!r.ok) return { ...s, errors: r.error }; - const last = visibleSteps(s.answers).length - 1; - return { ...s, cursor: Math.min(s.cursor + 1, last), errors: {} }; + return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} }; +} + +/** Apply a server-owned policy value (e.g. the scholing threshold). */ +export function setPolicy(s: IntakeState, scholingThreshold: number): IntakeState { + return s.tag === 'Answering' ? { ...s, scholingThreshold } : s; } export function back(s: IntakeState): IntakeState { @@ -147,7 +152,7 @@ export function back(s: IntakeState): IntakeState { export function submit(s: IntakeState): IntakeState { if (s.tag !== 'Answering') return s; - const r = validateAll(s.answers); + const r = validateAll(s.answers, s.scholingThreshold); return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error }; } @@ -164,6 +169,7 @@ export type IntakeMsg = | { tag: 'Retry' } | { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error: string } + | { tag: 'SetPolicy'; scholingThreshold: number } | { tag: 'Seed'; state: IntakeState }; export function reduce(s: IntakeState, m: IntakeMsg): IntakeState { @@ -182,6 +188,8 @@ export function reduce(s: IntakeState, m: IntakeMsg): IntakeState { return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s; case 'SubmitFailed': return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s; + case 'SetPolicy': + return setPolicy(s, m.scholingThreshold); case 'Seed': return m.state; default: diff --git a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts index 39dcfef..2719752 100644 --- a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts +++ b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts @@ -33,7 +33,10 @@ import { submitHerregistratie } from '@herregistratie/application/submit-herregi - Volgende +
+ Volgende + Annuleren +
} @else { Vorige Herregistratie aanvragen + Annuleren } @@ -95,6 +99,11 @@ export class HerregistratieWizardComponent { this.runIfSubmitting(); } + /** Reset the wizard to a fresh, empty start. */ + restart() { + this.dispatch({ tag: 'Seed', state: initial }); + } + /** The effect: when we entered Submitting, call the backend command, flip the optimistic cross-page flag, then dispatch the result (and commit/rollback). */ private async runIfSubmitting() { diff --git a/src/app/herregistratie/ui/herregistratie.page.ts b/src/app/herregistratie/ui/herregistratie.page.ts index a78b02b..81480a3 100644 --- a/src/app/herregistratie/ui/herregistratie.page.ts +++ b/src/app/herregistratie/ui/herregistratie.page.ts @@ -4,11 +4,11 @@ import { AlertComponent } from '@shared/ui/alert/alert.component'; import { ASYNC } from '@shared/ui/async/async.component'; import { map } from '@shared/application/remote-data'; import { BigProfileStore } from '@registratie/application/big-profile.store'; -import { isHerregistratieEligible } from '@registratie/domain/registration.policy'; import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component'; -/** A whole new page built from existing building blocks. Eligibility is a pure - domain rule (registration.policy) read from the shared profile state. */ +/** A whole new page built from existing building blocks. Eligibility is a + SERVER-computed decision read from the aggregated view — the frontend renders + it, it does not recompute the rule. */ @Component({ selector: 'app-herregistratie-page', imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent], @@ -35,8 +35,9 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie }) export class HerregistratiePage { private store = inject(BigProfileStore); - // Derive a boolean RemoteData from the combined profile via map (pure). + // The eligibility decision comes from the server (decisions block), not a + // client-side rule. The UI just reads the boolean. protected eligibility = computed(() => - map(this.store.profile(), (p) => isHerregistratieEligible(p.registration, new Date())), + map(this.store.decisions(), (d) => d.eligibleForHerregistratie), ); } diff --git a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts index 4a42f1e..c835d3f 100644 --- a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts +++ b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts @@ -1,4 +1,5 @@ -import { Component, computed, effect, inject, input } from '@angular/core'; +import { Component, computed, effect, inject, input, untracked } from '@angular/core'; +import { httpResource } from '@angular/common/http'; import { FormsModule } from '@angular/forms'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; @@ -15,11 +16,14 @@ import { StepId, initial, reduce, - visibleSteps, + STEPS, + lageUren, + SCHOLING_THRESHOLD_DEFAULT, } from '@herregistratie/domain/intake.machine'; +import { IntakePolicyDto } from '@herregistratie/contracts/intake-policy.dto'; import { submitIntake } from '@herregistratie/application/submit-intake'; -const STORAGE_KEY = 'intake-v1'; // ponytail: bump the suffix if the Answers shape changes; no migration. +const STORAGE_KEY = 'intake-v3'; // ponytail: bump the suffix if the persisted state shape changes; no migration. const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }]; /** Organism: a BRANCHING intake questionnaire. All state lives in one signal @@ -33,38 +37,38 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }]; template: ` @switch (state().tag) { @case ('Answering') { -

Stap {{ cursor() + 1 }} van {{ steps().length }}

+

Stap {{ cursor() + 1 }} van {{ steps.length }}

@switch (step()) { @case ('buitenland') { - + + @if (answers().buitenlandGewerkt === 'ja') { + + + + + + + } } - @case ('buitenlandDetails') { - - - - - - - } - @case ('uren') { + @case ('werk') { - } - @case ('scholing') { - - - - } - @case ('punten') { - - - + @if (scholingZichtbaar()) { + + + + } + @if (answers().scholingGevolgd === 'ja') { + + + + } } @case ('review') { Controleer uw antwoorden en dien de aanvraag in. @@ -75,10 +79,12 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
Buitenlandse uren
{{ answers().buitenlandseUren }}
}
Uren NL
{{ answers().uren }}
- @if (steps().includes('scholing')) { + @if (scholingZichtbaar()) {
Aanvullende scholing
{{ answers().scholingGevolgd }}
} -
Nascholingspunten
{{ answers().punten }}
+ @if (answers().scholingGevolgd === 'ja') { +
Nascholingspunten
{{ answers().punten }}
+ } } } @@ -87,6 +93,7 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }]; Vorige } {{ step() === 'review' ? 'Aanvraag indienen' : 'Volgende' }} + Annuleren } @@ -112,6 +119,12 @@ export class IntakeWizardComponent { private profile = inject(BigProfileStore); private store = createStore(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(() => 'mock/intake-policy.json', { + defaultValue: { scholingThreshold: SCHOLING_THRESHOLD_DEFAULT }, + }); + /** Optional seed so Storybook / the showcase can mount any state directly. */ seed = input(initial); @@ -120,14 +133,18 @@ export class IntakeWizardComponent { readonly dispatch = this.store.dispatch; private answering = computed(() => (this.state().tag === 'Answering' ? (this.state() as Extract) : null)); - /** Public so the showcase can render the live step list next to the wizard. */ - readonly steps = computed(() => visibleSteps(this.answering()?.answers ?? {})); + /** Public so the showcase can render the (fixed) step list next to the wizard. */ + readonly steps = STEPS; protected cursor = computed(() => this.answering()?.cursor ?? 0); protected answers = computed(() => this.answering()?.answers ?? {}); - protected step = computed(() => this.steps()[Math.min(this.cursor(), this.steps().length - 1)]); + protected step = computed(() => 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).error : '')); - protected err = (k: StepId) => this.answering()?.errors[k] ?? ''; + protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? ''; protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value }); constructor() { @@ -140,6 +157,13 @@ export class IntakeWizardComponent { if (s.tag === 'Answering') localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); else localStorage.removeItem(STORAGE_KEY); }); + // Apply the server-owned threshold into machine state as it arrives. Track + // only the policy value; untrack the dispatch (it reads the state signal + // internally, which would otherwise make this effect loop on its own write). + effect(() => { + const scholingThreshold = this.policyRes.value().scholingThreshold; + untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold })); + }); } private restore(): IntakeState | null { diff --git a/src/app/herregistratie/ui/intake-wizard/intake-wizard.stories.ts b/src/app/herregistratie/ui/intake-wizard/intake-wizard.stories.ts index a6b0b46..c12d462 100644 --- a/src/app/herregistratie/ui/intake-wizard/intake-wizard.stories.ts +++ b/src/app/herregistratie/ui/intake-wizard/intake-wizard.stories.ts @@ -16,12 +16,14 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const answering = (answers: Answers, cursor = 0): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {} }); +const answering = (answers: Answers, cursor = 0): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {}, scholingThreshold: 1000 }); export const Start: Story = { args: { seed: answering({}) } }; -export const AbroadBranch: Story = { args: { seed: answering({ buitenlandGewerkt: 'ja' }, 1) } }; -export const LowHoursScholing: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '500' }, 2) } }; -export const Review: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: '200' }, 3) } }; +// Inline reveal: country/hours appear within the buitenland step (cursor 0). +export const AbroadBranch: Story = { args: { seed: answering({ buitenlandGewerkt: 'ja' }, 0) } }; +// Inline reveal: the scholing question appears within the werk step (cursor 1). +export const LowHoursScholing: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '500' }, 1) } }; +export const Review: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: '200' }, 2) } }; export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } }; export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } }; export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } }; diff --git a/src/app/registratie/application/big-profile.store.ts b/src/app/registratie/application/big-profile.store.ts index 0ebeda3..ccffdf8 100644 --- a/src/app/registratie/application/big-profile.store.ts +++ b/src/app/registratie/application/big-profile.store.ts @@ -1,9 +1,10 @@ import { Injectable, computed, inject, signal } from '@angular/core'; -import { RemoteData, fromResource, map2 } from '@shared/application/remote-data'; +import { RemoteData, fromResource, map } from '@shared/application/remote-data'; import { Aantekening } from '../domain/registration'; import { BigProfile } from '../domain/big-profile'; +import { HerregistratieDecisions, DashboardView } from '../contracts/dashboard-view.dto'; import { BigRegisterAdapter } from '../infrastructure/big-register.adapter'; -import { BrpAdapter } from '../infrastructure/brp.adapter'; +import { DashboardViewAdapter, parseDashboardView } from '../infrastructure/dashboard-view.adapter'; type Err = Error | undefined; @@ -13,29 +14,32 @@ type Err = Error | undefined; * (created here, in the required injection context) and exposes them as * RemoteData signals. * - * The headline trick: `profile` combines TWO independent services — the - * BIG-register and the BRP — into ONE RemoteData via map2. A page renders a - * single state (loading / error / loaded), never juggling three. + * The dashboard data now comes from ONE screen-shaped ("BFF-lite") call that + * returns registration + person + server-computed `decisions`. One request → one + * consistent snapshot, instead of stitching three independently loading/erroring + * resources together client-side. See docs/architecture/0001-bff-lite-decision-dtos.md. */ @Injectable({ providedIn: 'root' }) export class BigProfileStore { private big = inject(BigRegisterAdapter); - private brp = inject(BrpAdapter); + private viewAdapter = inject(DashboardViewAdapter); - private registrationRes = this.big.registrationResource(); + private viewRes = this.viewAdapter.dashboardViewResource(); private aantekeningenRes = this.big.aantekeningenResource(); - private personRes = this.brp.personResource(); - /** BIG-register + BRP folded into one state. */ - readonly profile = computed>(() => - map2( - fromResource(this.registrationRes), - fromResource(this.personRes), - // httpResource types value as T | undefined; in the Success branch it is - // always present, so narrowing here is safe. - (registration, person) => ({ registration: registration!, person: person! }), - ), - ); + /** The aggregated view, validated at the trust boundary (DTO → domain). */ + private view = computed>(() => { + const rd = fromResource(this.viewRes); + if (rd.tag !== 'Success') return rd; + const parsed = parseDashboardView(rd.value); + return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) }; + }); + + /** Registration + person, from the single aggregated call. */ + readonly profile = computed>(() => map(this.view(), (v) => v.profile)); + + /** Server-computed decisions (e.g. herregistratie eligibility) — rendered, not recomputed. */ + readonly decisions = computed>(() => map(this.view(), (v) => v.decisions)); /** Specialisms/notes stay a separate stream (they have their own empty state). */ readonly aantekeningen = computed>(() => @@ -52,7 +56,7 @@ export class BigProfileStore { } confirmHerregistratie() { this.pending.set(false); - this.registrationRes.reload(); // invalidate: re-fetch the now-updated registration + this.viewRes.reload(); // invalidate: re-fetch the now-updated view (registration + decisions) } rollbackHerregistratie() { this.pending.set(false); // submission failed — undo the optimistic flag diff --git a/src/app/registratie/application/submit-registratie.ts b/src/app/registratie/application/submit-registratie.ts new file mode 100644 index 0000000..db1253c --- /dev/null +++ b/src/app/registratie/application/submit-registratie.ts @@ -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> { + 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); +} diff --git a/src/app/registratie/contracts/brp-address.dto.ts b/src/app/registratie/contracts/brp-address.dto.ts new file mode 100644 index 0000000..c82e06d --- /dev/null +++ b/src/app/registratie/contracts/brp-address.dto.ts @@ -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 }; +} diff --git a/src/app/registratie/contracts/dashboard-view.dto.ts b/src/app/registratie/contracts/dashboard-view.dto.ts new file mode 100644 index 0000000..790a3d3 --- /dev/null +++ b/src/app/registratie/contracts/dashboard-view.dto.ts @@ -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; +} diff --git a/src/app/registratie/contracts/duo-diplomas.dto.ts b/src/app/registratie/contracts/duo-diplomas.dto.ts new file mode 100644 index 0000000..1db0a14 --- /dev/null +++ b/src/app/registratie/contracts/duo-diplomas.dto.ts @@ -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'; +} diff --git a/src/app/registratie/domain/registratie-wizard.machine.spec.ts b/src/app/registratie/domain/registratie-wizard.machine.spec.ts new file mode 100644 index 0000000..788f879 --- /dev/null +++ b/src/app/registratie/domain/registratie-wizard.machine.spec.ts @@ -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, 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 = { ...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'); + }); +}); diff --git a/src/app/registratie/domain/registratie-wizard.machine.ts b/src/app/registratie/domain/registratie-wizard.machine.ts new file mode 100644 index 0000000..85acd7a --- /dev/null +++ b/src/app/registratie/domain/registratie-wizard.machine.ts @@ -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; // 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; +} + +/** 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; +} + +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): 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 { + 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 = {}; + 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 { + 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): 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); + } +} diff --git a/src/app/registratie/domain/registration.policy.ts b/src/app/registratie/domain/registration.policy.ts index 91a29cb..8b1579a 100644 --- a/src/app/registratie/domain/registration.policy.ts +++ b/src/app/registratie/domain/registration.policy.ts @@ -34,7 +34,10 @@ export function herregistratieDeadline(reg: Registration): Date | null { } /** A registration may apply for herregistratie only while active and within the - window before its deadline. A struck-off or suspended registration may not. */ + window before its deadline. A struck-off or suspended registration may not. + SERVER-OWNED RULE: this now runs on the backend (BFF), which ships the result + as `decisions.eligibleForHerregistratie` in the dashboard view. Kept here as + the reference implementation + unit test; the frontend no longer calls it. */ export function isHerregistratieEligible(reg: Registration, today: Date, windowMonths = 12): boolean { const deadline = herregistratieDeadline(reg); if (!deadline) return false; diff --git a/src/app/registratie/domain/value-objects/email.spec.ts b/src/app/registratie/domain/value-objects/email.spec.ts new file mode 100644 index 0000000..cfe67ac --- /dev/null +++ b/src/app/registratie/domain/value-objects/email.spec.ts @@ -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); + }); +}); diff --git a/src/app/registratie/domain/value-objects/email.ts b/src/app/registratie/domain/value-objects/email.ts new file mode 100644 index 0000000..fb9369e --- /dev/null +++ b/src/app/registratie/domain/value-objects/email.ts @@ -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; + +export function parseEmail(raw: string): Result { + 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); +} diff --git a/src/app/registratie/infrastructure/big-register.adapter.ts b/src/app/registratie/infrastructure/big-register.adapter.ts index 24fa549..01e2e52 100644 --- a/src/app/registratie/infrastructure/big-register.adapter.ts +++ b/src/app/registratie/infrastructure/big-register.adapter.ts @@ -1,19 +1,18 @@ import { Injectable } from '@angular/core'; import { httpResource } from '@angular/common/http'; -import { Registration, Aantekening } from '../domain/registration'; +import { Aantekening } from '../domain/registration'; /** * Infrastructure adapter for the BIG-register source. Exposes signal-based * resources (Angular's httpResource); each returns a Resource with * status()/value()/error()/reload(). Call from an injection context * (a field initializer in the store). + * + * Note: registration + person are now served via the aggregated dashboard-view + * endpoint (see DashboardViewAdapter). Only the notes stream remains separate. */ @Injectable({ providedIn: 'root' }) export class BigRegisterAdapter { - registrationResource() { - return httpResource(() => 'mock/registration.json'); - } - aantekeningenResource() { return httpResource(() => 'mock/notes.json', { defaultValue: [] }); } diff --git a/src/app/registratie/infrastructure/brp.adapter.spec.ts b/src/app/registratie/infrastructure/brp.adapter.spec.ts new file mode 100644 index 0000000..bb89215 --- /dev/null +++ b/src/app/registratie/infrastructure/brp.adapter.spec.ts @@ -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); + }); +}); diff --git a/src/app/registratie/infrastructure/brp.adapter.ts b/src/app/registratie/infrastructure/brp.adapter.ts index dec04a3..ae7a198 100644 --- a/src/app/registratie/infrastructure/brp.adapter.ts +++ b/src/app/registratie/infrastructure/brp.adapter.ts @@ -1,11 +1,34 @@ import { Injectable } from '@angular/core'; import { httpResource } from '@angular/common/http'; -import { Person } from '../domain/person'; +import { Result, ok, err } from '@shared/kernel/fp'; +import { BrpAddressDto } from '@registratie/contracts/brp-address.dto'; -/** Infrastructure adapter for the BRP (Basisregistratie Personen) source. */ +/** + * Infrastructure adapter for the BRP address lookup, reached only through our own + * ("BFF-lite") endpoint — the anti-corruption boundary. In this POC the endpoint + * is a static mock; pointing at a real backend touches only this file + the DTO. + */ @Injectable({ providedIn: 'root' }) export class BrpAdapter { - personResource() { - return httpResource(() => 'mock/brp.json'); + // Typed as the DTO for ergonomics, but the value is untrusted JSON until + // parseBrpAddress validates it. + adresResource() { + return httpResource(() => '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 { + if (typeof json !== 'object' || json === null) return err('brp-address: not an object'); + const dto = json as Partial; + 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 }); +} diff --git a/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts b/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts new file mode 100644 index 0000000..39b3b5f --- /dev/null +++ b/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts @@ -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); + }); +}); diff --git a/src/app/registratie/infrastructure/dashboard-view.adapter.ts b/src/app/registratie/infrastructure/dashboard-view.adapter.ts new file mode 100644 index 0000000..995cfde --- /dev/null +++ b/src/app/registratie/infrastructure/dashboard-view.adapter.ts @@ -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(() => '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 { + if (typeof json !== 'object' || json === null) return err('dashboard-view: not an object'); + const dto = json as Partial; + + 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 }, + }); +} diff --git a/src/app/registratie/infrastructure/duo.adapter.spec.ts b/src/app/registratie/infrastructure/duo.adapter.spec.ts new file mode 100644 index 0000000..24e8276 --- /dev/null +++ b/src/app/registratie/infrastructure/duo.adapter.spec.ts @@ -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 + }); +}); diff --git a/src/app/registratie/infrastructure/duo.adapter.ts b/src/app/registratie/infrastructure/duo.adapter.ts new file mode 100644 index 0000000..7e49a02 --- /dev/null +++ b/src/app/registratie/infrastructure/duo.adapter.ts @@ -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(() => '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; + 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 { + if (typeof json !== 'object' || json === null) return err('duo-lookup: not an object'); + const dto = json as Partial; + 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; + 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 }); +} diff --git a/src/app/registratie/ui/dashboard.page.ts b/src/app/registratie/ui/dashboard.page.ts index d395e4b..55b6b3b 100644 --- a/src/app/registratie/ui/dashboard.page.ts +++ b/src/app/registratie/ui/dashboard.page.ts @@ -56,6 +56,9 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';

+ Inschrijven in het BIG-register (registratiewizard) → +

+

Gegevens bekijken of een wijziging doorgeven →

diff --git a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts new file mode 100644 index 0000000..af5d25c --- /dev/null +++ b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -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 ; 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') { + +

{{ stepTitle() }}

+
+ @switch (step()) { + @case ('adres') { + @if (adresStatus() === 'laden') { + + } @else { + @switch (adresStatus()) { + @case ('gevonden') { + Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan. + } + @case ('geen') { + We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in. + } + @case ('fout') { + We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in. + } + } + + + + + + + + + + + + + @if (draft().correspondentie === 'email') { + + + + } + } + } + @case ('beroep') { + + + + + + + @if (handmatigActief()) { + Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig beoordeeld. + + + + } @else if (draft().beroep) { +
+ +
+ } + + @for (q of actieveVragen($any(data)); track q.id) { + + @if (q.type === 'ja-nee') { + + } @else { + + } + + } +
+ + + +
+ } + @case ('controle') { + Controleer uw gegevens en dien de registratie in. +
+ + + + @if (draft().correspondentie === 'email') { + + } + + + @for (item of samenvattingVragen(); track item.vraag) { + + } +
+
+ Adres wijzigen + Diploma wijzigen +
+ } + } +
+ @if (cursor() > 0) { + Vorige + } + {{ step() === 'controle' ? 'Registratie indienen' : 'Volgende' }} + Annuleren +
+ + } + @case ('Indienen') { + Uw registratie wordt verwerkt… + } + @case ('Ingediend') { + Uw registratie is ontvangen. Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie. +
+ Nieuwe registratie starten +
+ } + @case ('Mislukt') { + Het indienen is niet gelukt: {{ failedError() }} +
+ Opnieuw proberen +
+ } + } + `, +}) +export class RegistratieWizardComponent { + private brp = inject(BrpAdapter); + private duo = inject(DuoAdapter); + private store = createStore(initial, reduce); + + protected adresRes = this.brp.adresResource(); + private diplomasRes = this.duo.diplomasResource(); + + /** Optional seed so Storybook / tests can mount any state directly. */ + seed = input(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>('stepHeading'); + + private invullen = computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract) : null)); + protected cursor = computed(() => this.invullen()?.cursor ?? 0); + protected draft = computed(() => this.invullen()?.draft ?? { antwoorden: {} }); + protected step = computed(() => 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).referentie : '')); + protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract).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>(() => { + 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 template variable isn't in scope. */ + private duoData = computed(() => { + 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 }); + } +} diff --git a/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts b/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts new file mode 100644 index 0000000..c715436 --- /dev/null +++ b/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts @@ -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 = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', adresHerkomst: 'brp', correspondentie: 'post' }; +const filled: Partial = { ...adres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo', vraagIds: [] }; + +const invullen = (draft: Partial, 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 = { + title: 'Registratie/RegistratieWizard', + component: RegistratieWizardComponent, + decorators: [applicationConfig({ providers: [provideHttpClient()] })], +}; +export default meta; +type Story = StoryObj; + +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' } } }; diff --git a/src/app/registratie/ui/registratie.page.ts b/src/app/registratie/ui/registratie.page.ts new file mode 100644 index 0000000..3d3510a --- /dev/null +++ b/src/app/registratie/ui/registratie.page.ts @@ -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: ` + + + 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. + +
+ +
+
+ `, +}) +export class RegistratiePage {} diff --git a/src/app/shared/layout/breadcrumb/breadcrumb.component.ts b/src/app/shared/layout/breadcrumb/breadcrumb.component.ts new file mode 100644 index 0000000..7c17e3d --- /dev/null +++ b/src/app/shared/layout/breadcrumb/breadcrumb.component.ts @@ -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: ` + + `, +}) +export class BreadcrumbComponent { + items = input.required(); +} diff --git a/src/app/shared/layout/breadcrumb/breadcrumb.stories.ts b/src/app/shared/layout/breadcrumb/breadcrumb.stories.ts new file mode 100644 index 0000000..0e88986 --- /dev/null +++ b/src/app/shared/layout/breadcrumb/breadcrumb.stories.ts @@ -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 = { + title: 'Layout/Breadcrumb', + component: BreadcrumbComponent, + decorators: [applicationConfig({ providers: [provideRouter([])] })], + render: (args) => ({ + props: args, + template: ``, + }), +}; +export default meta; +type Story = StoryObj; + +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' }] }, +}; diff --git a/src/app/shared/layout/page-shell/page-shell.component.ts b/src/app/shared/layout/page-shell/page-shell.component.ts index 465c217..42a9565 100644 --- a/src/app/shared/layout/page-shell/page-shell.component.ts +++ b/src/app/shared/layout/page-shell/page-shell.component.ts @@ -1,22 +1,31 @@ import { Component, input } from '@angular/core'; import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { LinkComponent } from '@shared/ui/link/link.component'; +import { BreadcrumbComponent, BreadcrumbItem } from '@shared/layout/breadcrumb/breadcrumb.component'; -/** Template: standard page body — optional back-link, a heading, optional intro, - and projected content. Rendered inside the persistent ShellComponent via the - router outlet, so it owns only the content (not the header/footer chrome). */ +/** Template: standard page body — optional breadcrumb, optional back-link, a + heading, optional intro, and projected content. Rendered inside the persistent + ShellComponent via the router outlet, so it owns only the content (not chrome). */ @Component({ selector: 'app-page-shell', - imports: [HeadingComponent, LinkComponent], - styles: [':host{display:block}'], + imports: [HeadingComponent, LinkComponent, BreadcrumbComponent], + styles: [` + :host{display:block} + .body--narrow{max-inline-size:var(--app-form-narrow)} + .crumb{margin-block-end:var(--rhc-space-max-xl)} + .intro{margin-block-end:var(--rhc-space-max-2xl)} + `], template: ` -
+
+ @if (breadcrumb()) { + + } @if (backLink()) {

← {{ backLabel() }}

} {{ heading() }} @if (intro()) { -

{{ intro() }}

+

{{ intro() }}

}
@@ -28,4 +37,5 @@ export class PageShellComponent { backLink = input(); backLabel = input('Terug naar overzicht'); width = input<'default' | 'narrow'>('default'); + breadcrumb = input(); } diff --git a/src/app/shared/layout/shell/shell.component.ts b/src/app/shared/layout/shell/shell.component.ts index 8548049..1bfb91a 100644 --- a/src/app/shared/layout/shell/shell.component.ts +++ b/src/app/shared/layout/shell/shell.component.ts @@ -8,13 +8,19 @@ import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.comp @Component({ selector: 'app-shell', imports: [RouterOutlet, SiteHeaderComponent, SiteFooterComponent], - styles: [':host{display:block}'], + styles: [` + :host{display:block} + .skip{position:absolute;left:var(--app-skip-link-offset)} + .layout{min-block-size:100vh;align-items:stretch} + .main{flex:1;inline-size:100%;box-sizing:border-box} + .content{max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-3xl) var(--rhc-space-max-2xl);box-sizing:border-box} + `], template: ` - Naar de inhoud -
+ +
-
-
+
+
diff --git a/src/app/shared/layout/site-footer/site-footer.component.ts b/src/app/shared/layout/site-footer/site-footer.component.ts index 5467445..53a733f 100644 --- a/src/app/shared/layout/site-footer/site-footer.component.ts +++ b/src/app/shared/layout/site-footer/site-footer.component.ts @@ -3,13 +3,18 @@ import { Component } from '@angular/core'; /** Organism: site footer. */ @Component({ selector: 'app-site-footer', - styles: [':host{display:block}'], + styles: [` + :host{display:block} + .bar{background:var(--rhc-color-donkerblauw-700);color:var(--rhc-color-wit);margin-block-start:var(--rhc-space-max-5xl);inline-size:100%} + .inner{max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-2xl);display:flex;gap:var(--rhc-space-max-3xl);flex-wrap:wrap;box-sizing:border-box} + .end{margin-inline-start:auto} + `], template: ` -