diff --git a/CLAUDE.md b/CLAUDE.md index 0721cd1..48fbb33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,8 @@ advisories are pinned via `package.json` `overrides`; the shipped bundle audits ### 1. DDD: contexts then layers, dependencies point inward `src/app///`. Contexts: `shared`, `auth`, `registratie`, -`herregistratie`, `showcase` (teaching page, not a feature). +`herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching +page, not a feature; **sanctioned** to read every context — nothing imports it). | Layer | Job | Angular allowed? | | ----------------- | ----------------------------------------- | -------------------------------- | @@ -45,9 +46,11 @@ advisories are pinned via `package.json` `overrides`; the shipped bundle audits | `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. +`shared`; never the reverse. `ui`/`layout` never import `infrastructure` directly +(reach data through an application store/command) — lint-enforced. Cross-context only +`herregistratie → registratie → shared`, `auth → shared`, `brief → shared`. Imports use +aliases as direction statements: `@shared/* @auth/* @registratie/* @herregistratie/* +@brief/*`. `domain/` imports nothing from Angular. ### 2. Atomic design: folder = layer diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4338e5c..4b1fa7a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,7 +16,7 @@ re-registration — "herregistratie"). --- -## 1. The big picture: three "contexts", four "layers" +## 1. The big picture: six "contexts", five "layers" The code is split first by **business area** (a "bounded context" in DDD terms), then inside each area by **layer**. @@ -27,9 +27,14 @@ src/app/ auth/ logging in / the current session registratie/ the user's BIG registration + personal data herregistratie/ the re-registration application flow - showcase/ a teaching page; not a real feature + brief/ letter-composition teaching slice + showcase/ a teaching page; not a real feature (may read every context) ``` +`showcase/` is a **sanctioned exception** to the direction rules: its whole point is +showing multiple contexts side by side, so it may import any context. Nothing imports +`showcase`. (Enforced in `eslint.config.mjs`; same precedent as the `debug-state` panel.) + ### The atomic-design hierarchy, visualised The UI is built bottom-up: tiny **atoms** combine into **molecules**, which combine @@ -53,7 +58,7 @@ organism** (`intake-wizard`) — everything else (`form-field`, `text-input`, `b `alert`, `spinner`, the page shell) was reused unchanged. That is the payoff of the hierarchy. -Inside a context you'll see the same four folders. They answer four different +Inside a context you'll see the same five folders. They answer five different questions: | Layer | Answers… | May import Angular? | Example here | @@ -61,14 +66,18 @@ questions: | `domain/` | What are the business rules and data? | **No** (pure TS) | `registration.ts`, `registration.policy.ts` | | `application/` | How do we coordinate a task / state? | Yes (signals) | `big-profile.store.ts` | | `infrastructure/` | Where does data come from? | Yes (HTTP) | `big-register.adapter.ts`, `brp.adapter.ts` | +| `contracts/` | What's the FE⇄BE wire shape? | **No** (pure DTOs) | `dashboard-view.dto.ts` | | `ui/` | How does it look? | Yes (components) | `dashboard.page.ts` | **The one rule that keeps it sane: dependencies only point _inward_.** UI may use application, application may use domain, everyone may use `shared`. Never the -other way around. The `domain/` layer imports nothing from Angular, so the -business rules are plain functions you can read and test in isolation. +other way around. In particular **`ui/` and `layout/` never import `infrastructure/` +directly** — they reach data through an application store or command (lint-enforced). +The `domain/` layer imports nothing from Angular, so the business rules are plain +functions you can read and test in isolation. -Allowed direction: `herregistratie → registratie → shared`, `auth → shared`. +Allowed direction: `herregistratie → registratie → shared`, `auth → shared`, +`brief → shared` (`showcase` may read every context; see above). ### Why the `shared/` kernel is split too @@ -79,7 +88,7 @@ Allowed direction: `herregistratie → registratie → shared`, `auth → shared - `shared/infrastructure/` — the demo HTTP interceptor. Imports use path aliases so they read as direction statements: -`@shared/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`. +`@shared/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`, `@brief/*`. --- diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 9ec1f13..ef1f206 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -43,7 +43,7 @@ for its existing violations, so every WP ends green. | [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done | | [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done | | [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done | -| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | todo | +| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done | | [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | todo | | [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo | | [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo | diff --git a/docs/backlog/WP-04-ui-not-infrastructure.md b/docs/backlog/WP-04-ui-not-infrastructure.md index 05c5400..8a8467c 100644 --- a/docs/backlog/WP-04-ui-not-infrastructure.md +++ b/docs/backlog/WP-04-ui-not-infrastructure.md @@ -1,6 +1,6 @@ # WP-04 — Boundaries II: `ui ↛ infrastructure` + showcase sanction -Status: todo +Status: done (pending commit) Phase: 0 — enforcement & gates ## Why @@ -54,11 +54,13 @@ Phase 0 — a pure move of wiring, no behavior change. ## Acceptance criteria -- [ ] Rule active; lint green; no disables beyond the documented showcase + debug-state - exemptions. -- [ ] No `**/ui/**` file imports from `**/infrastructure/**`. -- [ ] Both wizards behave unchanged (specs pass; manual smoke). -- [ ] CLAUDE.md and ARCHITECTURE.md list 6 contexts / 5 layers. +- [x] Rule active; lint green; no disables beyond the documented showcase + debug-state + exemptions. (Probed: a ui→infra import errors.) +- [x] No `**/ui/**` file imports from `**/infrastructure/**` (production; stories/specs + exempted — test scaffolding wires the real client). +- [x] Both wizards behave unchanged (178 specs pass; storybook a11y suite mounts both + wizards green — behaviour is a pure wiring move behind root facades). +- [x] CLAUDE.md and ARCHITECTURE.md list 6 contexts / 5 layers. ## Verification diff --git a/documentation.json b/documentation.json index 30b04c1..a9301af 100644 --- a/documentation.json +++ b/documentation.json @@ -7165,6 +7165,63 @@ "extends": [], "type": "injectable" }, + { + "name": "IntakePolicyStore", + "id": "injectable-IntakePolicyStore-49ee4b3edc4a018bdf8e9132222e4e4cbbadfeb337fe1e50c150950cc07dc65399c9d125334a543fa2db459802f99e8ece8731e9aac30c4feb1b3043f5185072", + "file": "src/app/herregistratie/application/intake-policy.store.ts", + "properties": [ + { + "name": "policy", + "defaultValue": "inject(IntakePolicyAdapter)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 15, + "modifierKind": [ + 123 + ] + }, + { + "name": "policyRes", + "defaultValue": "this.policy.policyResource()", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 16, + "modifierKind": [ + 123 + ] + }, + { + "name": "scholingThreshold", + "defaultValue": "computed(() => this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 18, + "modifierKind": [ + 148 + ] + } + ], + "methods": [], + "deprecated": false, + "deprecationMessage": "", + "description": "

Application-layer facade for the server-owned intake policy (the scholing\nthreshold config value). It owns the httpResource (created here, in the required\ninjection context) and exposes the threshold as a derived signal, falling back to\nthe domain default until the backend answers. The UI reaches the network through\napplication/, never infrastructure/ directly (CLAUDE.md §1). The backend stays the\nauthority and re-validates on submit.

\n", + "rawdescription": "\n\nApplication-layer facade for the server-owned intake policy (the scholing\nthreshold config value). It owns the httpResource (created here, in the required\ninjection context) and exposes the threshold as a derived signal, falling back to\nthe domain default until the backend answers. The UI reaches the network through\napplication/, never infrastructure/ directly (CLAUDE.md §1). The backend stays the\nauthority and re-validates on submit.\n", + "sourceCode": "import { Injectable, computed, inject } from '@angular/core';\nimport { SCHOLING_THRESHOLD_DEFAULT } from '../domain/intake.machine';\nimport { IntakePolicyAdapter } from '../infrastructure/intake-policy.adapter';\n\n/**\n * Application-layer facade for the server-owned intake policy (the scholing\n * threshold config value). It owns the httpResource (created here, in the required\n * injection context) and exposes the threshold as a derived signal, falling back to\n * the domain default until the backend answers. The UI reaches the network through\n * application/, never infrastructure/ directly (CLAUDE.md §1). The backend stays the\n * authority and re-validates on submit.\n */\n@Injectable({ providedIn: 'root' })\nexport class IntakePolicyStore {\n private policy = inject(IntakePolicyAdapter);\n private policyRes = this.policy.policyResource();\n\n readonly scholingThreshold = computed(() => this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT);\n}\n", + "extends": [], + "type": "injectable" + }, { "name": "KeepaliveTransport", "id": "injectable-KeepaliveTransport-ec4f738d45fc556f5419cef06bad97f2f12a90de63211f79059088a2827ea9ee851b1544ec2110033a7797b65eaeb7caf0d4508fd122384394eaf9a145ffa7f6", @@ -7280,6 +7337,135 @@ "extends": [], "type": "injectable" }, + { + "name": "RegistratieLookupStore", + "id": "injectable-RegistratieLookupStore-29413ada1a913b946a0b54cc99e8dd45cba66699dfd6743b58d37aab238ba2d1339b7466259603de71086624c449c634a50b0d0f5d98394bf7d53cc8c611b7eb", + "file": "src/app/registratie/application/registratie-lookup.store.ts", + "properties": [ + { + "name": "adresRes", + "defaultValue": "this.brp.adresResource()", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 22, + "modifierKind": [ + 123 + ] + }, + { + "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": 28, + "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": [ + 148 + ] + }, + { + "name": "brp", + "defaultValue": "inject(BrpAdapter)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 19, + "modifierKind": [ + 123 + ] + }, + { + "name": "diplomasRes", + "defaultValue": "this.duo.diplomasResource()", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 23, + "modifierKind": [ + 123 + ] + }, + { + "name": "duo", + "defaultValue": "inject(DuoAdapter)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 20, + "modifierKind": [ + 123 + ] + }, + { + "name": "duoLookup", + "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": 47, + "rawdescription": "\nThe DUO lookup (diplomas + manual fallback), validated at the trust boundary.", + "modifierKind": [ + 148 + ] + }, + { + "name": "prefillAdres", + "defaultValue": "computed<{ straat: string; postcode: string; woonplaats: string } | null>(() => {\n const json = this.adresRes.value();\n if (json === undefined) return null;\n const parsed = parseBrpAddress(json);\n return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null;\n })", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "

The address to prefill the draft with, once BRP resolves with a found address;\nnull otherwise (loading, error, no address, malformed).

\n", + "line": 39, + "rawdescription": "\nThe address to prefill the draft with, once BRP resolves with a found address;\nnull otherwise (loading, error, no address, malformed).", + "modifierKind": [ + 148 + ] + } + ], + "methods": [ + { + "name": "reloadAdres", + "args": [], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 55, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\nReload the BRP lookup (e.g. when the wizard restarts) so the address re-prefills.", + "description": "

Reload the BRP lookup (e.g. when the wizard restarts) so the address re-prefills.

\n" + } + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

Application-layer facade for the registratie wizard's two lookups (BRP address,\nDUO diplomas). It owns the httpResources (created here, in the required injection\ncontext), runs the trust-boundary parse, and exposes RemoteData / derived signals\n— so the UI reaches the network through application/, never infrastructure/\ndirectly (CLAUDE.md §1: ui → application → domain). Same facade shape as\nBigProfileStore.

\n", + "rawdescription": "\n\nApplication-layer facade for the registratie wizard's two lookups (BRP address,\nDUO diplomas). It owns the httpResources (created here, in the required injection\ncontext), runs the trust-boundary parse, and exposes RemoteData / derived signals\n— so the UI reaches the network through application/, never infrastructure/\ndirectly (CLAUDE.md §1: ui → application → domain). Same facade shape as\nBigProfileStore.\n", + "sourceCode": "import { Injectable, computed, inject } from '@angular/core';\nimport { RemoteData, fromResource } from '@shared/application/remote-data';\nimport { DuoLookupDto } from '../contracts/duo-diplomas.dto';\nimport { BrpAdapter, parseBrpAddress } from '../infrastructure/brp.adapter';\nimport { DuoAdapter, parseDuoLookup } from '../infrastructure/duo.adapter';\n\ntype Err = Error | undefined;\n\n/**\n * Application-layer facade for the registratie wizard's two lookups (BRP address,\n * DUO diplomas). It owns the httpResources (created here, in the required injection\n * context), runs the trust-boundary parse, and exposes RemoteData / derived signals\n * — so the UI reaches the network through application/, never infrastructure/\n * directly (CLAUDE.md §1: ui → application → domain). Same facade shape as\n * BigProfileStore.\n */\n@Injectable({ providedIn: 'root' })\nexport class RegistratieLookupStore {\n private brp = inject(BrpAdapter);\n private duo = inject(DuoAdapter);\n\n private adresRes = this.brp.adresResource();\n private diplomasRes = this.duo.diplomasResource();\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 readonly 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 address to prefill the draft with, once BRP resolves with a found address;\n null otherwise (loading, error, no address, malformed). */\n readonly prefillAdres = computed<{ straat: string; postcode: string; woonplaats: string } | null>(() => {\n const json = this.adresRes.value();\n if (json === undefined) return null;\n const parsed = parseBrpAddress(json);\n return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null;\n });\n\n /** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */\n readonly duoLookup = 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 /** Reload the BRP lookup (e.g. when the wizard restarts) so the address re-prefills. */\n reloadAdres() {\n this.adresRes.reload();\n }\n}\n", + "extends": [], + "type": "injectable" + }, { "name": "SessionStore", "id": "injectable-SessionStore-0baea49d81a56d8083840504ebe739dda3b087e42874387498a8367e5a497df3048237969f26e8d3dbe3f105be04f2eb80171579db11fc757f896c02bf386f49", @@ -15329,7 +15515,7 @@ }, { "name": "IntakeWizardComponent", - "id": "component-IntakeWizardComponent-acc06871f7ee8f976b62b6cec56b32cbbb21995ff0abca69af478a4828bb433378576004e12dc3382d9671c38d042bf55aa21b3e05d30a091a0e1cddc26eecec", + "id": "component-IntakeWizardComponent-bda7aa46dbc7ddafdbd7d89444d06a03e087e92701c3f8ce5a12819757dddc0d73b2ad443bb3a580af1744375e8866fe9673960f8b2cdb5175c04221da1b8bc5", "file": "src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts", "encapsulation": [], "entryComponents": [], @@ -15353,7 +15539,7 @@ "indexKey": "", "optional": false, "description": "

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

\n", - "line": 131, + "line": 129, "rawdescription": "\nOptional seed so Storybook / the showcase can mount any state directly.", "required": false } @@ -15369,7 +15555,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 149, + "line": 147, "modifierKind": [ 123 ] @@ -15383,7 +15569,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 153, + "line": 151, "modifierKind": [ 124 ] @@ -15397,7 +15583,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 152, + "line": 150, "modifierKind": [ 124 ] @@ -15411,7 +15597,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 135, + "line": 133, "modifierKind": [ 148 ] @@ -15425,7 +15611,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 138, + "line": 136, "modifierKind": [ 123 ] @@ -15439,7 +15625,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 192, + "line": 190, "modifierKind": [ 124 ] @@ -15453,7 +15639,7 @@ "indexKey": "", "optional": false, "description": "

Current step's field errors, flattened for the shell's error summary. The\nfield ids match the answer keys, so the summary anchors jump to the field.

\n", - "line": 185, + "line": 183, "rawdescription": "\nCurrent step's field errors, flattened for the shell's error summary. The\nfield ids match the answer keys, so the summary anchors jump to the field.", "modifierKind": [ 124 @@ -15468,7 +15654,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 174, + "line": 172, "modifierKind": [ 124 ] @@ -15482,7 +15668,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 159, + "line": 157, "modifierKind": [ 124 ] @@ -15496,35 +15682,21 @@ "indexKey": "", "optional": false, "description": "", - "line": 133, + "line": 131, "modifierKind": [ 148 ] }, { - "name": "policy", - "defaultValue": "inject(IntakePolicyAdapter)", + "name": "policyStore", + "defaultValue": "inject(IntakePolicyStore)", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "", - "line": 123, - "modifierKind": [ - 123 - ] - }, - { - "name": "policyRes", - "defaultValue": "this.policy.policyResource()", - "deprecated": false, - "deprecationMessage": "", - "type": "unknown", - "indexKey": "", - "optional": false, - "description": "", - "line": 128, + "line": 125, "modifierKind": [ 123 ] @@ -15538,7 +15710,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 169, + "line": 167, "modifierKind": [ 124 ] @@ -15566,7 +15738,7 @@ "indexKey": "", "optional": false, "description": "

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

\n", - "line": 156, + "line": 154, "rawdescription": "\nServer-owned threshold from the policy endpoint (mirrored into machine state).", "modifierKind": [ 124 @@ -15581,7 +15753,7 @@ "indexKey": "", "optional": false, "description": "

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

\n", - "line": 158, + "line": 156, "rawdescription": "\nWhether the inline scholing question is shown (and required) in the 'werk' step.", "modifierKind": [ 124 @@ -15596,7 +15768,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 193, + "line": 191, "modifierKind": [ 124 ] @@ -15610,7 +15782,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 175, + "line": 173, "modifierKind": [ 124 ] @@ -15624,7 +15796,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 134, + "line": 132, "modifierKind": [ 148 ] @@ -15638,7 +15810,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 154, + "line": 152, "modifierKind": [ 124 ] @@ -15652,7 +15824,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 162, + "line": 160, "modifierKind": [ 148 ] @@ -15666,7 +15838,7 @@ "indexKey": "", "optional": false, "description": "

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

\n", - "line": 151, + "line": 149, "rawdescription": "\nPublic so the showcase can render the (fixed) step list next to the wizard.", "modifierKind": [ 148 @@ -15681,7 +15853,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 168, + "line": 166, "modifierKind": [ 124 ] @@ -15695,7 +15867,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 163, + "line": 161, "modifierKind": [ 123 ] @@ -15709,7 +15881,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 124, + "line": 126, "modifierKind": [ 123 ] @@ -15722,7 +15894,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 209, + "line": 207, "deprecated": false, "deprecationMessage": "" }, @@ -15732,7 +15904,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 216, + "line": 214, "deprecated": false, "deprecationMessage": "" }, @@ -15742,7 +15914,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 221, + "line": 219, "deprecated": false, "deprecationMessage": "" }, @@ -15752,7 +15924,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 228, + "line": 226, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nThe effect: when we enter Submitting, submit through the aanvraag lifecycle,\nflip the optimistic cross-page flag, then dispatch the outcome (commit/rollback).", @@ -15813,7 +15985,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\nsessionStorage so a page reload keeps the user's progress (cleared on tab close).

\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\nsessionStorage so a page reload keeps the user's progress (cleared on tab close).", "type": "component", - "sourceCode": "import { Component, computed, effect, inject, input, untracked } 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, JA_NEE } 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 { DataRowComponent } from '@shared/ui/data-row/data-row.component';\nimport { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';\nimport { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';\nimport { WizardShellComponent, WizardError, WizardStatus, naarStapLabel } from '@shared/layout/wizard-shell/wizard-shell.component';\nimport { createStore } from '@shared/application/store';\nimport { whenTag } from '@shared/kernel/fp';\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 hasProgress,\n SCHOLING_THRESHOLD_DEFAULT,\n} from '@herregistratie/domain/intake.machine';\nimport { createDraftSync } from '@registratie/application/draft-sync';\nimport { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-policy.adapter';\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 sessionStorage so a page reload keeps the user's progress (cleared on tab close). */\n@Component({\n selector: 'app-intake-wizard',\n imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, DataRowComponent, ReviewSectionComponent, ConfirmationComponent, WizardShellComponent],\n template: `\n 0\"\n [errors]=\"errorList()\"\n [errorMessage]=\"errorMessage()\"\n (primary)=\"onPrimary()\"\n (back)=\"dispatch({ tag: 'Back' })\"\n (cancel)=\"restart()\"\n (retry)=\"onRetry()\"\n (goToStep)=\"dispatch({ tag: 'GaNaarStap', cursor: $event })\">\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 \n @if (answers().buitenlandGewerkt === 'ja') {\n \n \n }\n \n \n \n @if (scholingZichtbaar()) {\n \n }\n @if (answers().scholingGevolgd === 'ja') {\n \n }\n \n }\n }\n\n
\n \n
\n Opnieuw beginnen\n
\n
\n
\n \n `,\n})\nexport class IntakeWizardComponent {\n private profile = inject(BigProfileStore);\n private policy = inject(IntakePolicyAdapter);\n private store = createStore(initial, reduce);\n\n // Server-owned policy: the scholing threshold is fetched from the backend, not\n // hardcoded. The backend stays the authority and re-validates on submit.\n private policyRes = this.policy.policyResource();\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 // Backend draft-sync (replaces sessionStorage); the intake has no uploads.\n private draftSync = createDraftSync({\n type: 'intake',\n snapshot: () => {\n const s = this.state();\n if (s.tag !== 'Answering' || !hasProgress(s)) return null;\n return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds: [] };\n },\n onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as IntakeState }),\n enabled: () => this.seed() === initial,\n });\n\n private answering = computed(() => whenTag(this.state(), 'Answering'));\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(() => whenTag(this.state(), 'Failed')?.error ?? '');\n\n // --- Presentational wiring for the shared wizard shell ---------------------\n readonly stepLabels = [$localize`:@@intake.step.buitenland:Buitenland`, $localize`:@@intake.step.werk:Werk`, $localize`:@@intake.step.controle:Controle`];\n private stepTitles: Record = {\n buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,\n werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,\n review: $localize`:@@intake.title.review:Controleren en indienen`,\n };\n protected stepTitle = computed(() => this.stepTitles[this.step()]);\n protected primaryLabel = computed(() => {\n if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`;\n const next = this.cursor() + 1;\n return naarStapLabel(next + 1, this.stepLabels[next]);\n });\n protected errorMessage = computed(() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`);\n protected shellStatus = computed(() => {\n switch (this.state().tag) {\n case 'Answering': return 'editing';\n case 'Submitting': return 'submitting';\n case 'Submitted': return 'submitted';\n case 'Failed': return 'failed';\n }\n });\n /** Current step's field errors, flattened for the shell's error summary. The\n field ids match the answer keys, so the summary anchors jump to the field. */\n protected errorList = computed(() => {\n const e = this.answering()?.errors ?? {};\n return (Object.keys(e) as (keyof Answers)[])\n .filter((k) => e[k])\n .map((k) => ({ id: k, message: e[k]! }));\n });\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/tests) wins; otherwise resume the backend draft\n // (`?aanvraag=`) or start fresh. Persistence is the draftSync controller's job.\n const seeded = this.seed();\n queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume()));\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 ?? SCHOLING_THRESHOLD_DEFAULT;\n untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));\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.draftSync.reset();\n this.dispatch({ tag: 'Seed', state: initial });\n }\n\n /** The effect: when we enter Submitting, submit through the aanvraag lifecycle,\n flip the optimistic cross-page flag, then dispatch the outcome (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 this.draftSync.submit({ uren: s.data.uren });\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 { 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, JA_NEE } 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 { DataRowComponent } from '@shared/ui/data-row/data-row.component';\nimport { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';\nimport { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';\nimport { WizardShellComponent, WizardError, WizardStatus, naarStapLabel } from '@shared/layout/wizard-shell/wizard-shell.component';\nimport { createStore } from '@shared/application/store';\nimport { whenTag } from '@shared/kernel/fp';\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 hasProgress,\n SCHOLING_THRESHOLD_DEFAULT,\n} from '@herregistratie/domain/intake.machine';\nimport { createDraftSync } from '@registratie/application/draft-sync';\nimport { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';\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 sessionStorage so a page reload keeps the user's progress (cleared on tab close). */\n@Component({\n selector: 'app-intake-wizard',\n imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, DataRowComponent, ReviewSectionComponent, ConfirmationComponent, WizardShellComponent],\n template: `\n 0\"\n [errors]=\"errorList()\"\n [errorMessage]=\"errorMessage()\"\n (primary)=\"onPrimary()\"\n (back)=\"dispatch({ tag: 'Back' })\"\n (cancel)=\"restart()\"\n (retry)=\"onRetry()\"\n (goToStep)=\"dispatch({ tag: 'GaNaarStap', cursor: $event })\">\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 \n @if (answers().buitenlandGewerkt === 'ja') {\n \n \n }\n \n \n \n @if (scholingZichtbaar()) {\n \n }\n @if (answers().scholingGevolgd === 'ja') {\n \n }\n \n }\n }\n\n
\n \n
\n Opnieuw beginnen\n
\n
\n
\n \n `,\n})\nexport class IntakeWizardComponent {\n private profile = inject(BigProfileStore);\n // Server-owned policy (scholing threshold): fetched from the backend via the\n // application facade, not hardcoded. The backend stays the authority on submit.\n private policyStore = inject(IntakePolicyStore);\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 // Backend draft-sync (replaces sessionStorage); the intake has no uploads.\n private draftSync = createDraftSync({\n type: 'intake',\n snapshot: () => {\n const s = this.state();\n if (s.tag !== 'Answering' || !hasProgress(s)) return null;\n return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds: [] };\n },\n onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as IntakeState }),\n enabled: () => this.seed() === initial,\n });\n\n private answering = computed(() => whenTag(this.state(), 'Answering'));\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(() => whenTag(this.state(), 'Failed')?.error ?? '');\n\n // --- Presentational wiring for the shared wizard shell ---------------------\n readonly stepLabels = [$localize`:@@intake.step.buitenland:Buitenland`, $localize`:@@intake.step.werk:Werk`, $localize`:@@intake.step.controle:Controle`];\n private stepTitles: Record = {\n buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,\n werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,\n review: $localize`:@@intake.title.review:Controleren en indienen`,\n };\n protected stepTitle = computed(() => this.stepTitles[this.step()]);\n protected primaryLabel = computed(() => {\n if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`;\n const next = this.cursor() + 1;\n return naarStapLabel(next + 1, this.stepLabels[next]);\n });\n protected errorMessage = computed(() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`);\n protected shellStatus = computed(() => {\n switch (this.state().tag) {\n case 'Answering': return 'editing';\n case 'Submitting': return 'submitting';\n case 'Submitted': return 'submitted';\n case 'Failed': return 'failed';\n }\n });\n /** Current step's field errors, flattened for the shell's error summary. The\n field ids match the answer keys, so the summary anchors jump to the field. */\n protected errorList = computed(() => {\n const e = this.answering()?.errors ?? {};\n return (Object.keys(e) as (keyof Answers)[])\n .filter((k) => e[k])\n .map((k) => ({ id: k, message: e[k]! }));\n });\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/tests) wins; otherwise resume the backend draft\n // (`?aanvraag=`) or start fresh. Persistence is the draftSync controller's job.\n const seeded = this.seed();\n queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume()));\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.policyStore.scholingThreshold();\n untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));\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.draftSync.reset();\n this.dispatch({ tag: 'Seed', state: initial });\n }\n\n /** The effect: when we enter Submitting, submit through the aanvraag lifecycle,\n flip the optimistic cross-page flag, then dispatch the outcome (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 this.draftSync.submit({ uren: s.data.uren });\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": "", @@ -15823,7 +15995,7 @@ "deprecated": false, "deprecationMessage": "", "args": [], - "line": 193 + "line": 191 }, "extends": [] }, @@ -17957,7 +18129,7 @@ }, { "name": "RegistratieWizardComponent", - "id": "component-RegistratieWizardComponent-7b4be4ac3a431f778dc30271ab55dd42012e698589cec700747a7bf537ce07ece64e1a98c1951aafe34cd0acd76753a6d8fc9db89378daaf5fdfc357b28474d8", + "id": "component-RegistratieWizardComponent-ba2c674ab681ab986914fb38da77932c3dd88b94f5b7e2e95e4838f2bed9141ef7af97d97f8ef36a28760184d38828f9020373a104d71ed68a3a2d30982d339d", "file": "src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts", "encapsulation": [], "entryComponents": [], @@ -17981,7 +18153,7 @@ "indexKey": "", "optional": false, "description": "

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

\n", - "line": 211, + "line": 206, "rawdescription": "\nOptional seed so Storybook / tests can mount any state directly.", "required": false } @@ -17997,7 +18169,7 @@ "indexKey": "", "optional": false, "description": "

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

\n", - "line": 337, + "line": 317, "rawdescription": "\nThe policy questions that apply to the current choice (server-decided).", "modifierKind": [ 124 @@ -18012,21 +18184,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 285, - "modifierKind": [ - 124 - ] - }, - { - "name": "adresRes", - "defaultValue": "this.brp.adresResource()", - "deprecated": false, - "deprecationMessage": "", - "type": "unknown", - "indexKey": "", - "optional": false, - "description": "", - "line": 207, + "line": 280, "modifierKind": [ 124 ] @@ -18040,22 +18198,22 @@ "indexKey": "", "optional": false, "description": "", - "line": 280, + "line": 275, "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 })", + "defaultValue": "this.lookup.adresStatus", "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": 292, - "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.", + "description": "

BRP lookup outcome (laden/gevonden/geen/fout) and the parsed DUO lookup, both\nserved by the application facade — the wizard renders, it does not fetch/parse.

\n", + "line": 286, + "rawdescription": "\nBRP lookup outcome (laden/gevonden/geen/fout) and the parsed DUO lookup, both\nserved by the application facade — the wizard renders, it does not fetch/parse.", "modifierKind": [ 124 ] @@ -18069,7 +18227,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 320, + "line": 300, "modifierKind": [ 124 ] @@ -18083,25 +18241,11 @@ "indexKey": "", "optional": false, "description": "", - "line": 334, + "line": 314, "modifierKind": [ 124 ] }, - { - "name": "brp", - "defaultValue": "inject(BrpAdapter)", - "deprecated": false, - "deprecationMessage": "", - "type": "unknown", - "indexKey": "", - "optional": false, - "description": "", - "line": 197, - "modifierKind": [ - 123 - ] - }, { "name": "correspondentieLabel", "defaultValue": "computed(() => ({ email: $localize`:@@regWizard.corr.email:Per e-mail`, post: $localize`:@@regWizard.corr.post:Per post` }[this.draft().correspondentie ?? 'post']))", @@ -18111,7 +18255,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 286, + "line": 281, "modifierKind": [ 124 ] @@ -18125,7 +18269,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 220, + "line": 215, "modifierKind": [ 124 ] @@ -18139,7 +18283,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 287, + "line": 282, "modifierKind": [ 124 ] @@ -18153,7 +18297,7 @@ "indexKey": "", "optional": false, "description": "

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

\n", - "line": 327, + "line": 307, "rawdescription": "\nThe radio selection: a diploma id, or the\"not listed\" sentinel in manual mode.", "modifierKind": [ 124 @@ -18168,25 +18312,11 @@ "indexKey": "", "optional": false, "description": "", - "line": 329, + "line": 309, "modifierKind": [ 124 ] }, - { - "name": "diplomasRes", - "defaultValue": "this.duo.diplomasResource()", - "deprecated": false, - "deprecationMessage": "", - "type": "unknown", - "indexKey": "", - "optional": false, - "description": "", - "line": 208, - "modifierKind": [ - 123 - ] - }, { "name": "dispatch", "defaultValue": "this.store.dispatch", @@ -18196,7 +18326,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 217, + "line": 212, "modifierKind": [ 148 ] @@ -18210,7 +18340,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 221, + "line": 216, "modifierKind": [ 124 ] @@ -18224,21 +18354,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 237, - "modifierKind": [ - 123 - ] - }, - { - "name": "duo", - "defaultValue": "inject(DuoAdapter)", - "deprecated": false, - "deprecationMessage": "", - "type": "unknown", - "indexKey": "", - "optional": false, - "description": "", - "line": 198, + "line": 232, "modifierKind": [ 123 ] @@ -18252,7 +18368,7 @@ "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": 311, + "line": 291, "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 @@ -18267,7 +18383,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 318, + "line": 298, "modifierKind": [ 124 ] @@ -18281,7 +18397,7 @@ "indexKey": "", "optional": false, "description": "

Current step's errors (incl. per-question), flattened for the error summary.

\n", - "line": 269, + "line": 264, "rawdescription": "\nCurrent step's errors (incl. per-question), flattened for the error summary.", "modifierKind": [ 124 @@ -18296,7 +18412,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 259, + "line": 254, "modifierKind": [ 124 ] @@ -18310,7 +18426,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 251, + "line": 246, "modifierKind": [ 124 ] @@ -18324,7 +18440,7 @@ "indexKey": "", "optional": false, "description": "

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

\n", - "line": 325, + "line": 305, "rawdescription": "\nTrue while the user is entering a diploma manually (not in the DUO list).", "modifierKind": [ 124 @@ -18339,7 +18455,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 219, + "line": 214, "modifierKind": [ 123 ] @@ -18353,7 +18469,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 316, + "line": 296, "modifierKind": [ 148 ] @@ -18367,22 +18483,35 @@ "indexKey": "", "optional": false, "description": "", - "line": 213, + "line": 208, "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 })", + "name": "lookup", + "defaultValue": "inject(RegistratieLookupStore)", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, - "description": "

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

\n", - "line": 302, - "rawdescription": "\nThe DUO lookup (diplomas + manual fallback), validated at the trust boundary.", + "description": "", + "line": 196, + "modifierKind": [ + 123 + ] + }, + { + "name": "lookupRd", + "defaultValue": "this.lookup.duoLookup", + "deprecated": false, + "deprecationMessage": "", + "type": "function", + "indexKey": "", + "optional": false, + "description": "", + "line": 287, "modifierKind": [ 124 ] @@ -18396,7 +18525,7 @@ "indexKey": "", "optional": false, "description": "

Preview/download link for a completed upload; the dev-simulation demo-* ids\nhave no stored bytes, so they get no link.

\n", - "line": 204, + "line": 202, "rawdescription": "\nPreview/download link for a completed upload; the dev-simulation `demo-*` ids\nhave no stored bytes, so they get no link.", "modifierKind": [ 124 @@ -18411,7 +18540,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 254, + "line": 249, "modifierKind": [ 124 ] @@ -18425,7 +18554,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 250, + "line": 245, "modifierKind": [ 124 ] @@ -18439,7 +18568,7 @@ "indexKey": "", "optional": false, "description": "

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

\n", - "line": 343, + "line": 323, "rawdescription": "\nAnswered policy questions for the controle summary (question text + answer).", "modifierKind": [ 124 @@ -18454,7 +18583,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 321, + "line": 301, "modifierKind": [ 124 ] @@ -18468,7 +18597,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 322, + "line": 302, "modifierKind": [ 124 ] @@ -18482,7 +18611,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 260, + "line": 255, "modifierKind": [ 124 ] @@ -18496,7 +18625,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 216, + "line": 211, "modifierKind": [ 148 ] @@ -18510,7 +18639,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 248, + "line": 243, "modifierKind": [ 124 ] @@ -18524,7 +18653,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 214, + "line": 209, "modifierKind": [ 148 ] @@ -18538,7 +18667,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 249, + "line": 244, "modifierKind": [ 124 ] @@ -18552,7 +18681,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 215, + "line": 210, "modifierKind": [ 123 ] @@ -18566,7 +18695,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 200, + "line": 198, "modifierKind": [ 123 ] @@ -18580,7 +18709,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 222, + "line": 217, "modifierKind": [ 124 ] @@ -18594,7 +18723,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 199, + "line": 197, "modifierKind": [ 123 ] @@ -18608,7 +18737,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 223, + "line": 218, "modifierKind": [ 124 ] @@ -18622,7 +18751,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 319, + "line": 299, "modifierKind": [ 124 ] @@ -18652,7 +18781,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 351, + "line": 331, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -18689,7 +18818,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 386, + "line": 362, "deprecated": false, "deprecationMessage": "" }, @@ -18699,7 +18828,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 393, + "line": 369, "deprecated": false, "deprecationMessage": "" }, @@ -18709,7 +18838,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 400, + "line": 376, "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.", @@ -18721,7 +18850,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 408, + "line": 384, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nThe effect: when we enter Indienen, submit through the aanvraag lifecycle\n(duo → auto-approve, handmatig → manual), then dispatch the outcome.", @@ -18797,7 +18926,7 @@ "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 the\nbackend as a Concept aanvraag (createDraftSync) so a reload — or a"Verder gaan"\nfrom the dashboard via ?aanvraag=<id> — resumes progress. Built 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 the\nbackend as a Concept aanvraag (createDraftSync) so a reload — or a\"Verder gaan\"\nfrom the dashboard via `?aanvraag=` — resumes progress. Built from existing\natoms/molecules.", "type": "component", - "sourceCode": "import { Component, computed, effect, inject, input, untracked } 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, JA_NEE } 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 { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';\nimport { DataRowComponent } from '@shared/ui/data-row/data-row.component';\nimport { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';\nimport { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';\nimport { WizardShellComponent, WizardError, WizardStatus, naarStapLabel } from '@shared/layout/wizard-shell/wizard-shell.component';\nimport { ASYNC } from '@shared/ui/async/async.component';\nimport { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';\nimport { createStore } from '@shared/application/store';\nimport { whenTag } from '@shared/kernel/fp';\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 hasProgress,\n STEPS,\n} from '@registratie/domain/registratie-wizard.machine';\nimport { createDraftSync } from '@registratie/application/draft-sync';\nimport { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';\nimport { createUploadController } from '@shared/upload/upload-controller';\nimport { UploadAdapter } from '@shared/upload/upload.adapter';\nimport { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';\n\nconst KANALEN = [\n { value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },\n { value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },\n];\nconst HANDMATIG = '__handmatig__'; // sentinel option:\"my diploma isn't listed\"\n/** The server-owned geldigheidsvraag whose\"ja\" answer requires a Dutch-taalvaardigheid\n upload (proof of the confirmed B2 level). Stable id shared with the backend. */\nconst NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';\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 the\n backend as a Concept aanvraag (createDraftSync) so a reload — or a\"Verder gaan\"\n from the dashboard via `?aanvraag=` — resumes progress. Built from existing\n atoms/molecules. */\n@Component({\n selector: 'app-registratie-wizard',\n imports: [\n FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,\n AlertComponent, SkeletonComponent, DataRowComponent, ReviewSectionComponent, ConfirmationComponent, WizardShellComponent,\n AddressFieldsComponent, DocumentUploadComponent, ...ASYNC,\n ],\n template: `\n 0\"\n [errors]=\"errorList()\"\n [errorMessage]=\"errorMessage()\"\n i18n-submittingLabel=\"@@regWizard.submitting\" submittingLabel=\"Uw registratie wordt verwerkt…\"\n (primary)=\"onPrimary()\"\n (back)=\"dispatch({ tag: 'Back' })\"\n (cancel)=\"restart()\"\n (retry)=\"onRetry()\"\n (goToStep)=\"dispatch({ tag: 'GaNaarStap', cursor: $event })\">\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 @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 \n @if (err('documenten')) {\n {{ err('documenten') }}\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 \n \n @for (item of samenvattingVragen(); track item.vraag) {\n \n }\n \n }\n }\n\n
\n \n

Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.

\n
\n Nieuwe registratie starten\n
\n
\n
\n \n `,\n})\nexport class RegistratieWizardComponent {\n private brp = inject(BrpAdapter);\n private duo = inject(DuoAdapter);\n private uploadAdapter = inject(UploadAdapter);\n private store = createStore(initial, reduce);\n\n /** Preview/download link for a completed upload; the dev-simulation `demo-*` ids\n have no stored bytes, so they get no link. */\n protected previewUrlFor = (documentId: string): string | undefined =>\n documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);\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 = [$localize`:@@regWizard.step.adres:Adres`, $localize`:@@regWizard.step.beroep:Beroep`, $localize`:@@regWizard.step.controle:Controle`]; // short labels for the stepper\n private stepTitles = [$localize`:@@regWizard.title.adres:Adres en correspondentievoorkeur`, $localize`:@@regWizard.title.beroep:Beroep op basis van uw diploma`, $localize`:@@regWizard.title.controle:Controleren en indienen`];\n readonly state = this.store.model;\n readonly dispatch = this.store.dispatch;\n\n private invullen = computed(() => whenTag(this.state(), 'Invullen'));\n protected cursor = computed(() => this.invullen()?.cursor ?? 0);\n protected draft = computed(() => this.invullen()?.draft ?? { antwoorden: {} });\n protected upload = computed(() => this.invullen()?.upload ?? initialUpload);\n protected uploadCtl = createUploadController({\n wizardId: 'registratie',\n getUpload: () => this.upload(),\n dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),\n // Required documents depend on answers (server decides): a diploma upload only for a\n // manual diploma; a Dutch-taalvaardigheid upload only once the applicant confirms\n // (\"ja\") the B2 language requirement.\n getCategoryParams: () => ({\n diplomaHerkomst: this.draft().diplomaHerkomst,\n taalvaardigheid: this.draft().antwoorden[NL_TAALVAARDIGHEID_VRAAG],\n }),\n });\n // Backend draft-sync (replaces sessionStorage): create a Concept once the user has\n // made progress, then debounced-sync the whole machine snapshot; resume by `?aanvraag`.\n private draftSync = createDraftSync({\n type: 'registratie',\n snapshot: () => {\n const s = this.state();\n if (s.tag !== 'Invullen' || !hasProgress(s)) return null;\n const documentIds = deliveryRefs(s.upload).filter((r) => r.channel === 'digital' && r.documentId).map((r) => r.documentId!);\n return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds };\n },\n onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as RegistratieState }),\n enabled: () => this.seed() === initial,\n });\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(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');\n protected failedError = computed(() => whenTag(this.state(), 'Mislukt')?.error ?? '');\n\n // --- Presentational wiring for the shared wizard shell ---------------------\n protected primaryLabel = computed(() => {\n if (this.step() === 'controle') return $localize`:@@regWizard.indienen:Registratie indienen`;\n const next = this.cursor() + 1;\n return naarStapLabel(next + 1, this.stepLabels[next]);\n });\n protected errorMessage = computed(() => $localize`:@@regWizard.indienenMislukt:Het indienen is niet gelukt:` + ` ${this.failedError()}`);\n protected shellStatus = computed(() => {\n switch (this.state().tag) {\n case 'Invullen': return 'editing';\n case 'Indienen': return 'submitting';\n case 'Ingediend': return 'submitted';\n case 'Mislukt': return 'failed';\n }\n });\n /** Current step's errors (incl. per-question), flattened for the error summary. */\n protected errorList = computed(() => {\n const e = this.invullen()?.errors ?? {};\n const out: WizardError[] = [];\n for (const [k, v] of Object.entries(e)) {\n if (k !== 'antwoorden' && typeof v === 'string' && v) out.push({ id: k, message: v });\n }\n for (const [qid, msg] of Object.entries(e.antwoorden ?? {})) {\n if (msg) out.push({ id: 'vraag-' + qid, message: msg });\n }\n return out;\n });\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: $localize`:@@regWizard.herkomst.adresBrp:Automatisch uit de BRP`, handmatig: $localize`:@@regWizard.herkomst.adresHandmatig:Handmatig ingevoerd` }[this.draft().adresHerkomst ?? 'handmatig']));\n protected correspondentieLabel = computed(() => ({ email: $localize`:@@regWizard.corr.email:Per e-mail`, post: $localize`:@@regWizard.corr.post:Per post` }[this.draft().correspondentie ?? 'post']));\n protected diplomaHerkomstLabel = computed(() => ({ duo: $localize`:@@regWizard.herkomst.diplomaDuo:Geverifieerd via DUO`, handmatig: $localize`:@@regWizard.herkomst.diplomaHandmatig: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' | 'documenten') => this.invullen()?.errors[k] ?? '';\n protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? '';\n protected antwoord = (id: string) => this.draft().antwoorden[id] ?? ''; // runtime guard: missing key → undefined\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: $localize`:@@regWizard.diplomaNietBij: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/tests) wins; otherwise resume from the backend draft\n // (`?aanvraag=`), or start fresh. Persistence is the draftSync controller's job.\n const seeded = this.seed();\n queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume()));\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: focus management (step heading on step change, error summary on a\n // failed submit) now lives in the shared WizardShellComponent.\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.draftSync.reset(); // discard the current Concept; a fresh one starts on next progress\n this.dispatch({ tag: 'Seed', state: initial });\n this.adresRes.reload();\n }\n\n /** The effect: when we enter Indienen, submit through the aanvraag lifecycle\n (duo → auto-approve, handmatig → manual), then dispatch the outcome. */\n private async runIfIndienen() {\n const s = this.state();\n if (s.tag !== 'Indienen') return;\n const r = await this.draftSync.submit({ diplomaHerkomst: s.data.diplomaHerkomst, documents: s.data.documents });\n if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value.referentie ?? '' });\n else this.dispatch({ tag: 'SubmitFailed', error: r.error });\n }\n}\n", + "sourceCode": "import { Component, computed, effect, inject, input, untracked } 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, JA_NEE } 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 { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';\nimport { DataRowComponent } from '@shared/ui/data-row/data-row.component';\nimport { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';\nimport { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';\nimport { WizardShellComponent, WizardError, WizardStatus, naarStapLabel } from '@shared/layout/wizard-shell/wizard-shell.component';\nimport { ASYNC } from '@shared/ui/async/async.component';\nimport { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';\nimport { createStore } from '@shared/application/store';\nimport { whenTag } from '@shared/kernel/fp';\nimport { RemoteData } from '@shared/application/remote-data';\nimport { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';\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 hasProgress,\n STEPS,\n} from '@registratie/domain/registratie-wizard.machine';\nimport { createDraftSync } from '@registratie/application/draft-sync';\nimport { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';\nimport { createUploadController } from '@shared/upload/upload-controller';\nimport { UploadAdapter } from '@shared/upload/upload.adapter';\nimport { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';\n\nconst KANALEN = [\n { value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },\n { value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },\n];\nconst HANDMATIG = '__handmatig__'; // sentinel option:\"my diploma isn't listed\"\n/** The server-owned geldigheidsvraag whose\"ja\" answer requires a Dutch-taalvaardigheid\n upload (proof of the confirmed B2 level). Stable id shared with the backend. */\nconst NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';\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 the\n backend as a Concept aanvraag (createDraftSync) so a reload — or a\"Verder gaan\"\n from the dashboard via `?aanvraag=` — resumes progress. Built from existing\n atoms/molecules. */\n@Component({\n selector: 'app-registratie-wizard',\n imports: [\n FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,\n AlertComponent, SkeletonComponent, DataRowComponent, ReviewSectionComponent, ConfirmationComponent, WizardShellComponent,\n AddressFieldsComponent, DocumentUploadComponent, ...ASYNC,\n ],\n template: `\n 0\"\n [errors]=\"errorList()\"\n [errorMessage]=\"errorMessage()\"\n i18n-submittingLabel=\"@@regWizard.submitting\" submittingLabel=\"Uw registratie wordt verwerkt…\"\n (primary)=\"onPrimary()\"\n (back)=\"dispatch({ tag: 'Back' })\"\n (cancel)=\"restart()\"\n (retry)=\"onRetry()\"\n (goToStep)=\"dispatch({ tag: 'GaNaarStap', cursor: $event })\">\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 @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 \n @if (err('documenten')) {\n {{ err('documenten') }}\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 \n \n @for (item of samenvattingVragen(); track item.vraag) {\n \n }\n \n }\n }\n\n
\n \n

Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.

\n
\n Nieuwe registratie starten\n
\n
\n
\n \n `,\n})\nexport class RegistratieWizardComponent {\n private lookup = inject(RegistratieLookupStore);\n private uploadAdapter = inject(UploadAdapter);\n private store = createStore(initial, reduce);\n\n /** Preview/download link for a completed upload; the dev-simulation `demo-*` ids\n have no stored bytes, so they get no link. */\n protected previewUrlFor = (documentId: string): string | undefined =>\n documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);\n\n /** Optional seed so Storybook / tests can mount any state directly. */\n seed = input(initial);\n\n readonly kanalen = KANALEN;\n readonly stepLabels = [$localize`:@@regWizard.step.adres:Adres`, $localize`:@@regWizard.step.beroep:Beroep`, $localize`:@@regWizard.step.controle:Controle`]; // short labels for the stepper\n private stepTitles = [$localize`:@@regWizard.title.adres:Adres en correspondentievoorkeur`, $localize`:@@regWizard.title.beroep:Beroep op basis van uw diploma`, $localize`:@@regWizard.title.controle:Controleren en indienen`];\n readonly state = this.store.model;\n readonly dispatch = this.store.dispatch;\n\n private invullen = computed(() => whenTag(this.state(), 'Invullen'));\n protected cursor = computed(() => this.invullen()?.cursor ?? 0);\n protected draft = computed(() => this.invullen()?.draft ?? { antwoorden: {} });\n protected upload = computed(() => this.invullen()?.upload ?? initialUpload);\n protected uploadCtl = createUploadController({\n wizardId: 'registratie',\n getUpload: () => this.upload(),\n dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),\n // Required documents depend on answers (server decides): a diploma upload only for a\n // manual diploma; a Dutch-taalvaardigheid upload only once the applicant confirms\n // (\"ja\") the B2 language requirement.\n getCategoryParams: () => ({\n diplomaHerkomst: this.draft().diplomaHerkomst,\n taalvaardigheid: this.draft().antwoorden[NL_TAALVAARDIGHEID_VRAAG],\n }),\n });\n // Backend draft-sync (replaces sessionStorage): create a Concept once the user has\n // made progress, then debounced-sync the whole machine snapshot; resume by `?aanvraag`.\n private draftSync = createDraftSync({\n type: 'registratie',\n snapshot: () => {\n const s = this.state();\n if (s.tag !== 'Invullen' || !hasProgress(s)) return null;\n const documentIds = deliveryRefs(s.upload).filter((r) => r.channel === 'digital' && r.documentId).map((r) => r.documentId!);\n return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds };\n },\n onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as RegistratieState }),\n enabled: () => this.seed() === initial,\n });\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(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');\n protected failedError = computed(() => whenTag(this.state(), 'Mislukt')?.error ?? '');\n\n // --- Presentational wiring for the shared wizard shell ---------------------\n protected primaryLabel = computed(() => {\n if (this.step() === 'controle') return $localize`:@@regWizard.indienen:Registratie indienen`;\n const next = this.cursor() + 1;\n return naarStapLabel(next + 1, this.stepLabels[next]);\n });\n protected errorMessage = computed(() => $localize`:@@regWizard.indienenMislukt:Het indienen is niet gelukt:` + ` ${this.failedError()}`);\n protected shellStatus = computed(() => {\n switch (this.state().tag) {\n case 'Invullen': return 'editing';\n case 'Indienen': return 'submitting';\n case 'Ingediend': return 'submitted';\n case 'Mislukt': return 'failed';\n }\n });\n /** Current step's errors (incl. per-question), flattened for the error summary. */\n protected errorList = computed(() => {\n const e = this.invullen()?.errors ?? {};\n const out: WizardError[] = [];\n for (const [k, v] of Object.entries(e)) {\n if (k !== 'antwoorden' && typeof v === 'string' && v) out.push({ id: k, message: v });\n }\n for (const [qid, msg] of Object.entries(e.antwoorden ?? {})) {\n if (msg) out.push({ id: 'vraag-' + qid, message: msg });\n }\n return out;\n });\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: $localize`:@@regWizard.herkomst.adresBrp:Automatisch uit de BRP`, handmatig: $localize`:@@regWizard.herkomst.adresHandmatig:Handmatig ingevoerd` }[this.draft().adresHerkomst ?? 'handmatig']));\n protected correspondentieLabel = computed(() => ({ email: $localize`:@@regWizard.corr.email:Per e-mail`, post: $localize`:@@regWizard.corr.post:Per post` }[this.draft().correspondentie ?? 'post']));\n protected diplomaHerkomstLabel = computed(() => ({ duo: $localize`:@@regWizard.herkomst.diplomaDuo:Geverifieerd via DUO`, handmatig: $localize`:@@regWizard.herkomst.diplomaHandmatig:Handmatig ingevoerd (wordt beoordeeld)` }[this.draft().diplomaHerkomst ?? 'handmatig']));\n\n /** BRP lookup outcome (laden/gevonden/geen/fout) and the parsed DUO lookup, both\n served by the application facade — the wizard renders, it does not fetch/parse. */\n protected adresStatus = this.lookup.adresStatus;\n protected lookupRd: () => RemoteData = this.lookup.duoLookup;\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' | 'documenten') => this.invullen()?.errors[k] ?? '';\n protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? '';\n protected antwoord = (id: string) => this.draft().antwoorden[id] ?? ''; // runtime guard: missing key → undefined\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: $localize`:@@regWizard.diplomaNietBij: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/tests) wins; otherwise resume from the backend draft\n // (`?aanvraag=`), or start fresh. Persistence is the draftSync controller's job.\n const seeded = this.seed();\n queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume()));\n // Prefill the address from the BRP lookup as it arrives. Track only the facade's\n // parsed prefill signal; untrack the dispatch (it reads the state signal, which\n // would otherwise make this effect loop on its own write). Don't clobber\n // edits/restored data. A null prefill (loading/error/geen adres) leaves manual entry.\n effect(() => {\n const a = this.lookup.prefillAdres();\n if (!a) return;\n untracked(() => {\n const s = this.state();\n if (s.tag !== 'Invullen' || s.draft.straat) return;\n this.dispatch({ tag: 'PrefillAdres', straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats });\n });\n });\n // A11y: focus management (step heading on step change, error summary on a\n // failed submit) now lives in the shared WizardShellComponent.\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.draftSync.reset(); // discard the current Concept; a fresh one starts on next progress\n this.dispatch({ tag: 'Seed', state: initial });\n this.lookup.reloadAdres();\n }\n\n /** The effect: when we enter Indienen, submit through the aanvraag lifecycle\n (duo → auto-approve, handmatig → manual), then dispatch the outcome. */\n private async runIfIndienen() {\n const s = this.state();\n if (s.tag !== 'Indienen') return;\n const r = await this.draftSync.submit({ diplomaHerkomst: s.data.diplomaHerkomst, documents: s.data.documents });\n if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value.referentie ?? '' });\n else this.dispatch({ tag: 'SubmitFailed', error: r.error });\n }\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -18807,7 +18936,7 @@ "deprecated": false, "deprecationMessage": "", "args": [], - "line": 358 + "line": 338 }, "extends": [] }, @@ -28406,6 +28535,17 @@ "description": "", "kind": 193 }, + { + "name": "Err", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "Error | undefined", + "file": "src/app/registratie/application/registratie-lookup.store.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, { "name": "Errors", "ctype": "miscellaneous", @@ -35768,6 +35908,19 @@ "kind": 193 } ], + "src/app/registratie/application/registratie-lookup.store.ts": [ + { + "name": "Err", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "Error | undefined", + "file": "src/app/registratie/application/registratie-lookup.store.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + } + ], "src/app/herregistratie/domain/intake.machine.ts": [ { "name": "Errors", @@ -36875,6 +37028,15 @@ "coverageCount": "1/10", "status": "low" }, + { + "filePath": "src/app/herregistratie/application/intake-policy.store.ts", + "type": "injectable", + "linktype": "injectable", + "name": "IntakePolicyStore", + "coveragePercent": 25, + "coverageCount": "1/4", + "status": "low" + }, { "filePath": "src/app/herregistratie/domain/herregistratie.machine.ts", "type": "interface", @@ -37293,8 +37455,8 @@ "type": "component", "linktype": "component", "name": "IntakeWizardComponent", - "coveragePercent": 21, - "coverageCount": "7/32", + "coveragePercent": 22, + "coverageCount": "7/31", "status": "low" }, { @@ -37382,6 +37544,25 @@ "coverageCount": "0/1", "status": "low" }, + { + "filePath": "src/app/registratie/application/registratie-lookup.store.ts", + "type": "injectable", + "linktype": "injectable", + "name": "RegistratieLookupStore", + "coveragePercent": 55, + "coverageCount": "5/9", + "status": "good" + }, + { + "filePath": "src/app/registratie/application/registratie-lookup.store.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "Err", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, { "filePath": "src/app/registratie/application/submit-change-request.ts", "type": "function", @@ -38423,7 +38604,7 @@ "linktype": "component", "name": "RegistratieWizardComponent", "coveragePercent": 24, - "coverageCount": "13/53", + "coverageCount": "12/50", "status": "low" }, { diff --git a/eslint.config.mjs b/eslint.config.mjs index 257e3a2..53f3f38 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -143,4 +143,44 @@ export default [ plugins: { '@typescript-eslint': tseslint.plugin }, rules: { '@typescript-eslint/no-restricted-imports': 'off' }, }, + + // ui/ and layout/ are the presentation layer: dependencies point inward + // (ui → application → domain, CLAUDE.md §1), so they must NOT import + // infrastructure/ directly — they reach data through an application store or + // command. (Stories/specs are test scaffolding and may wire the real client.) + // Uses the @typescript-eslint variant so it composes with the base + // no-restricted-imports context-direction rules above (last-wins is per rule name). + { + files: ['src/app/**/ui/**/*.ts', 'src/app/**/layout/**/*.ts'], + ignores: ['**/*.stories.ts', '**/*.spec.ts'], + plugins: { '@typescript-eslint': tseslint.plugin }, + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@shared/infrastructure/*', + '@auth/infrastructure/*', + '@registratie/infrastructure/*', + '@herregistratie/infrastructure/*', + '@brief/infrastructure/*', + ], + allowTypeImports: true, + message: 'ui/ and layout/ must not import infrastructure/ directly (CLAUDE.md §1: ui → application → domain). Reach data through an application store or command. (Type-only DTO imports are fine: use `import type`.)', + }, + ], + }, + ], + }, + }, + + // Sanctioned exception: showcase/ is the teaching page whose whole point is showing + // multiple contexts side by side (ARCHITECTURE.md §6). It may read every context; + // nothing imports showcase. Same precedent as the shared/ui/debug-state exemption. + { + files: ['src/app/showcase/**/*.ts'], + rules: { 'no-restricted-imports': 'off' }, + }, ]; diff --git a/src/app/herregistratie/application/intake-policy.store.ts b/src/app/herregistratie/application/intake-policy.store.ts new file mode 100644 index 0000000..432af0f --- /dev/null +++ b/src/app/herregistratie/application/intake-policy.store.ts @@ -0,0 +1,19 @@ +import { Injectable, computed, inject } from '@angular/core'; +import { SCHOLING_THRESHOLD_DEFAULT } from '../domain/intake.machine'; +import { IntakePolicyAdapter } from '../infrastructure/intake-policy.adapter'; + +/** + * Application-layer facade for the server-owned intake policy (the scholing + * threshold config value). It owns the httpResource (created here, in the required + * injection context) and exposes the threshold as a derived signal, falling back to + * the domain default until the backend answers. The UI reaches the network through + * application/, never infrastructure/ directly (CLAUDE.md §1). The backend stays the + * authority and re-validates on submit. + */ +@Injectable({ providedIn: 'root' }) +export class IntakePolicyStore { + private policy = inject(IntakePolicyAdapter); + private policyRes = this.policy.policyResource(); + + readonly scholingThreshold = computed(() => this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT); +} 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 57f527e..5cbb944 100644 --- a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts +++ b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts @@ -25,7 +25,7 @@ import { SCHOLING_THRESHOLD_DEFAULT, } from '@herregistratie/domain/intake.machine'; import { createDraftSync } from '@registratie/application/draft-sync'; -import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-policy.adapter'; +import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store'; /** Organism: a BRANCHING intake questionnaire. All state lives in one signal driven by the pure `reduce` (intake.machine.ts). Which step renders is derived @@ -120,13 +120,11 @@ import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-polic }) export class IntakeWizardComponent { private profile = inject(BigProfileStore); - private policy = inject(IntakePolicyAdapter); + // Server-owned policy (scholing threshold): fetched from the backend via the + // application facade, not hardcoded. The backend stays the authority on submit. + private policyStore = inject(IntakePolicyStore); private store = createStore(initial, reduce); - // Server-owned policy: the scholing threshold is fetched from the backend, not - // hardcoded. The backend stays the authority and re-validates on submit. - private policyRes = this.policy.policyResource(); - /** Optional seed so Storybook / the showcase can mount any state directly. */ seed = input(initial); @@ -201,7 +199,7 @@ export class IntakeWizardComponent { // 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 ?? SCHOLING_THRESHOLD_DEFAULT; + const scholingThreshold = this.policyStore.scholingThreshold(); untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold })); }); } diff --git a/src/app/registratie/application/registratie-lookup.store.ts b/src/app/registratie/application/registratie-lookup.store.ts new file mode 100644 index 0000000..8931dd3 --- /dev/null +++ b/src/app/registratie/application/registratie-lookup.store.ts @@ -0,0 +1,58 @@ +import { Injectable, computed, inject } from '@angular/core'; +import { RemoteData, fromResource } from '@shared/application/remote-data'; +import { DuoLookupDto } from '../contracts/duo-diplomas.dto'; +import { BrpAdapter, parseBrpAddress } from '../infrastructure/brp.adapter'; +import { DuoAdapter, parseDuoLookup } from '../infrastructure/duo.adapter'; + +type Err = Error | undefined; + +/** + * Application-layer facade for the registratie wizard's two lookups (BRP address, + * DUO diplomas). It owns the httpResources (created here, in the required injection + * context), runs the trust-boundary parse, and exposes RemoteData / derived signals + * — so the UI reaches the network through application/, never infrastructure/ + * directly (CLAUDE.md §1: ui → application → domain). Same facade shape as + * BigProfileStore. + */ +@Injectable({ providedIn: 'root' }) +export class RegistratieLookupStore { + private brp = inject(BrpAdapter); + private duo = inject(DuoAdapter); + + private adresRes = this.brp.adresResource(); + private diplomasRes = this.duo.diplomasResource(); + + /** 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. */ + readonly 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 address to prefill the draft with, once BRP resolves with a found address; + null otherwise (loading, error, no address, malformed). */ + readonly prefillAdres = computed<{ straat: string; postcode: string; woonplaats: string } | null>(() => { + const json = this.adresRes.value(); + if (json === undefined) return null; + const parsed = parseBrpAddress(json); + return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null; + }); + + /** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */ + readonly duoLookup = 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) }; + }); + + /** Reload the BRP lookup (e.g. when the wizard restarts) so the address re-prefills. */ + reloadAdres() { + this.adresRes.reload(); + } +} diff --git a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts index e8b0c28..2e8abc7 100644 --- a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -14,9 +14,8 @@ import { ASYNC } from '@shared/ui/async/async.component'; import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component'; import { createStore } from '@shared/application/store'; import { whenTag } from '@shared/kernel/fp'; -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 { RemoteData } from '@shared/application/remote-data'; +import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store'; import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto'; import { RegistratieState, @@ -194,8 +193,7 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid'; `, }) export class RegistratieWizardComponent { - private brp = inject(BrpAdapter); - private duo = inject(DuoAdapter); + private lookup = inject(RegistratieLookupStore); private uploadAdapter = inject(UploadAdapter); private store = createStore(initial, reduce); @@ -204,9 +202,6 @@ export class RegistratieWizardComponent { protected previewUrlFor = (documentId: string): string | undefined => documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId); - protected adresRes = this.brp.adresResource(); - private diplomasRes = this.duo.diplomasResource(); - /** Optional seed so Storybook / tests can mount any state directly. */ seed = input(initial); @@ -286,25 +281,10 @@ export class RegistratieWizardComponent { protected correspondentieLabel = computed(() => ({ email: $localize`:@@regWizard.corr.email:Per e-mail`, post: $localize`:@@regWizard.corr.post:Per post` }[this.draft().correspondentie ?? 'post'])); protected diplomaHerkomstLabel = computed(() => ({ duo: $localize`:@@regWizard.herkomst.diplomaDuo:Geverifieerd via DUO`, handmatig: $localize`:@@regWizard.herkomst.diplomaHandmatig: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) }; - }); + /** BRP lookup outcome (laden/gevonden/geen/fout) and the parsed DUO lookup, both + served by the application facade — the wizard renders, it does not fetch/parse. */ + protected adresStatus = this.lookup.adresStatus; + protected lookupRd: () => RemoteData = this.lookup.duoLookup; /** Parsed lookup as a plain value (or null) — used outside the beroep step (the controle summary) where the template variable isn't in scope. */ @@ -362,21 +342,17 @@ export class RegistratieWizardComponent { // (`?aanvraag=`), or start fresh. Persistence is the draftSync controller's job. const seeded = this.seed(); queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume())); - // 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. + // Prefill the address from the BRP lookup as it arrives. Track only the facade's + // parsed prefill signal; 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. A null prefill (loading/error/geen adres) leaves manual entry. effect(() => { - const json = this.adresRes.value(); - if (!json) return; - const parsed = parseBrpAddress(json); + const a = this.lookup.prefillAdres(); + if (!a) return; 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 }); - } + this.dispatch({ tag: 'PrefillAdres', straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats }); }); }); // A11y: focus management (step heading on step change, error summary on a @@ -400,7 +376,7 @@ export class RegistratieWizardComponent { restart() { this.draftSync.reset(); // discard the current Concept; a fresh one starts on next progress this.dispatch({ tag: 'Seed', state: initial }); - this.adresRes.reload(); + this.lookup.reloadAdres(); } /** The effect: when we enter Indienen, submit through the aanvraag lifecycle