style: format frontend, docs and skills with prettier; add .prettierignore

One-time prettier --write so the new format:check CI gate starts green.
.prettierignore excludes generated (api-client.ts, documentation.json),
vendored (public/cibg-huisstijl), and backend (dotnet format owns it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 13:39:31 +02:00
parent 546097434d
commit e82309786d
176 changed files with 5069 additions and 1471 deletions

View File

@@ -34,6 +34,7 @@ server re-validates as authority).
The mapping may read as an identity copy today — the point is the **types** differ, The mapping may read as an identity copy today — the point is the **types** differ,
so the compiler forces a real mapping the moment the wire diverges. so the compiler forces a real mapping the moment the wire diverges.
5. **Application store** exposes the resource as `RemoteData` (`fromResource`, 5. **Application store** exposes the resource as `RemoteData` (`fromResource`,
combine sources with `map`/`map2`/`andThen` from `@shared/application/remote-data`). combine sources with `map`/`map2`/`andThen` from `@shared/application/remote-data`).
UI never imports infrastructure (lint-enforced). UI never imports infrastructure (lint-enforced).

View File

@@ -16,9 +16,15 @@ unrepresentable.
```ts ```ts
import { Result, assertNever } from '@shared/kernel/fp'; import { Result, assertNever } from '@shared/kernel/fp';
export interface Draft { postcode: string; uren: string } // raw strings as typed export interface Draft {
postcode: string;
uren: string;
} // raw strings as typed
export type StepErrors = Partial<Record<keyof Draft, string>>; export type StepErrors = Partial<Record<keyof Draft, string>>;
export interface Valid { postcode: Postcode; uren: Uren } // branded, proven valid export interface Valid {
postcode: Postcode;
uren: Uren;
} // branded, proven valid
export type State = export type State =
| { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors } | { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors }
@@ -28,8 +34,12 @@ export type State =
export type Msg = export type Msg =
| { tag: 'SetField'; key: keyof Draft; value: string } | { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Next' } | { tag: 'Back' } | { tag: 'Submit' } | { tag: 'Retry' } | { tag: 'Next' }
| { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error: string } | { tag: 'Back' }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Seed'; state: State }; // mount any state (stories, resume) | { tag: 'Seed'; state: State }; // mount any state (stories, resume)
export const initial: State = { tag: 'Editing', step: 1, draft: emptyDraft, errors: {} }; export const initial: State = { tag: 'Editing', step: 1, draft: emptyDraft, errors: {} };
@@ -37,14 +47,18 @@ export const initial: State = { tag: 'Editing', step: 1, draft: emptyDraft, erro
export function reduce(s: State, m: Msg): State { export function reduce(s: State, m: Msg): State {
switch (m.tag) { switch (m.tag) {
/* … pure transitions only … */ /* … pure transitions only … */
default: return assertNever(m); // exhaustiveness enforced default:
return assertNever(m); // exhaustiveness enforced
} }
} }
export function validate(draft: Draft): Result<StepErrors, Valid> { /* calls value-object parsers */ } export function validate(draft: Draft): Result<StepErrors, Valid> {
/* calls value-object parsers */
}
``` ```
Rules: Rules:
- **Reducer stays pure.** HTTP lives in a command that dispatches `SubmitConfirmed` / `SubmitFailed` (see **mutation-command** skill). - **Reducer stays pure.** HTTP lives in a command that dispatches `SubmitConfirmed` / `SubmitFailed` (see **mutation-command** skill).
- **Derive, don't store**: anything computable from answers is a pure function (`visibleSteps(answers)`), never a stored field. - **Derive, don't store**: anything computable from answers is a pure function (`visibleSteps(answers)`), never a stored field.
- Server-owned thresholds arrive as config values; keep only an offline fallback constant (see `SCHOLING_THRESHOLD_DEFAULT` in the intake machine). - Server-owned thresholds arrive as config values; keep only an offline fallback constant (see `SCHOLING_THRESHOLD_DEFAULT` in the intake machine).

View File

@@ -17,7 +17,9 @@ machine's `Valid` type, returns the server's answer, no parse (server is authori
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ChangeRequestAdapter { export class ChangeRequestAdapter {
private client = inject(ApiClient); private client = inject(ApiClient);
async changeRequest(data: Valid): Promise<string> { /* → server reference */ } async changeRequest(data: Valid): Promise<string> {
/* → server reference */
}
} }
``` ```

View File

@@ -33,6 +33,7 @@ Every feature is built inward-out. Never start with the component.
## Worked example ## Worked example
The intake wizard slice, end to end: The intake wizard slice, end to end:
- `src/app/herregistratie/domain/intake.machine.ts` (+ spec) - `src/app/herregistratie/domain/intake.machine.ts` (+ spec)
- `src/app/herregistratie/infrastructure/` - `src/app/herregistratie/infrastructure/`
- `src/app/herregistratie/ui/` (wizard component + page) - `src/app/herregistratie/ui/` (wizard component + page)

View File

@@ -28,6 +28,7 @@ export function parsePostcode(raw: string): Result<string, Postcode> {
``` ```
Rules: Rules:
- The `as Brand` cast appears **only** inside the parser — the type is mintable nowhere else. - The `as Brand` cast appears **only** inside the parser — the type is mintable nowhere else.
- Error message is user-facing → `$localize` with a stable `@@validation.<name>` id. - Error message is user-facing → `$localize` with a stable `@@validation.<name>` id.
- Trim/normalise before testing; return the cleaned value. - Trim/normalise before testing; return the cleaned value.

18
.prettierignore Normal file
View File

@@ -0,0 +1,18 @@
# Build output & caches
dist/
storybook-static/
coverage/
.angular/
# Lockfile
package-lock.json
# Generated — owned by their generators, not prettier
documentation.json
src/app/shared/infrastructure/api-client.ts
# Vendored design system (CIBG Huisstijl)
public/cibg-huisstijl/
# Backend is formatted by `dotnet format`, not prettier
backend/

View File

@@ -1,18 +1,11 @@
import type { StorybookConfig } from '@storybook/angular'; import type { StorybookConfig } from '@storybook/angular';
const config: StorybookConfig = { const config: StorybookConfig = {
"stories": [ stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
"../src/**/*.mdx", addons: ['@storybook/addon-a11y', '@storybook/addon-docs', '@storybook/addon-onboarding'],
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)" framework: '@storybook/angular',
],
"addons": [
"@storybook/addon-a11y",
"@storybook/addon-docs",
"@storybook/addon-onboarding"
],
"framework": "@storybook/angular",
// Serve the vendored CIBG package so preview-head.html can <link> its CSS (and its // Serve the vendored CIBG package so preview-head.html can <link> its CSS (and its
// relative font/icon/image url()s resolve) — mirrors index.html for the real app. // relative font/icon/image url()s resolve) — mirrors index.html for the real app.
"staticDirs": ["../public"] staticDirs: ['../public'],
}; };
export default config; export default config;

View File

@@ -11,9 +11,7 @@ if (typeof document !== 'undefined') document.body.classList.add('brand--cibg');
const preview: Preview = { const preview: Preview = {
// CIBG/Bootstrap styles bare elements — no theme wrapper class needed; just pad. // CIBG/Bootstrap styles bare elements — no theme wrapper class needed; just pad.
// The token bridge lives in styles.scss (loaded via the app build target). // The token bridge lives in styles.scss (loaded via the app build target).
decorators: [ decorators: [componentWrapperDecorator((story) => `<div style="padding:1.5rem">${story}</div>`)],
componentWrapperDecorator((story) => `<div style="padding:1.5rem">${story}</div>`),
],
parameters: { parameters: {
layout: 'padded', layout: 'padded',
// Accessibility (axe) via @storybook/addon-a11y. Runs WCAG 2.0/2.1 A+AA rule // Accessibility (axe) via @storybook/addon-a11y. Runs WCAG 2.0/2.1 A+AA rule

View File

@@ -8,7 +8,7 @@ and demonstrates a robust **async-state pattern** where the UI can never reach a
inconsistent state. inconsistent state.
> Demo / POC — **no real login** (DigiD is faked) and synthetic seed data. The > Demo / POC — **no real login** (DigiD is faked) and synthetic seed data. The
> business rules and data *are* served by a real **ASP.NET Core backend** > business rules and data _are_ served by a real **ASP.NET Core backend**
> (`backend/`) consumed through a generated typed client, so the BFF + DDD design > (`backend/`) consumed through a generated typed client, so the BFF + DDD design
> is demonstrable, not hand-waved. A system-font stack stands in for the licensed > is demonstrable, not hand-waved. A system-font stack stands in for the licensed
> Rijksoverheid font and a text wordmark for the logo. > Rijksoverheid font and a text wordmark for the logo.
@@ -49,7 +49,7 @@ eligibility, thresholds); see **[backend/README.md](backend/README.md)**.
Append `?scenario=` to any data page (e.g. `/dashboard`) to force an async state: Append `?scenario=` to any data page (e.g. `/dashboard`) to force an async state:
| URL | What you see | | URL | What you see |
|-----|--------------| | ----------------------------- | -------------------------------------------- |
| `/dashboard` | real data (fast) | | `/dashboard` | real data (fast) |
| `/dashboard?scenario=slow` | skeletons for ~2.5s, then data | | `/dashboard?scenario=slow` | skeletons for ~2.5s, then data |
| `/dashboard?scenario=loading` | the loading state, held open | | `/dashboard?scenario=loading` | the loading state, held open |
@@ -61,10 +61,10 @@ Append `?scenario=` to any data page (e.g. `/dashboard`) to force an async state
## How atomic design works here (folder = layer) ## How atomic design works here (folder = layer)
Atomic design organizes UI into five layers, each built from the one below. In this repo Atomic design organizes UI into five layers, each built from the one below. In this repo
the folder structure *is* the hierarchy (`src/app/`): the folder structure _is_ the hierarchy (`src/app/`):
| Layer | What it is | Examples here | | Layer | What it is | Examples here |
|-------|-----------|---------------| | -------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| **atoms/** | smallest building blocks; wrap one design-system element | `button`, `text-input`, `heading`, `link`, `alert`, `status-badge`, `spinner`, `skeleton` | | **atoms/** | smallest building blocks; wrap one design-system element | `button`, `text-input`, `heading`, `link`, `alert`, `status-badge`, `spinner`, `skeleton` |
| **molecules/** | a few atoms combined into a unit | `form-field` (label + input + error), `data-row`, `async` (state wrapper) | | **molecules/** | a few atoms combined into a unit | `form-field` (label + input + error), `data-row`, `async` (state wrapper) |
| **organisms/** | larger, self-contained sections | `site-header`, `site-footer`, `login-form`, `registration-summary`, `registration-table`, `change-request-form` | | **organisms/** | larger, self-contained sections | `site-header`, `site-footer`, `login-form`, `registration-summary`, `registration-table`, `change-request-form` |
@@ -82,9 +82,9 @@ does the visual work and we only own a small, typed component API.
**1. Reuse — the same blocks appear everywhere.** **1. Reuse — the same blocks appear everywhere.**
| Component | Appears in | | Component | Appears in |
|-----------|-----------| | ----------------------------- | ------------------------------------------------------------- |
| `button` | login, change-request, herregistratie, async retry, Storybook | | `button` | login, change-request, herregistratie, async retry, Storybook |
| `form-field` + `text-input` | login form *and* change-request *and* herregistratie | | `form-field` + `text-input` | login form _and_ change-request _and_ herregistratie |
| `status-badge` | dashboard summary, detail summary | | `status-badge` | dashboard summary, detail summary |
| `page-shell` / `page-layout` | all four pages | | `page-shell` / `page-layout` | all four pages |
| `site-header` / `site-footer` | every page | | `site-header` / `site-footer` | every page |
@@ -125,7 +125,7 @@ decisions the FE renders rather than recomputes (see ADR-0001).
The molecule **`<app-async>`** turns those signals into UI. It renders **exactly one** of The molecule **`<app-async>`** turns those signals into UI. It renders **exactly one** of
four slots, chosen by a single `computed` — so loading, empty, error and loaded are four slots, chosen by a single `computed` — so loading, empty, error and loaded are
mutually exclusive *by construction*. You cannot render data and an error at the same mutually exclusive _by construction_. You cannot render data and an error at the same
time, or show stale content during a hard failure: those states are unrepresentable. time, or show stale content during a hard failure: those states are unrepresentable.
```html ```html
@@ -139,7 +139,7 @@ time, or show stale content during a hard failure: those states are unrepresenta
- **Loaded** — your content, with the value. - **Loaded** — your content, with the value.
- **Loading** — your skeleton, or a default **delayed spinner** (only appears after - **Loading** — your skeleton, or a default **delayed spinner** (only appears after
~250ms, so fast connections never flash a spinner; slow ones get feedback). Skeletons ~250ms, so fast connections never flash a spinner; slow ones get feedback). Skeletons
are also delay-gated. → *handles slow vs fast connections.* are also delay-gated. → _handles slow vs fast connections._
- **Empty** — your message, or a default "Geen gegevens gevonden" (driven by an - **Empty** — your message, or a default "Geen gegevens gevonden" (driven by an
`isEmpty` predicate). `isEmpty` predicate).
- **Error** — your template, or a default alert + a **retry** button that calls - **Error** — your template, or a default alert + a **retry** button that calls
@@ -184,6 +184,7 @@ Babel 8 (a breaking change across the Storybook/Babel chain) and is deliberately
We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 22 → 21. We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 22 → 21.
### Deliberately out of scope (POC) ### Deliberately out of scope (POC)
Real auth/DigiD, real BRP/DUO upstreams, a database/persisted audit store, i18n, Real auth/DigiD, real BRP/DUO upstreams, a database/persisted audit store, i18n,
NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark). (The backend NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark). (The backend
itself *is* implemented.) itself _is_ implemented.)

View File

@@ -13,7 +13,7 @@ services:
- api-bin:/src/src/BigRegister.Api/bin - api-bin:/src/src/BigRegister.Api/bin
- api-obj:/src/src/BigRegister.Api/obj - api-obj:/src/src/BigRegister.Api/obj
ports: ports:
- "5000:5000" - '5000:5000'
web: web:
image: node:24 image: node:24
@@ -24,7 +24,7 @@ services:
- ./:/app:z - ./:/app:z
- web-modules:/app/node_modules - web-modules:/app/node_modules
ports: ports:
- "4200:4200" - '4200:4200'
depends_on: depends_on:
- api - api

View File

@@ -21,7 +21,7 @@ backend team.
## Options considered ## Options considered
| Option | Fewer calls? | Unifies policy? | Cost | | Option | Fewer calls? | Unifies policy? | Cost |
|---|---|---|---| | -------------------------------------------------------------- | ------------------------- | --------------------------------------------------------- | --------------------------- |
| 1. Status quo (client calls upstreams, aggregates, owns rules) | No | No | — | | 1. Status quo (client calls upstreams, aggregates, owns rules) | No | No | — |
| 2. Unified client API layer (one facade in the FE) | No — still N round-trips | No — rules still on client | Low, but misses the goal | | 2. Unified client API layer (one facade in the FE) | No — still N round-trips | No — rules still on client | Low, but misses the goal |
| **3. Screen-shaped endpoints on our own backend ("BFF-lite")** | **Yes** — 1 call/screen | **Yes** — server computes decisions | Lowmedium | | **3. Screen-shaped endpoints on our own backend ("BFF-lite")** | **Yes** — 1 call/screen | **Yes** — server computes decisions | Lowmedium |
@@ -40,7 +40,7 @@ them.** Keep it minimal: implement BFF-shaped endpoints on the backend we alread
own. Promote to a separately-deployed BFF service only when a second consumer own. Promote to a separately-deployed BFF service only when a second consumer
(mobile/partner) or a team boundary demands it — not before. (mobile/partner) or a team boundary demands it — not before.
### Why DTOs *decouple* rather than couple ### Why DTOs _decouple_ rather than couple
The coupling people fear comes from **not** having DTOs — i.e. serializing internal The coupling people fear comes from **not** having DTOs — i.e. serializing internal
DB/domain entities straight onto the wire, so every schema change ripples to the DB/domain entities straight onto the wire, so every schema change ripples to the
@@ -53,7 +53,7 @@ DB entity / domain model → DTO (the wire contract) → FE view model
Each side keeps its own internal model and refactors freely; only the DTO is a Each side keeps its own internal model and refactors freely; only the DTO is a
deliberate, versioned change. The one coupling that remains — both sides agreeing deliberate, versioned change. The one coupling that remains — both sides agreeing
on the contract — is the *wanted*, reviewable seam. Manage it with **one source of on the contract — is the _wanted_, reviewable seam. Manage it with **one source of
truth** (OpenAPI or TypeSpec) that **generates types for both sides**. That spec is truth** (OpenAPI or TypeSpec) that **generates types for both sides**. That spec is
the governance/transparency artifact. the governance/transparency artifact.
@@ -76,6 +76,7 @@ This POC has no real backend (static mock JSON + fake submit timers), so the
would compute. Two slices were implemented to demonstrate **both** policy shapes: would compute. Two slices were implemented to demonstrate **both** policy shapes:
**A. Dashboard profile → one aggregated, decision-enriched call (decision-flag).** **A. Dashboard profile → one aggregated, decision-enriched call (decision-flag).**
- Contract: `src/app/registratie/contracts/dashboard-view.dto.ts` - Contract: `src/app/registratie/contracts/dashboard-view.dto.ts`
(`DashboardViewDto` = registration + person + `decisions`). (`DashboardViewDto` = registration + person + `decisions`).
- Endpoint: `public/mock/dashboard-view.json` (one call replaces three). - Endpoint: `public/mock/dashboard-view.json` (one call replaces three).
@@ -92,6 +93,7 @@ would compute. Two slices were implemented to demonstrate **both** policy shapes
`brp.json`) were deleted — those calls live behind the BFF now. `brp.json`) were deleted — those calls live behind the BFF now.
**B. Intake scholing threshold → config value.** **B. Intake scholing threshold → config value.**
- Contract: `src/app/herregistratie/contracts/intake-policy.dto.ts`. - Contract: `src/app/herregistratie/contracts/intake-policy.dto.ts`.
- Endpoint: `public/mock/intake-policy.json` (`{ "scholingThreshold": 1000 }`). - Endpoint: `public/mock/intake-policy.json` (`{ "scholingThreshold": 1000 }`).
- `intake.machine.ts`: the hardcoded `LAGE_UREN_DREMPEL` constant is gone; - `intake.machine.ts`: the hardcoded `LAGE_UREN_DREMPEL` constant is gone;

View File

@@ -7,7 +7,7 @@ Status: Proposed · Date: 2026-07-01
Today the app knows exactly one actor. `auth/domain/session.ts` is a flat Today the app knows exactly one actor. `auth/domain/session.ts` is a flat
`Session { bsn, naam }`, authentication is a faked DigiD flow, and the backend has no `Session { bsn, naam }`, authentication is a faked DigiD flow, and the backend has no
role model at all (only an `X-Admin: true` header seam in `Program.cs` and a stringly-typed role model at all (only an `X-Admin: true` header seam in `Program.cs` and a stringly-typed
`Actor` on audit entries). This whole repo *is* the **Zorgverlener** self-service portal (SSP). `Actor` on audit entries). This whole repo _is_ the **Zorgverlener** self-service portal (SSP).
We now need a second user group — **Behandelaar** (backoffice: assessing and deciding on We now need a second user group — **Behandelaar** (backoffice: assessing and deciding on
applications) — and want room for others later (admin, auditor, institution rep). The question applications) — and want room for others later (admin, auditor, institution rep). The question
@@ -28,7 +28,7 @@ Confirmed constraints (with the product owner):
## Options considered ## Options considered
| Option | Ubiquitous language respected? | Coupling | Verdict | | Option | Ubiquitous language respected? | Coupling | Verdict |
|---|---|---|---| | ------------------------------------------------------------------------------ | --------------------------------------------------------------- | -------- | --------- |
| 1. Split contexts **by role** (`zorgverlener/`, `behandelaar/` folders) | No — role ≠ capability; features smear across personas | High | Reject | | 1. Split contexts **by role** (`zorgverlener/`, `behandelaar/` folders) | No — role ≠ capability; features smear across personas | High | Reject |
| 2. One catch-all **`users`/`identity`** context owning everything about people | No — becomes a god-context; mixes identity, authz, and features | High | Reject | | 2. One catch-all **`users`/`identity`** context owning everything about people | No — becomes a god-context; mixes identity, authz, and features | High | Reject |
| 3. **Actors are personas; contexts are capabilities; identity is typed** | Yes | Low | **Adopt** | | 3. **Actors are personas; contexts are capabilities; identity is typed** | Yes | Low | **Adopt** |
@@ -42,9 +42,9 @@ Confirmed constraints (with the product owner):
The same real-world thing is described in two different languages: The same real-world thing is described in two different languages:
- **Zelfbediening (SSP)** — the Zorgverlener: *"ik vraag herregistratie aan"* — eligibility, fill in - **Zelfbediening (SSP)** — the Zorgverlener: _"ik vraag herregistratie aan"_ — eligibility, fill in
my data, upload documents, submit. **This repo.** my data, upload documents, submit. **This repo.**
- **Behandeling (backoffice)** — the Behandelaar: *"ik beoordeel de aanvraag"* — werkvoorraad, - **Behandeling (backoffice)** — the Behandelaar: _"ik beoordeel de aanvraag"_ — werkvoorraad,
beoordeling, besluit, meer-info-opvragen, SLA, audit. **A sibling application**, not a folder here. beoordeling, besluit, meer-info-opvragen, SLA, audit. **A sibling application**, not a folder here.
Diverging verbs over the same noun is the textbook signal for **two bounded contexts**. Diverging verbs over the same noun is the textbook signal for **two bounded contexts**.
@@ -52,9 +52,9 @@ Diverging verbs over the same noun is the textbook signal for **two bounded cont
### 2. The aggregate is owned by the backend; the contexts integrate through it ### 2. The aggregate is owned by the backend; the contexts integrate through it
The aanvraag/registration is the **system of record in the backend domain**. Neither frontend owns The aanvraag/registration is the **system of record in the backend domain**. Neither frontend owns
it. They integrate *through the backend* using the **BFF-lite decision DTOs of ADR-0001** — the same it. They integrate _through the backend_ using the **BFF-lite decision DTOs of ADR-0001** — the same
aggregate projected into two screen-shaped views. The **aanvraag status lifecycle** is the *published aggregate projected into two screen-shaped views. The **aanvraag status lifecycle** is the _published
contract* between the two contexts: contract_ between the two contexts:
``` ```
Ingediend → In behandeling → (Meer info gevraagd ⇄) → Goedgekeurd / Afgewezen Ingediend → In behandeling → (Meer info gevraagd ⇄) → Goedgekeurd / Afgewezen
@@ -102,14 +102,14 @@ These are two concerns people habitually conflate; keeping them apart is the cru
second actor arrives. second actor arrives.
- **Authorization — "what may you do"** → enforced at the **backend / context boundary**, where the - **Authorization — "what may you do"** → enforced at the **backend / context boundary**, where the
backend is the authority (per ADR-0001). It is *not* a permission matrix living in `auth`. The backend is the authority (per ADR-0001). It is _not_ a permission matrix living in `auth`. The
frontend receives only the decisions it needs to render (e.g. a `canBeoordelen` flag), exactly like frontend receives only the decisions it needs to render (e.g. a `canBeoordelen` flag), exactly like
every other server-owned rule. every other server-owned rule.
### 4. "Other users" slot in without inventing contexts ### 4. "Other users" slot in without inventing contexts
Admin, auditor, institution-rep are additional **`Principal` variants** or additional **`rollen` on Admin, auditor, institution-rep are additional **`Principal` variants** or additional **`rollen` on
`medewerker`** — never a new folder-per-role. A genuinely new *bounded context* is warranted only when `medewerker`** — never a new folder-per-role. A genuinely new _bounded context_ is warranted only when
an actor brings a new **language and capability** (e.g. an "Toezicht/Handhaving" enforcement context), an actor brings a new **language and capability** (e.g. an "Toezicht/Handhaving" enforcement context),
not merely a new login. not merely a new login.
@@ -121,7 +121,7 @@ not merely a new login.
`authGuard`/`SessionStore` seams already localise that (`auth.guard.ts`, `session.store.ts`). `authGuard`/`SessionStore` seams already localise that (`auth.guard.ts`, `session.store.ts`).
- The backend becomes the authority for the **aanvraag status lifecycle** and for **authorization**, - The backend becomes the authority for the **aanvraag status lifecycle** and for **authorization**,
publishing both as decision DTOs — a natural extension of ADR-0001, not a new pattern. publishing both as decision DTOs — a natural extension of ADR-0001, not a new pattern.
- `pendingHerregistratie` is understood as a *temporary stand-in* for a real, backend-owned status. - `pendingHerregistratie` is understood as a _temporary stand-in_ for a real, backend-owned status.
## Out of scope here (next steps, not built) ## Out of scope here (next steps, not built)

View File

@@ -23,7 +23,7 @@ layer — not a palette swap.
references resolve at runtime. Storybook serves the same via `staticDirs`. references resolve at runtime. Storybook serves the same via `staticDirs`.
2. **Token bridge over token rewrite.** `src/styles.scss` redefines the app's ~54 `--rhc-*` tokens 2. **Token bridge over token rewrite.** `src/styles.scss` redefines the app's ~54 `--rhc-*` tokens
onto CIBG values (`--bs-*` where one exists, CIBG palette hex otherwise). The `--rhc-*` names are onto CIBG values (`--bs-*` where one exists, CIBG palette hex otherwise). The `--rhc-*` names are
now an internal alias set; the *values* are CIBG. This avoided rewriting 300+ token references and now an internal alias set; the _values_ are CIBG. This avoided rewriting 300+ token references and
keeps the "components reference tokens" convention intact. (`styles.scss` is exempt from keeps the "components reference tokens" convention intact. (`styles.scss` is exempt from
`check:tokens`, so palette hex lives in that one file only.) `check:tokens`, so palette hex lives in that one file only.)
3. **Re-skin atoms, keep their `input()` APIs.** Each `shared/ui` atom now emits Bootstrap/CIBG classes 3. **Re-skin atoms, keep their `input()` APIs.** Each `shared/ui` atom now emits Bootstrap/CIBG classes
@@ -42,7 +42,7 @@ layer — not a palette swap.
(`public/` already copied), and `.storybook/` — plus the class strings in ~40 `shared/ui` + (`public/` already copied), and `.storybook/` — plus the class strings in ~40 `shared/ui` +
`shared/layout` + a few domain components. The `@rijkshuisstijl-community/*` deps are dropped. `shared/layout` + a few domain components. The `@rijkshuisstijl-community/*` deps are dropped.
- `check:tokens` still guards raw hex in components; the token bridge + hand-rolled surfaces comply. - `check:tokens` still guards raw hex in components; the token bridge + hand-rolled surfaces comply.
- Known benign build warning: *"Unable to locate stylesheet: /cibg-huisstijl/css/huisstijl.min.css"* - Known benign build warning: _"Unable to locate stylesheet: /cibg-huisstijl/css/huisstijl.min.css"_
Angular's index optimizer doesn't process a `public/` stylesheet at build time. The asset is copied Angular's index optimizer doesn't process a `public/` stylesheet at build time. The asset is copied
and the link is preserved (verified: served 200, `.btn-primary` present); the build exits green. The and the link is preserved (verified: served 200, `.btn-primary` present); the build exits green. The
alternative (adding the CSS to `angular.json` `styles`) would force-bundle the licensed fonts we alternative (adding the CSS to `angular.json` `styles`) would force-bundle the licensed fonts we

View File

@@ -39,7 +39,7 @@ Gates land before the work they cover; each lint rule lands in the same WP as th
for its existing violations, so every WP ends green. for its existing violations, so every WP ends green.
| WP | Title | Phase | Status | | WP | Title | Phase | Status |
|----|-------|-------|--------| | --------------------------------------- | ------------------------------------------------------------ | ------------- | ------ |
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done | | [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-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-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
@@ -72,12 +72,20 @@ Status: todo | in-progress | done (<commit>)
Phase: N — name Phase: N — name
## Why ## Why
## Read first ## Read first
## Decisions (pre-made, don't relitigate) ## Decisions (pre-made, don't relitigate)
## Files ## Files
## Steps ## Steps
## Acceptance criteria ## Acceptance criteria
## Verification ## Verification
## Out of scope ## Out of scope
## Risks ## Risks
``` ```

View File

@@ -6,7 +6,7 @@ Phase: 0 — enforcement & gates
## Why ## Why
The Storybook a11y addon (`@storybook/addon-a11y`, configured in `.storybook/preview.ts` The Storybook a11y addon (`@storybook/addon-a11y`, configured in `.storybook/preview.ts`
for `wcag2a, wcag2aa, wcag21a, wcag21aa`) only surfaces violations *interactively*. for `wcag2a, wcag2aa, wcag21a, wcag21aa`) only surfaces violations _interactively_.
Nothing gates CI. This WP turns "a panel you can look at" into "a check that fails the Nothing gates CI. This WP turns "a panel you can look at" into "a check that fails the
build", so every story added or changed by later WPs is automatically covered. build", so every story added or changed by later WPs is automatically covered.

View File

@@ -30,7 +30,7 @@ principle (every response through a hand-written `parse*` returning `Result`).
- `src/app/registratie/infrastructure/big-register.adapter.ts` (~line 25) — - `src/app/registratie/infrastructure/big-register.adapter.ts` (~line 25) —
`n.type as AantekeningType` → validated parse `n.type as AantekeningType` → validated parse
- `src/app/brief/infrastructure/brief.adapter.ts` (~line 189) — `dto.scope as - `src/app/brief/infrastructure/brief.adapter.ts` (~line 189) — `dto.scope as
PassageScope` → validated parse (the file is otherwise parse-heavy; this one field skips) PassageScope` → validated parse (the file is otherwise parse-heavy; this one field skips)
- New co-located specs: `intake-policy.adapter.spec.ts`, extend - New co-located specs: `intake-policy.adapter.spec.ts`, extend
`big-register.adapter.spec.ts` / `brief.adapter.spec.ts` (create if missing) `big-register.adapter.spec.ts` / `brief.adapter.spec.ts` (create if missing)
- New `src/docs/parse-dont-validate.mdx` — title `Foundations/Parse, don't validate` - New `src/docs/parse-dont-validate.mdx` — title `Foundations/Parse, don't validate`
@@ -57,7 +57,7 @@ GREEN + `npm run test-storybook:ci`. Smoke: intake wizard still loads its policy
## Out of scope ## Out of scope
Runtime validation on *every* endpoint (explicitly out of scope for the POC per Runtime validation on _every_ endpoint (explicitly out of scope for the POC per
CLAUDE.md); `digid.adapter.ts` (faked auth, sanctioned). CLAUDE.md); `digid.adapter.ts` (faked auth, sanctioned).
## Risks ## Risks

View File

@@ -26,7 +26,7 @@ bypassing the shared molecule.
(`Idle | Busy | Failed{error}`), replacing `busy`+`lastError`. `saveState` keeps its (`Idle | Busy | Failed{error}`), replacing `busy`+`lastError`. `saveState` keeps its
union shape (align tag style). union shape (align tag style).
- Load lifecycle → `RemoteData` + `<app-async>`; the machine keeps owning the letter's - Load lifecycle → `RemoteData` + `<app-async>`; the machine keeps owning the letter's
*domain* lifecycle (loading tags move out of the machine only if they purely mirror _domain_ lifecycle (loading tags move out of the machine only if they purely mirror
the fetch — keep the seam: RemoteData = fetch, machine = letter). the fetch — keep the seam: RemoteData = fetch, machine = letter).
- Keep the debounced-save sequencing identical; only re-type the state. - Keep the debounced-save sequencing identical; only re-type the state.

View File

@@ -43,6 +43,7 @@ the list-family rationale to document.
## Files ## Files
Components to mark (closest CIBG concept in parens): Components to mark (closest CIBG concept in parens):
- `skeleton`, `spinner` (Laadindicatie — no vendored class, verified) - `skeleton`, `spinner` (Laadindicatie — no vendored class, verified)
- `upload/` suite (Bestand-upload) - `upload/` suite (Bestand-upload)
- `rich-text-editor` (Tekstgebied) - `rich-text-editor` (Tekstgebied)
@@ -52,7 +53,7 @@ Components to mark (closest CIBG concept in parens):
- `debug-state` (devtool, no CIBG concept) - `debug-state` (devtool, no CIBG concept)
- `status-badge` (deliberate custom, documented in code), `card` (`.app-card`), - `status-badge` (deliberate custom, documented in code), `card` (`.app-card`),
`placeholder-chip` `placeholder-chip`
Plus: Plus:
- Delete `upload-status-banner` + its story; inline alert at its consumer - Delete `upload-status-banner` + its story; inline alert at its consumer
- Header comments on `task-list`/`application-list`/`choice-list` - Header comments on `task-list`/`application-list`/`choice-list`
- New `src/docs/cibg-gaps.mdx` — title `Foundations/CIBG Gap Register` - New `src/docs/cibg-gaps.mdx` — title `Foundations/CIBG Gap Register`

View File

@@ -6,6 +6,7 @@ Phase: 4 — a11y
## Why ## Why
Audit findings axe can't (fully) catch: Audit findings axe can't (fully) catch:
- `form-field` renders a description `<div [id]="fieldId()+'-desc'">` that **no control - `form-field` renders a description `<div [id]="fieldId()+'-desc'">` that **no control
ever references** — `text-input` sets `aria-describedby` only to `…-error` and only ever references** — `text-input` sets `aria-describedby` only to `…-error` and only
when invalid. Screen readers never announce field descriptions (e.g. the BSN hint on when invalid. Screen readers never announce field descriptions (e.g. the BSN hint on

View File

@@ -6,9 +6,10 @@ Phase: 4 — a11y
## Why ## Why
Three app-level gaps close the WCAG story: Three app-level gaps close the WCAG story:
- **No route-change focus/scroll management** — `app.config.ts` has only - **No route-change focus/scroll management** — `app.config.ts` has only
`provideRouter(routes, withViewTransitions())`; after navigation, focus stays wherever `provideRouter(routes, withViewTransitions())`; after navigation, focus stays wherever
it was and scroll position is unmanaged. (The wizard manages focus *within* steps; the it was and scroll position is unmanaged. (The wizard manages focus _within_ steps; the
skip link is the only cross-page mechanism.) skip link is the only cross-page mechanism.)
- **No template a11y linting** — `@angular-eslint` is entirely absent. - **No template a11y linting** — `@angular-eslint` is entirely absent.
- User decision: a **manual WCAG checklist** documents what automation can't test. - User decision: a **manual WCAG checklist** documents what automation can't test.
@@ -60,7 +61,7 @@ Three app-level gaps close the WCAG story:
- [ ] Navigating between routes moves focus to the new page's heading; scroll resets; - [ ] Navigating between routes moves focus to the new page's heading; scroll resets;
view transitions still play. view transitions still play.
- [ ] Template a11y rules active and *proven* to fire; lint green. - [ ] Template a11y rules active and _proven_ to fire; lint green.
- [ ] Checklist checked in with an initial pass filled in for the dashboard at minimum. - [ ] Checklist checked in with an initial pass filled in for the dashboard at minimum.
- [ ] `a11y.mdx` renders; links to checklist and WP-01 skip rules. - [ ] `a11y.mdx` renders; links to checklist and WP-01 skip rules.

View File

@@ -4,7 +4,7 @@ Status: Proposed · Date: 2026-07-01 · Context: SSP / Zorgverlener (see ADR-000
> Cross-references: **ADR-0001** (BFF-lite endpoints + decision DTOs) and **ADR-0002** (user groups as > Cross-references: **ADR-0001** (BFF-lite endpoints + decision DTOs) and **ADR-0002** (user groups as
> actors; the `Concept → In behandeling → Goedgekeurd/Afgewezen` aanvraag lifecycle). This PRD > actors; the `Concept → In behandeling → Goedgekeurd/Afgewezen` aanvraag lifecycle). This PRD
> *materializes* that lifecycle as a backend-owned aggregate — still entirely within the Zorgverlener > _materializes_ that lifecycle as a backend-owned aggregate — still entirely within the Zorgverlener
> self-service context; the Behandelaar/backoffice app that advances manual cases stays a separate, > self-service context; the Behandelaar/backoffice app that advances manual cases stays a separate,
> unbuilt context. > unbuilt context.
@@ -24,7 +24,7 @@ uploaded. Concretely, today:
and there is **no GET-content endpoint**. and there is **no GET-content endpoint**.
- A **manual diploma is hard-rejected**: `SubmissionRules.RejectRegistratie("handmatig")` returns a - A **manual diploma is hard-rejected**: `SubmissionRules.RejectRegistratie("handmatig")` returns a
422 (`backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs`). The product wants such a 422 (`backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs`). The product wants such a
submission to *succeed* and sit in a pending (manual-review) state instead. submission to _succeed_ and sit in a pending (manual-review) state instead.
## 2. Goals ## 2. Goals
@@ -54,7 +54,7 @@ uploaded. Concretely, today:
## 4. Personas ## 4. Personas
Single actor: the **Zorgverlener** (healthcare professional, DigiD/BSN, self-service). Per ADR-0002, Single actor: the **Zorgverlener** (healthcare professional, DigiD/BSN, self-service). Per ADR-0002,
"who may advance a manual application" is the **Behandelaar**, an actor in a *separate* backoffice "who may advance a manual application" is the **Behandelaar**, an actor in a _separate_ backoffice
context that is not part of this app. context that is not part of this app.
## 5. Domain model — the `Aanvraag` aggregate (backend-owned) ## 5. Domain model — the `Aanvraag` aggregate (backend-owned)
@@ -62,7 +62,7 @@ context that is not part of this app.
The backend gains an `Aanvraag` (application) aggregate — the system of record the dashboard reads. The backend gains an `Aanvraag` (application) aggregate — the system of record the dashboard reads.
| Field | Type | Notes | | Field | Type | Notes |
|---|---|---| | ------------------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------- |
| `id` | string (uuid) | client-visible handle; used in the resume deep link | | `id` | string (uuid) | client-visible handle; used in the resume deep link |
| `type` | `registratie` \| `herregistratie` \| `intake` | which wizard | | `type` | `registratie` \| `herregistratie` \| `intake` | which wizard |
| `status` | discriminated union (below) | computed on read for auto-approval | | `status` | discriminated union (below) | computed on read for auto-approval |
@@ -118,7 +118,7 @@ Per-status block (new `aanvraag-block` component; **which** actions/badge a bloc
pure `blockActions(status)`): pure `blockActions(status)`):
| Status | Badge | Body | Actions | | Status | Badge | Body | Actions |
|---|---|---|---| | ------------------ | ---------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------ |
| **Concept** | grey "Concept" | wizard type + "Stap X van Y" | **Verder gaan** (resume), **Annuleren** (cancel) | | **Concept** | grey "Concept" | wizard type + "Stap X van Y" | **Verder gaan** (resume), **Annuleren** (cancel) |
| **In behandeling** | amber "In behandeling" | referentie + ingediend-datum; manual → "wordt handmatig beoordeeld in de backoffice" | view/preview documents | | **In behandeling** | amber "In behandeling" | referentie + ingediend-datum; manual → "wordt handmatig beoordeeld in de backoffice" | view/preview documents |
| **Goedgekeurd** | green "Goedgekeurd" | referentie | — | | **Goedgekeurd** | green "Goedgekeurd" | referentie | — |

View File

@@ -4,7 +4,7 @@ Status: Proposed · Date: 2026-07-02 · Context: SSP / backoffice actors (see AD
> Cross-references: **ADR-0001** (BFF-lite endpoints + decision DTOs), **ADR-0002** (user groups as > Cross-references: **ADR-0001** (BFF-lite endpoints + decision DTOs), **ADR-0002** (user groups as
> actors; identity vs authorization), and **PRD-0001** (the `Aanvraag` lifecycle those decisions gate). > actors; identity vs authorization), and **PRD-0001** (the `Aanvraag` lifecycle those decisions gate).
> This PRD *materializes* ADR-0002's authorization half: the AD server authenticates and supplies > This PRD _materializes_ ADR-0002's authorization half: the AD server authenticates and supplies
> **coarse roles**; the app layers a **fine-grained, app-owned** access model on top, resolved by the > **coarse roles**; the app layers a **fine-grained, app-owned** access model on top, resolved by the
> backend and rendered — never decided — by the UI. > backend and rendered — never decided — by the UI.
@@ -19,8 +19,8 @@ administer:
- **Capability gating** — one role, many buttons: some users in a role may approve letters, reveal a - **Capability gating** — one role, many buttons: some users in a role may approve letters, reveal a
BSN, or advance a manual application; others may not. BSN, or advance a manual application; others may not.
- **Data-scoping** — the same role sees *different rows*: only their own region / office / caseload. - **Data-scoping** — the same role sees _different rows_: only their own region / office / caseload.
- **Field / PII-level** — restrict *which fields* (notably the BSN and other special-category personal - **Field / PII-level** — restrict _which fields_ (notably the BSN and other special-category personal
data under GDPR/AVG art. 9) a user may see or edit, independently of their role. data under GDPR/AVG art. 9) a user may see or edit, independently of their role.
- **Segregation-of-duty / step-up** — combinations and conditions: approver ≠ drafter, four-eyes, - **Segregation-of-duty / step-up** — combinations and conditions: approver ≠ drafter, four-eyes,
recent MFA, time-boxed break-glass. recent MFA, time-boxed break-glass.
@@ -35,13 +35,13 @@ Today the codebase has none of this, and what stands in for a "role" is not a se
requests as an `X-Role` header by a dev-only interceptor (`src/app/shared/infrastructure/role.interceptor.ts`, requests as an `X-Role` header by a dev-only interceptor (`src/app/shared/infrastructure/role.interceptor.ts`,
registered only under `isDevMode()` in `src/app/app.config.ts:22`). `X-Admin: true` is the parallel registered only under `isDevMode()` in `src/app/app.config.ts:22`). `X-Admin: true` is the parallel
admin stand-in. admin stand-in.
- One route guard exists — `authGuard` (`src/app/auth/auth.guard.ts:6-10`) — a pure *authentication* - One route guard exists — `authGuard` (`src/app/auth/auth.guard.ts:6-10`) — a pure _authentication_
check. There is **no** role/permission guard, and **no** `can` / `hasRole` / `isAuthorized` helper check. There is **no** role/permission guard, and **no** `can` / `hasRole` / `isAuthorized` helper
anywhere. anywhere.
- The backend is **fully open**: `backend/src/BigRegister.Api/Program.cs` has no authentication or - The backend is **fully open**: `backend/src/BigRegister.Api/Program.cs` has no authentication or
authorization middleware, no `[Authorize]`, and never reads `HttpContext.User`. Identity is faked via authorization middleware, no `[Authorize]`, and never reads `HttpContext.User`. Identity is faked via
a single `DemoOwner` id (`DocumentStore.cs:26`) plus the client-asserted `X-Role` / `X-Admin` a single `DemoOwner` id (`DocumentStore.cs:26`) plus the client-asserted `X-Role` / `X-Admin`
headers. The brief's two-person rule *is* enforced (`BriefStore.Review`, `backend/.../Data/BriefStore.cs:113-123`: headers. The brief's two-person rule _is_ enforced (`BriefStore.Review`, `backend/.../Data/BriefStore.cs:113-123`:
`if (actingId == e.DrafterId) return Forbidden`) — but against the **unverified** `X-Role` header, so `if (actingId == e.DrafterId) return Forbidden`) — but against the **unverified** `X-Role` header, so
any caller can assert `X-Role: approver`. any caller can assert `X-Role: approver`.
@@ -49,13 +49,13 @@ The building block we need already exists in one place: the **decision-flag seam
computes `(bool, reason)` and embeds it in a screen DTO — `HerregistratieDecisionsDto` inside computes `(bool, reason)` and embeds it in a screen DTO — `HerregistratieDecisionsDto` inside
`DashboardViewDto` (`backend/src/BigRegister.Api/Contracts/Dtos.cs:25-27`), computed by `DashboardViewDto` (`backend/src/BigRegister.Api/Contracts/Dtos.cs:25-27`), computed by
`HerregistratieRule.Evaluate` (`backend/.../Domain/Registrations/HerregistratieRule.cs:16-27`). This `HerregistratieRule.Evaluate` (`backend/.../Domain/Registrations/HerregistratieRule.cs:16-27`). This
PRD extends that same seam from *business* decisions to *authorization* decisions. PRD extends that same seam from _business_ decisions to _authorization_ decisions.
## 2. Goals ## 2. Goals
1. Support all four control types above — **capability gating, data-scoping, field/PII-level, and 1. Support all four control types above — **capability gating, data-scoping, field/PII-level, and
step-up/SoD** — as one coherent model. step-up/SoD** — as one coherent model.
2. **Backend is the authority** for every access decision (per ADR-0001). The UI *mirrors* decisions 2. **Backend is the authority** for every access decision (per ADR-0001). The UI _mirrors_ decisions
for UX; it never computes them. for UX; it never computes them.
3. **AD roles are the base; the app owns a fine-grained overlay.** The two merge **server-side** into a 3. **AD roles are the base; the app owns a fine-grained overlay.** The two merge **server-side** into a
single `Principal`; capabilities are resolved server-side. single `Principal`; capabilities are resolved server-side.
@@ -69,7 +69,7 @@ PRD extends that same seam from *business* decisions to *authorization* decision
## 3. Non-goals / Out of scope (POC) ## 3. Non-goals / Out of scope (POC)
- **Real AD / OIDC / SAML integration.** The AD roles remain *simulated*; how claims actually arrive - **Real AD / OIDC / SAML integration.** The AD roles remain _simulated_; how claims actually arrive
(token, header, SSO) is a wiring concern for later, isolated to `infrastructure/` + the backend (token, header, SSO) is a wiring concern for later, isolated to `infrastructure/` + the backend
authn middleware. authn middleware.
- **A general policy engine (OPA/Cedar/XACML).** We express access as named **capabilities** computed - **A general policy engine (OPA/Cedar/XACML).** We express access as named **capabilities** computed
@@ -85,13 +85,13 @@ PRD extends that same seam from *business* decisions to *authorization* decision
## 4. Personas & attributes ## 4. Personas & attributes
Actors (per ADR-0002): the **Zorgverlener** (self-service, DigiD/BSN) and one or more **backoffice** Actors (per ADR-0002): the **Zorgverlener** (self-service, DigiD/BSN) and one or more **backoffice**
actors (Behandelaar, Beoordelaar). ABAC is what lets these — and finer distinctions *within* a role — actors (Behandelaar, Beoordelaar). ABAC is what lets these — and finer distinctions _within_ a role —
diverge without a folder-per-role explosion. diverge without a folder-per-role explosion.
An access decision is a function of four attribute sets: An access decision is a function of four attribute sets:
| Attribute set | Source | Examples | | Attribute set | Source | Examples |
|---|---|---| | --------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **Subject** | AD roles **+ app overlay + derived context** | AD: `beoordelaar`, `behandelaar`. Overlay: `mag-bsn-inzien`, `mag-brief-goedkeuren`. Derived: own BIG-registration, own region/office | | **Subject** | AD roles **+ app overlay + derived context** | AD: `beoordelaar`, `behandelaar`. Overlay: `mag-bsn-inzien`, `mag-brief-goedkeuren`. Derived: own BIG-registration, own region/office |
| **Resource** | the domain entity | owner id, region, sensitivity class (contains BSN / art. 9 data), status | | **Resource** | the domain entity | owner id, region, sensitivity class (contains BSN / art. 9 data), status |
| **Action** | the request | `view`, `edit`, `approve`, `reveal-bsn`, `beoordelen` | | **Action** | the request | `view`, `edit`, `approve`, `reveal-bsn`, `beoordelen` |
@@ -124,7 +124,7 @@ from `currentRole()` — this PRD moves that authority to the server flag.)
### 5b. Data-scoping (row-level) ### 5b. Data-scoping (row-level)
The server **filters rows by the subject's scope attributes at the source** — a Beoordelaar for region The server **filters rows by the subject's scope attributes at the source** — a Beoordelaar for region
*Noord* receives only *Noord* aanvragen. The FE never receives out-of-scope records and so cannot leak _Noord_ receives only _Noord_ aanvragen. The FE never receives out-of-scope records and so cannot leak
them (no client-side "fetch all, hide some"). Scope is a subject attribute (overlay/derived), applied them (no client-side "fetch all, hide some"). Scope is a subject attribute (overlay/derived), applied
in the query, not a UI filter. in the query, not a UI filter.
@@ -181,7 +181,7 @@ The FE's job is to **mirror** server decisions cleanly and deny-by-default. It r
never read directly by feature code. never read directly by feature code.
> **Non-negotiable:** none of the above is a security boundary. A user who forges `can()` in the > **Non-negotiable:** none of the above is a security boundary. A user who forges `can()` in the
> browser changes only what they *see*; every gated route, action, and field is independently enforced > browser changes only what they _see_; every gated route, action, and field is independently enforced
> by the backend (§7). > by the backend (§7).
## 7. Backend design ## 7. Backend design
@@ -193,7 +193,7 @@ Extends ADR-0001's decision-DTO pattern; closes the "fully open" gap.
authn middleware later). Merge **AD roles + the app-owned overlay** into one `Principal` here — the authn middleware later). Merge **AD roles + the app-owned overlay** into one `Principal` here — the
FE never sees the merge. FE never sees the merge.
- **Resolve + enforce capabilities** in a single shared authorization helper (`Authz.Can(principal, - **Resolve + enforce capabilities** in a single shared authorization helper (`Authz.Can(principal,
action, resource, env)`), used **on every endpoint** — not merely to *emit* flags but to *gate* the action, resource, env)`), used **on every endpoint** — not merely to _emit_ flags but to _gate_ the
operation. Forbidden ⇒ 403 (reuse the existing `Outcome.Forbidden → 403` mapping, operation. Forbidden ⇒ 403 (reuse the existing `Outcome.Forbidden → 403` mapping,
`backend/.../Program.cs:330-335`). Emitting a flag and forgetting to enforce it is the classic `backend/.../Program.cs:330-335`). Emitting a flag and forgetting to enforce it is the classic
broken-object-level-authorization bug; the helper makes emit and enforce the same code path. broken-object-level-authorization bug; the helper makes emit and enforce the same code path.

View File

@@ -25,7 +25,7 @@ onto: spacing `--rhc-space-max-{sm..5xl}`, type `--rhc-text-font-size-*` /
`--rhc-border-radius-*`. `--rhc-border-radius-*`.
| # | Pri | Finding | Resolves to | | # | Pri | Finding | Resolves to |
|---|-----|---------|-------------| | --- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| 1.1 | **H** | `var(--rhc-color-grijs-700)` is referenced in 3 wizards but **`--rhc-color-grijs-*` does not exist** in the package → the "Stap X van Y" text falls back to the default color, not grey. | `--rhc-color-foreground-subtle` | | 1.1 | **H** | `var(--rhc-color-grijs-700)` is referenced in 3 wizards but **`--rhc-color-grijs-*` does not exist** in the package → the "Stap X van Y" text falls back to the default color, not grey. | `--rhc-color-foreground-subtle` |
| 1.2 | **H** | `site-header`/`site-footer` set bg to `var(--rhc-color-lintblauw-700,#154273)` / `…-900,#01689b``lintblauw-900` doesn't exist (only 50700), so the **hex fallback renders**. | header `--rhc-color-lintblauw-700`; footer `--rhc-color-donkerblauw-700`; text `--rhc-color-wit` | | 1.2 | **H** | `site-header`/`site-footer` set bg to `var(--rhc-color-lintblauw-700,#154273)` / `…-900,#01689b``lintblauw-900` doesn't exist (only 50700), so the **hex fallback renders**. | header `--rhc-color-lintblauw-700`; footer `--rhc-color-donkerblauw-700`; text `--rhc-color-wit` |
| 1.3 | M | Raw spacing everywhere: `0.25/0.5/0.75/1/1.5/2/3rem` as `gap`/`margin`/`padding` (≈30 occurrences). | `--rhc-space-max-{sm,md,lg,xl,2xl,3xl,5xl}` | | 1.3 | M | Raw spacing everywhere: `0.25/0.5/0.75/1/1.5/2/3rem` as `gap`/`margin`/`padding` (≈30 occurrences). | `--rhc-space-max-{sm,md,lg,xl,2xl,3xl,5xl}` |
@@ -36,35 +36,35 @@ onto: spacing `--rhc-space-max-{sm..5xl}`, type `--rhc-text-font-size-*` /
## 2. Typography & heading hierarchy ## 2. Typography & heading hierarchy
| # | Pri | Finding | Resolves to | | # | Pri | Finding | Resolves to |
|---|-----|---------|-------------| | --- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| 2.1 | **H** | Each page has one `<h1>` (page-shell) but the **wizard steps have no `<h2>`** — step title is a plain `<p>`. Screen-reader users get no step heading to navigate to. | per-step `<h2>` via `app-heading [level]="2"` | | 2.1 | **H** | Each page has one `<h1>` (page-shell) but the **wizard steps have no `<h2>`** — step title is a plain `<p>`. Screen-reader users get no step heading to navigate to. | per-step `<h2>` via `app-heading [level]="2"` |
| 2.2 | M | Type scale not applied to bespoke text (header wordmark uses raw sizes). | `--rhc-text-font-size-*`, `app-heading` | | 2.2 | M | Type scale not applied to bespoke text (header wordmark uses raw sizes). | `--rhc-text-font-size-*`, `app-heading` |
## 3. Color & contrast ## 3. Color & contrast
| # | Pri | Finding | Resolves to | | # | Pri | Finding | Resolves to |
|---|-----|---------|-------------| | --- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| 3.1 | **H** | Because of 1.1/1.2 the actual rendered colors are partly accidental (default text color, hex fallbacks) — contrast is unverified. | map to real tokens, then verify AA | | 3.1 | **H** | Because of 1.1/1.2 the actual rendered colors are partly accidental (default text color, hex fallbacks) — contrast is unverified. | map to real tokens, then verify AA |
| 3.2 | M | Palette discipline: confirm a single primary blue (lintblauw) + neutrals + status-only accents (`alert` types ok/info/warning/error already map to Utrecht alert variants). | keep alert variants; route blues to lintblauw/donkerblauw tokens | | 3.2 | M | Palette discipline: confirm a single primary blue (lintblauw) + neutrals + status-only accents (`alert` types ok/info/warning/error already map to Utrecht alert variants). | keep alert variants; route blues to lintblauw/donkerblauw tokens |
## 4. Spacing & layout ## 4. Spacing & layout
| # | Pri | Finding | Resolves to | | # | Pri | Finding | Resolves to |
|---|-----|---------|-------------| | --- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| 4.1 | M | Repeated inline idioms: `display:flex;gap:0.5rem;margin-top:1rem` (button rows, 6×), `max-width:30rem` (forms, 3×), `margin:1rem 0` (summaries). | utility classes `.app-button-row`, `.app-form`, `.app-stack` in `styles.scss` | | 4.1 | M | Repeated inline idioms: `display:flex;gap:0.5rem;margin-top:1rem` (button rows, 6×), `max-width:30rem` (forms, 3×), `margin:1rem 0` (summaries). | utility classes `.app-button-row`, `.app-form`, `.app-stack` in `styles.scss` |
| 4.2 | L | `shell` uses a custom `--app-content-max:64rem` defined inline. | promote to the `styles.scss` token layer | | 4.2 | L | `shell` uses a custom `--app-content-max:64rem` defined inline. | promote to the `styles.scss` token layer |
## 5. Component reuse ## 5. Component reuse
| # | Pri | Finding | Resolves to | | # | Pri | Finding | Resolves to |
|---|-----|---------|-------------| | --- | --- | ----------------------------------------------------------------------------------------------------------------------- | --------------------- |
| 5.1 | L | `intake-wizard` review step uses bespoke `<div><dt><dd>` instead of `app-data-row` (out of scope here; note for later). | `app-data-row` | | 5.1 | L | `intake-wizard` review step uses bespoke `<div><dt><dd>` instead of `app-data-row` (out of scope here; note for later). | `app-data-row` |
| 5.2 | M | Button rows / form containers are ad-hoc inline layout rather than a shared idiom. | utility classes (4.1) | | 5.2 | M | Button rows / form containers are ad-hoc inline layout rather than a shared idiom. | utility classes (4.1) |
## 6. Form, validation & status patterns ## 6. Form, validation & status patterns
| # | Pri | Finding | Resolves to | | # | Pri | Finding | Resolves to |
|---|-----|---------|-------------| | --- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| 6.1 | **H** | `form-field` renders the error with `role="alert"` but **no `id`**, and the projected input has **no `aria-describedby`** → error not programmatically linked. | error `id="${fieldId}-error"`; input `aria-describedby` | | 6.1 | **H** | `form-field` renders the error with `role="alert"` but **no `id`**, and the projected input has **no `aria-describedby`** → error not programmatically linked. | error `id="${fieldId}-error"`; input `aria-describedby` |
| 6.2 | **H** | `text-input` never sets `aria-invalid` even when `invalid()` is true. | `[attr.aria-invalid]` | | 6.2 | **H** | `text-input` never sets `aria-invalid` even when `invalid()` is true. | `[attr.aria-invalid]` |
| 6.3 | **H** | `radio-group` has `role="radiogroup"` but **no accessible name** and no invalid state. | `aria-labelledby="${name}-label"`, add `invalid` input → `aria-invalid`/`aria-describedby` | | 6.3 | **H** | `radio-group` has `role="radiogroup"` but **no accessible name** and no invalid state. | `aria-labelledby="${name}-label"`, add `invalid` input → `aria-invalid`/`aria-describedby` |
@@ -74,7 +74,7 @@ onto: spacing `--rhc-space-max-{sm..5xl}`, type `--rhc-text-font-size-*` /
## 7. Page chrome ## 7. Page chrome
| # | Pri | Finding | Resolves to | | # | Pri | Finding | Resolves to |
|---|-----|---------|-------------| | --- | --- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| 7.1 | M | No breadcrumb / location context in the portal chrome. | new `app-breadcrumb` (`.rhc-breadcrumb-nav`), wired via `page-shell` | | 7.1 | M | No breadcrumb / location context in the portal chrome. | new `app-breadcrumb` (`.rhc-breadcrumb-nav`), wired via `page-shell` |
| 7.2 | M | Step progress is non-semantic text. | new `app-stepper` (`<ol>`, `aria-current="step"`) | | 7.2 | M | Step progress is non-semantic text. | new `app-stepper` (`<ol>`, `aria-current="step"`) |
| 7.3 | L | Skip-link uses `left:-999px` (works) and landmarks (`header/main/footer`) are correct. | keep; tokenize offset | | 7.3 | L | Skip-link uses `left:-999px` (works) and landmarks (`header/main/footer`) are correct. | keep; tokenize offset |
@@ -82,13 +82,13 @@ onto: spacing `--rhc-space-max-{sm..5xl}`, type `--rhc-text-font-size-*` /
## 8. Copy & tone ## 8. Copy & tone
| # | Pri | Finding | Resolves to | | # | Pri | Finding | Resolves to |
|---|-----|---------|-------------| | --- | --- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| 8.1 | M | Microcopy is functional but informal/uneven (labels, helper text, button text, the manual-diploma warning, the controle summary). | formal, plain official Dutch; consistent with domain terms (registratie wizard + chrome only) | | 8.1 | M | Microcopy is functional but informal/uneven (labels, helper text, button text, the manual-diploma warning, the controle summary). | formal, plain official Dutch; consistent with domain terms (registratie wizard + chrome only) |
## 9. Accessibility (WCAG 2.1 AA) — consolidated ## 9. Accessibility (WCAG 2.1 AA) — consolidated
| # | Pri | Finding | Resolves to | | # | Pri | Finding | Resolves to |
|---|-----|---------|-------------| | --- | ----- | --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| 9.1 | **H** | No focus management: after Next/Back focus stays on the button; SR/keyboard users aren't moved to the new step. | move focus to the step `<h2>` (`tabindex="-1"`) on `cursor` change | | 9.1 | **H** | No focus management: after Next/Back focus stays on the button; SR/keyboard users aren't moved to the new step. | move focus to the step `<h2>` (`tabindex="-1"`) on `cursor` change |
| 9.2 | **H** | Error/label/invalid association missing (6.16.3). | as above | | 9.2 | **H** | Error/label/invalid association missing (6.16.3). | as above |
| 9.3 | M | Step changes & async states not announced (6.4). | `aria-live` | | 9.3 | M | Step changes & async states not announced (6.4). | `aria-live` |
@@ -98,7 +98,7 @@ onto: spacing `--rhc-space-max-{sm..5xl}`, type `--rhc-text-font-size-*` /
## 10. Responsive ## 10. Responsive
| # | Pri | Finding | Resolves to | | # | Pri | Finding | Resolves to |
|---|-----|---------|-------------| | ---- | --- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| 10.1 | M | Fixed `rem` widths and inline flex rows; verify reflow + ≥24px targets at narrow widths. | token widths + `flex-wrap` on button rows; manual check | | 10.1 | M | Fixed `rem` widths and inline flex rows; verify reflow + ≥24px targets at narrow widths. | token widths + `flex-wrap` on button rows; manual check |
--- ---

View File

@@ -42,7 +42,14 @@ export default [
rules: { rules: {
'no-restricted-imports': [ 'no-restricted-imports': [
'error', 'error',
{ patterns: [{ group: ['@angular/*', '@angular/**'], message: 'domain/ must stay framework-free (pure TS) — no Angular imports.' }] }, {
patterns: [
{
group: ['@angular/*', '@angular/**'],
message: 'domain/ must stay framework-free (pure TS) — no Angular imports.',
},
],
},
], ],
}, },
}, },
@@ -55,7 +62,14 @@ export default [
rules: { rules: {
'no-restricted-imports': [ 'no-restricted-imports': [
'error', 'error',
{ patterns: [{ group: ['@auth/*', '@registratie/*', '@herregistratie/*', '@brief/*'], message: 'shared/ must not depend on a feature context.' }] }, {
patterns: [
{
group: ['@auth/*', '@registratie/*', '@herregistratie/*', '@brief/*'],
message: 'shared/ must not depend on a feature context.',
},
],
},
], ],
}, },
}, },
@@ -66,7 +80,14 @@ export default [
rules: { rules: {
'no-restricted-imports': [ 'no-restricted-imports': [
'error', 'error',
{ patterns: [{ group: ['@registratie/*', '@herregistratie/*', '@brief/*'], message: 'auth/ may depend only on shared.' }] }, {
patterns: [
{
group: ['@registratie/*', '@herregistratie/*', '@brief/*'],
message: 'auth/ may depend only on shared.',
},
],
},
], ],
}, },
}, },
@@ -77,7 +98,14 @@ export default [
rules: { rules: {
'no-restricted-imports': [ 'no-restricted-imports': [
'error', 'error',
{ patterns: [{ group: ['@herregistratie/*', '@brief/*'], message: 'Dependencies point herregistratie → registratie → shared, never back.' }] }, {
patterns: [
{
group: ['@herregistratie/*', '@brief/*'],
message: 'Dependencies point herregistratie → registratie → shared, never back.',
},
],
},
], ],
}, },
}, },
@@ -88,7 +116,14 @@ export default [
rules: { rules: {
'no-restricted-imports': [ 'no-restricted-imports': [
'error', 'error',
{ patterns: [{ group: ['@auth/*', '@registratie/*', '@herregistratie/*'], message: 'brief/ may depend only on shared.' }] }, {
patterns: [
{
group: ['@auth/*', '@registratie/*', '@herregistratie/*'],
message: 'brief/ may depend only on shared.',
},
],
},
], ],
}, },
}, },
@@ -105,8 +140,20 @@ export default [
{ {
patterns: [ patterns: [
{ {
group: ['@angular/**', '@shared/**', '@auth/**', '@registratie/**', '@herregistratie/**', '@brief/**', './*', '../*', './**', '../**'], group: [
message: 'contracts/ is the wire seam — it must import NOTHING (pure DTO shapes). Map wire → domain in the infrastructure adapter, not here.', '@angular/**',
'@shared/**',
'@auth/**',
'@registratie/**',
'@herregistratie/**',
'@brief/**',
'./*',
'../*',
'./**',
'../**',
],
message:
'contracts/ is the wire seam — it must import NOTHING (pure DTO shapes). Map wire → domain in the infrastructure adapter, not here.',
}, },
], ],
}, },
@@ -129,7 +176,8 @@ export default [
{ {
group: ['@shared/infrastructure/api-client'], group: ['@shared/infrastructure/api-client'],
allowTypeImports: true, allowTypeImports: true,
message: 'The ApiClient lives only in infrastructure/ adapters (ADR-0001). UI/application call an adapter or a command, not the network client. (Type-only DTO imports are fine: use `import type`.)', message:
'The ApiClient lives only in infrastructure/ adapters (ADR-0001). UI/application call an adapter or a command, not the network client. (Type-only DTO imports are fine: use `import type`.)',
}, },
], ],
}, },
@@ -168,7 +216,8 @@ export default [
'@brief/infrastructure/*', '@brief/infrastructure/*',
], ],
allowTypeImports: true, 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`.)', 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`.)',
}, },
], ],
}, },

View File

@@ -1,4 +1,9 @@
import { ApplicationConfig, LOCALE_ID, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core'; import {
ApplicationConfig,
LOCALE_ID,
isDevMode,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter, withViewTransitions } from '@angular/router'; import { provideRouter, withViewTransitions } from '@angular/router';
import type { ActivatedRouteSnapshot } from '@angular/router'; import type { ActivatedRouteSnapshot } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideHttpClient, withInterceptors } from '@angular/common/http';
@@ -43,5 +48,5 @@ export const appConfig: ApplicationConfig = {
provideApiClient(), provideApiClient(),
{ provide: SESSION_PORT, useExisting: SessionStore }, { provide: SESSION_PORT, useExisting: SessionStore },
{ provide: LOCALE_ID, useValue: 'nl' }, { provide: LOCALE_ID, useValue: 'nl' },
] ],
}; };

View File

@@ -8,15 +8,53 @@ export const routes: Routes = [
component: ShellComponent, // persistent header/footer; only children swap component: ShellComponent, // persistent header/footer; only children swap
children: [ children: [
{ path: '', pathMatch: 'full', redirectTo: 'login' }, { path: '', pathMatch: 'full', redirectTo: 'login' },
{ path: 'login', loadComponent: () => import('@auth/ui/login.page').then(m => m.LoginPage) }, {
{ path: 'dashboard', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/dashboard.page').then(m => m.DashboardPage) }, path: 'login',
{ path: 'registratie', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registration-detail.page').then(m => m.RegistrationDetailPage) }, loadComponent: () => import('@auth/ui/login.page').then((m) => m.LoginPage),
{ path: 'aanvraag/:id', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/aanvraag-detail.page').then(m => m.AanvraagDetailPage) }, },
{ path: 'registreren', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registratie.page').then(m => m.RegistratiePage) }, {
{ path: 'herregistratie', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage) }, path: 'dashboard',
{ path: 'intake', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/intake.page').then(m => m.IntakePage) }, canActivate: [authGuard],
{ path: 'brief', canActivate: [authGuard], loadComponent: () => import('@brief/ui/brief.page').then(m => m.BriefPage) }, loadComponent: () => import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage),
{ path: 'concepts', loadComponent: () => import('./showcase/concepts.page').then(m => m.ConceptsPage) }, },
{
path: 'registratie',
canActivate: [authGuard],
loadComponent: () =>
import('@registratie/ui/registration-detail.page').then((m) => m.RegistrationDetailPage),
},
{
path: 'aanvraag/:id',
canActivate: [authGuard],
loadComponent: () =>
import('@registratie/ui/aanvraag-detail.page').then((m) => m.AanvraagDetailPage),
},
{
path: 'registreren',
canActivate: [authGuard],
loadComponent: () =>
import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage),
},
{
path: 'herregistratie',
canActivate: [authGuard],
loadComponent: () =>
import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage),
},
{
path: 'intake',
canActivate: [authGuard],
loadComponent: () => import('@herregistratie/ui/intake.page').then((m) => m.IntakePage),
},
{
path: 'brief',
canActivate: [authGuard],
loadComponent: () => import('@brief/ui/brief.page').then((m) => m.BriefPage),
},
{
path: 'concepts',
loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage),
},
{ path: '**', redirectTo: 'login' }, { path: '**', redirectTo: 'login' },
], ],
}, },

View File

@@ -9,7 +9,8 @@ export class DigidAdapter {
// Swap for a real OIDC redirect flow when there's a backend. // Swap for a real OIDC redirect flow when there's a backend.
async authenticate(bsn: string): Promise<Result<string, Session>> { async authenticate(bsn: string): Promise<Result<string, Session>> {
const t = bsn.trim(); const t = bsn.trim();
if (!/^\d{9}$/.test(t)) return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`); if (!/^\d{9}$/.test(t))
return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`);
return ok({ bsn: t, naam: 'Dr. A. (Anna) de Vries' }); return ok({ bsn: t, naam: 'Dr. A. (Anna) de Vries' });
} }
} }

View File

@@ -11,10 +11,19 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
template: ` template: `
<form (ngSubmit)="submitted.emit(bsn)" class="form-horizontal"> <form (ngSubmit)="submitted.emit(bsn)" class="form-horizontal">
<div class="form-header"> <div class="form-header">
<div class="form-action"><span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span></div> <div class="form-action">
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
</div>
</div> </div>
<app-form-field i18n-label="@@login.bsnLabel" label="BSN" fieldId="bsn" required i18n-description="@@login.bsnDescription" description="9 cijfers (demo: vul iets in)"> <app-form-field
i18n-label="@@login.bsnLabel"
label="BSN"
fieldId="bsn"
required
i18n-description="@@login.bsnDescription"
description="9 cijfers (demo: vul iets in)"
>
<app-text-input inputId="bsn" [(ngModel)]="bsn" name="bsn" placeholder="123456789" /> <app-text-input inputId="bsn" [(ngModel)]="bsn" name="bsn" placeholder="123456789" />
</app-form-field> </app-form-field>
@@ -22,7 +31,9 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
<app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" /> <app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" />
</app-form-field> </app-form-field>
<app-button type="submit" variant="primary" i18n="@@login.submit">Inloggen met DigiD</app-button> <app-button type="submit" variant="primary" i18n="@@login.submit"
>Inloggen met DigiD</app-button
>
</form> </form>
`, `,
}) })

View File

@@ -9,9 +9,16 @@ import { SessionStore } from '@auth/application/session.store';
selector: 'app-login-page', selector: 'app-login-page',
imports: [PageShellComponent, AlertComponent, LoginFormComponent], imports: [PageShellComponent, AlertComponent, LoginFormComponent],
template: ` template: `
<app-page-shell i18n-heading="@@login.heading" heading="Inloggen" width="narrow" <app-page-shell
i18n-intro="@@login.intro" intro="Log in op uw persoonlijke BIG-register omgeving."> i18n-heading="@@login.heading"
@if (error()) { <app-alert type="error">{{ error() }}</app-alert> } heading="Inloggen"
width="narrow"
i18n-intro="@@login.intro"
intro="Log in op uw persoonlijke BIG-register omgeving."
>
@if (error()) {
<app-alert type="error">{{ error() }}</app-alert>
}
<app-login-form (submitted)="login($event)" /> <app-login-form (submitted)="login($event)" />
</app-page-shell> </app-page-shell>
`, `,

View File

@@ -3,7 +3,13 @@ import { Result } from '@shared/kernel/fp';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { Role } from '@shared/domain/role'; import { Role } from '@shared/domain/role';
import { currentRole } from '@shared/infrastructure/role'; import { currentRole } from '@shared/infrastructure/role';
import { Brief, allDiagnostics, canSubmit, hasBlockingErrors, unresolvedPlaceholders } from '@brief/domain/brief'; import {
Brief,
allDiagnostics,
canSubmit,
hasBlockingErrors,
unresolvedPlaceholders,
} from '@brief/domain/brief';
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine'; import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { BriefAdapter } from '@brief/infrastructure/brief.adapter'; import { BriefAdapter } from '@brief/infrastructure/brief.adapter';
@@ -34,7 +40,9 @@ export class BriefStore {
treats as editable (draft, or rejected — which reopens to draft on the first edit). */ treats as editable (draft, or rejected — which reopens to draft on the first edit). */
readonly editable = computed(() => { readonly editable = computed(() => {
const b = this.brief(); const b = this.brief();
return !!b && (b.status.tag === 'draft' || b.status.tag === 'rejected') && this.role === 'drafter'; return (
!!b && (b.status.tag === 'draft' || b.status.tag === 'rejected') && this.role === 'drafter'
);
}); });
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : [])); readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : [])); readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
@@ -46,7 +54,12 @@ export class BriefStore {
async load() { async load() {
const r = await this.adapter.load(); const r = await this.adapter.load();
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', brief: r.value.brief, availablePassages: r.value.availablePassages }); if (r.ok)
this.store.dispatch({
tag: 'BriefLoaded',
brief: r.value.brief,
availablePassages: r.value.availablePassages,
});
else this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error }); else this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
} }
@@ -84,7 +97,12 @@ export class BriefStore {
const r = await this.adapter.reset(); const r = await this.adapter.reset();
this.busy.set(false); this.busy.set(false);
this.saveState.set('idle'); this.saveState.set('idle');
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', brief: r.value.brief, availablePassages: r.value.availablePassages }); if (r.ok)
this.store.dispatch({
tag: 'BriefLoaded',
brief: r.value.brief,
availablePassages: r.value.availablePassages,
});
else this.lastError.set(r.error); else this.lastError.set(r.error);
} }
@@ -119,7 +137,12 @@ export class BriefStore {
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt }); this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt });
break; break;
case 'rejected': case 'rejected':
this.store.dispatch({ tag: 'Rejected', by: s.rejectedBy, at: s.rejectedAt, comments: s.comments }); this.store.dispatch({
tag: 'Rejected',
by: s.rejectedBy,
at: s.rejectedAt,
comments: s.comments,
});
break; break;
case 'sent': case 'sent':
this.store.dispatch({ tag: 'Sent', at: s.sentAt }); this.store.dispatch({ tag: 'Sent', at: s.sentAt });

View File

@@ -9,7 +9,9 @@ const placeholders: PlaceholderDef[] = [
{ key: 'reden', label: 'Reden', autoResolvable: false }, { key: 'reden', label: 'Reden', autoResolvable: false },
]; ];
const text = (t: string): RichTextBlock => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] }); const text = (t: string): RichTextBlock => ({
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
});
const libPassage = (id: string, sectionKey: string): LibraryPassage => ({ const libPassage = (id: string, sectionKey: string): LibraryPassage => ({
passageId: id, passageId: id,
@@ -35,7 +37,10 @@ function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
}; };
} }
const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sections']): BriefState => ({ const loaded = (
status: BriefStatus = { tag: 'draft' },
sections?: Brief['sections'],
): BriefState => ({
tag: 'loaded', tag: 'loaded',
brief: briefWith(status, sections), brief: briefWith(status, sections),
availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')], availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
@@ -46,14 +51,27 @@ const sectionBlocks = (s: BriefState, key: string) =>
describe('brief.machine reduce', () => { describe('brief.machine reduce', () => {
it('BriefLoaded / BriefLoadFailed / Seed set state directly', () => { it('BriefLoaded / BriefLoadFailed / Seed set state directly', () => {
expect(reduce(initialLoading(), { tag: 'BriefLoaded', brief: briefWith({ tag: 'draft' }), availablePassages: [] }).tag).toBe('loaded'); expect(
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({ tag: 'failed', reason: 'x' }); reduce(initialLoading(), {
tag: 'BriefLoaded',
brief: briefWith({ tag: 'draft' }),
availablePassages: [],
}).tag,
).toBe('loaded');
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
tag: 'failed',
reason: 'x',
});
const seeded = loaded(); const seeded = loaded();
expect(reduce(initialLoading(), { tag: 'Seed', state: seeded })).toBe(seeded); expect(reduce(initialLoading(), { tag: 'Seed', state: seeded })).toBe(seeded);
}); });
it('PassagesInserted creates one frozen block per passage, in order, with local ids', () => { it('PassagesInserted creates one frozen block per passage, in order, with local ids', () => {
const s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')] }); const s = reduce(loaded(), {
tag: 'PassagesInserted',
sectionKey: 'aanhef',
passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
});
const blocks = sectionBlocks(s, 'aanhef'); const blocks = sectionBlocks(s, 'aanhef');
expect(blocks.map((b) => b.blockId)).toEqual(['local-1', 'local-2']); expect(blocks.map((b) => b.blockId)).toEqual(['local-1', 'local-2']);
expect(blocks.every((b) => b.type === 'passage' && b.edited === false)).toBe(true); expect(blocks.every((b) => b.type === 'passage' && b.edited === false)).toBe(true);
@@ -62,7 +80,11 @@ describe('brief.machine reduce', () => {
it('PassagesInserted deep-copies content — later library mutation does not leak in', () => { it('PassagesInserted deep-copies content — later library mutation does not leak in', () => {
const passage = libPassage('p1', 'aanhef'); const passage = libPassage('p1', 'aanhef');
const s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [passage] }); const s = reduce(loaded(), {
tag: 'PassagesInserted',
sectionKey: 'aanhef',
passages: [passage],
});
// Mutate the source passage object after insertion. // Mutate the source passage object after insertion.
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED'; (passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
const block = sectionBlocks(s, 'aanhef')[0]; const block = sectionBlocks(s, 'aanhef')[0];
@@ -77,7 +99,11 @@ describe('brief.machine reduce', () => {
}); });
it('BlockContentEdited replaces content and marks a passage block edited', () => { it('BlockContentEdited replaces content and marks a passage block edited', () => {
let s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef')] }); let s = reduce(loaded(), {
tag: 'PassagesInserted',
sectionKey: 'aanhef',
passages: [libPassage('p1', 'aanhef')],
});
s = reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('aangepast') }); s = reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('aangepast') });
const block = sectionBlocks(s, 'aanhef')[0]; const block = sectionBlocks(s, 'aanhef')[0];
expect(block.type === 'passage' && block.edited).toBe(true); expect(block.type === 'passage' && block.edited).toBe(true);
@@ -85,7 +111,11 @@ describe('brief.machine reduce', () => {
}); });
it('BlockRemoved and BlockMovedWithinSection reorder within a section', () => { it('BlockRemoved and BlockMovedWithinSection reorder within a section', () => {
let s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')] }); let s = reduce(loaded(), {
tag: 'PassagesInserted',
sectionKey: 'aanhef',
passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
});
s = reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 1 }); s = reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 1 });
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-2', 'local-1']); expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-2', 'local-1']);
s = reduce(s, { tag: 'BlockRemoved', blockId: 'local-2' }); s = reduce(s, { tag: 'BlockRemoved', blockId: 'local-2' });
@@ -94,17 +124,33 @@ describe('brief.machine reduce', () => {
it('edits to a locked section are no-ops (insert, free-text, content, remove, move)', () => { it('edits to a locked section are no-ops (insert, free-text, content, remove, move)', () => {
const lockedSections: Brief['sections'] = [ const lockedSections: Brief['sections'] = [
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: true, blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }] }, {
sectionKey: 'aanhef',
title: 'Aanhef',
required: true,
locked: true,
blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }],
},
{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks: [] }, { sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks: [] },
]; ];
const s = loaded({ tag: 'draft' }, lockedSections); const s = loaded({ tag: 'draft' }, lockedSections);
// The brief value is left untouched (withEdit reallocates state, but the guard returns // The brief value is left untouched (withEdit reallocates state, but the guard returns
// the same brief), so assert on deep equality of the section contents. // the same brief), so assert on deep equality of the section contents.
expect(reduce(s, { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef')] })).toEqual(s); expect(
reduce(s, {
tag: 'PassagesInserted',
sectionKey: 'aanhef',
passages: [libPassage('p1', 'aanhef')],
}),
).toEqual(s);
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' })).toEqual(s); expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' })).toEqual(s);
expect(reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') })).toEqual(s); expect(
reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') }),
).toEqual(s);
expect(reduce(s, { tag: 'BlockRemoved', blockId: 'local-1' })).toEqual(s); expect(reduce(s, { tag: 'BlockRemoved', blockId: 'local-1' })).toEqual(s);
expect(reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 0 })).toEqual(s); expect(reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 0 })).toEqual(
s,
);
// the unlocked section still accepts edits // the unlocked section still accepts edits
const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' }); const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
expect(sectionBlocks(edited, 'kern')).toHaveLength(1); expect(sectionBlocks(edited, 'kern')).toHaveLength(1);
@@ -116,7 +162,12 @@ describe('brief.machine reduce', () => {
}); });
it('editing a rejected letter reopens it to draft', () => { it('editing a rejected letter reopens it to draft', () => {
const s = loaded({ tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'graag aanpassen' }); const s = loaded({
tag: 'rejected',
rejectedBy: 'u2',
rejectedAt: 't',
comments: 'graag aanpassen',
});
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' }); const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft'); expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft');
expect(sectionBlocks(next, 'slot')).toHaveLength(1); expect(sectionBlocks(next, 'slot')).toHaveLength(1);
@@ -128,7 +179,11 @@ describe('brief.machine reduce', () => {
// fill the required section, then submit // fill the required section, then submit
const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' }); const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' });
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't' }); const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't' });
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' }); expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
tag: 'submitted',
submittedBy: 'u1',
submittedAt: 't',
});
}); });
it('approve/reject fire only from submitted; send only from approved', () => { it('approve/reject fire only from submitted; send only from approved', () => {
@@ -136,10 +191,19 @@ describe('brief.machine reduce', () => {
// approve from draft is a no-op // approve from draft is a no-op
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't' })).toEqual(loaded()); expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't' })).toEqual(loaded());
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2' }); const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2' });
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({ tag: 'approved', approvedBy: 'u2', approvedAt: 't2' }); expect(approved.tag === 'loaded' && approved.brief.status).toEqual({
tag: 'approved',
approvedBy: 'u2',
approvedAt: 't2',
});
// reject carries comments // reject carries comments
const rejected = reduce(submitted, { tag: 'Rejected', by: 'u2', at: 't2', comments: 'nee' }); const rejected = reduce(submitted, { tag: 'Rejected', by: 'u2', at: 't2', comments: 'nee' });
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({ tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't2', comments: 'nee' }); expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
tag: 'rejected',
rejectedBy: 'u2',
rejectedAt: 't2',
comments: 'nee',
});
// send only from approved // send only from approved
expect(reduce(submitted, { tag: 'Sent', at: 't' })).toBe(submitted); expect(reduce(submitted, { tag: 'Sent', at: 't' })).toBe(submitted);
const sent = reduce(approved, { tag: 'Sent', at: 't3' }); const sent = reduce(approved, { tag: 'Sent', at: 't3' });

View File

@@ -1,5 +1,13 @@
import { assertNever } from '@shared/kernel/fp'; import { assertNever } from '@shared/kernel/fp';
import { Brief, BriefStatus, LetterBlock, LetterSection, LibraryPassage, allBlocks, canSubmit } from './brief'; import {
Brief,
BriefStatus,
LetterBlock,
LetterSection,
LibraryPassage,
allBlocks,
canSubmit,
} from './brief';
import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-text'; import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-text';
/** /**
@@ -59,8 +67,15 @@ function nextLocalIndex(brief: Brief): number {
return max + 1; return max + 1;
} }
function mapSection(brief: Brief, sectionKey: string, f: (s: LetterSection) => LetterSection): Brief { function mapSection(
return { ...brief, sections: brief.sections.map((s) => (s.sectionKey === sectionKey ? f(s) : s)) }; brief: Brief,
sectionKey: string,
f: (s: LetterSection) => LetterSection,
): Brief {
return {
...brief,
sections: brief.sections.map((s) => (s.sectionKey === sectionKey ? f(s) : s)),
};
} }
/** The section a block currently lives in, or undefined if the block is gone. */ /** The section a block currently lives in, or undefined if the block is gone. */
@@ -86,7 +101,11 @@ function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
return { ...s, brief }; return { ...s, brief };
} }
function insertPassages(brief: Brief, sectionKey: string, passages: readonly LibraryPassage[]): Brief { function insertPassages(
brief: Brief,
sectionKey: string,
passages: readonly LibraryPassage[],
): Brief {
let idx = nextLocalIndex(brief); let idx = nextLocalIndex(brief);
// The freeze happens HERE: each block gets a deep VALUE copy of the library content, // The freeze happens HERE: each block gets a deep VALUE copy of the library content,
// so later library edits can never mutate this letter (frozen snapshot). // so later library edits can never mutate this letter (frozen snapshot).
@@ -102,7 +121,11 @@ function insertPassages(brief: Brief, sectionKey: string, passages: readonly Lib
} }
function addFreeText(brief: Brief, sectionKey: string): Brief { function addFreeText(brief: Brief, sectionKey: string): Brief {
const block: LetterBlock = { type: 'freeText', blockId: `local-${nextLocalIndex(brief)}`, content: emptyBlock() }; const block: LetterBlock = {
type: 'freeText',
blockId: `local-${nextLocalIndex(brief)}`,
content: emptyBlock(),
};
return mapSection(brief, sectionKey, (s) => ({ ...s, blocks: [...s.blocks, block] })); return mapSection(brief, sectionKey, (s) => ({ ...s, blocks: [...s.blocks, block] }));
} }
@@ -118,7 +141,11 @@ function editBlockContent(brief: Brief, blockId: string, content: RichTextBlock)
); );
} }
function moveWithinSection(blocks: readonly LetterBlock[], blockId: string, toIndex: number): LetterBlock[] { function moveWithinSection(
blocks: readonly LetterBlock[],
blockId: string,
toIndex: number,
): LetterBlock[] {
const from = blocks.findIndex((b) => b.blockId === blockId); const from = blocks.findIndex((b) => b.blockId === blockId);
if (from === -1) return [...blocks]; if (from === -1) return [...blocks];
const clamped = Math.max(0, Math.min(toIndex, blocks.length - 1)); const clamped = Math.max(0, Math.min(toIndex, blocks.length - 1));
@@ -140,12 +167,18 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
// Section-level guard (defense-in-depth): locked sections never accept edits, even if a // Section-level guard (defense-in-depth): locked sections never accept edits, even if a
// Msg reaches the reducer. The UI already hides controls for locked sections. // Msg reaches the reducer. The UI already hides controls for locked sections.
case 'PassagesInserted': case 'PassagesInserted':
return withEdit(s, (b) => (isSectionEditable(b, m.sectionKey) ? insertPassages(b, m.sectionKey, m.passages) : b)); return withEdit(s, (b) =>
isSectionEditable(b, m.sectionKey) ? insertPassages(b, m.sectionKey, m.passages) : b,
);
case 'FreeTextBlockAdded': case 'FreeTextBlockAdded':
return withEdit(s, (b) => (isSectionEditable(b, m.sectionKey) ? addFreeText(b, m.sectionKey) : b)); return withEdit(s, (b) =>
isSectionEditable(b, m.sectionKey) ? addFreeText(b, m.sectionKey) : b,
);
case 'BlockContentEdited': case 'BlockContentEdited':
return withEdit(s, (b) => return withEdit(s, (b) =>
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId)) ? editBlockContent(b, m.blockId, m.content) : b, isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
? editBlockContent(b, m.blockId, m.content)
: b,
); );
case 'BlockRemoved': case 'BlockRemoved':
return withEdit(s, (b) => return withEdit(s, (b) =>
@@ -157,18 +190,34 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
return withEdit(s, (b) => return withEdit(s, (b) =>
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId)) isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
? mapBlocks(b, (blocks) => ? mapBlocks(b, (blocks) =>
blocks.some((x) => x.blockId === m.blockId) ? moveWithinSection(blocks, m.blockId, m.toIndex) : [...blocks], blocks.some((x) => x.blockId === m.blockId)
? moveWithinSection(blocks, m.blockId, m.toIndex)
: [...blocks],
) )
: b, : b,
); );
case 'Submitted': case 'Submitted':
// Guard the transition AND the completeness invariant. // Guard the transition AND the completeness invariant.
return transition(s, 'draft', () => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }), canSubmit); return transition(
s,
'draft',
() => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }),
canSubmit,
);
case 'Approved': case 'Approved':
return transition(s, 'submitted', () => ({ tag: 'approved', approvedBy: m.by, approvedAt: m.at })); return transition(s, 'submitted', () => ({
tag: 'approved',
approvedBy: m.by,
approvedAt: m.at,
}));
case 'Rejected': case 'Rejected':
return transition(s, 'submitted', () => ({ tag: 'rejected', rejectedBy: m.by, rejectedAt: m.at, comments: m.comments })); return transition(s, 'submitted', () => ({
tag: 'rejected',
rejectedBy: m.by,
rejectedAt: m.at,
comments: m.comments,
}));
case 'Sent': case 'Sent':
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at })); return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }));

View File

@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { Brief, LetterBlock, allDiagnostics, canSubmit, hasBlockingErrors, unresolvedPlaceholders } from './brief'; import {
Brief,
LetterBlock,
allDiagnostics,
canSubmit,
hasBlockingErrors,
unresolvedPlaceholders,
} from './brief';
import { PlaceholderDef } from './placeholders'; import { PlaceholderDef } from './placeholders';
import { RichTextBlock } from '@shared/kernel/rich-text'; import { RichTextBlock } from '@shared/kernel/rich-text';
@@ -19,21 +26,47 @@ const passage = (blockId: string, ...keys: string[]): LetterBlock => ({
}); });
function brief(sections: Brief['sections']): Brief { function brief(sections: Brief['sections']): Brief {
return { briefId: 'b1', beroep: 'arts', templateId: 't1', placeholders, sections, status: { tag: 'draft' }, drafterId: 'u1' }; return {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
placeholders,
sections,
status: { tag: 'draft' },
drafterId: 'u1',
};
} }
describe('brief selectors', () => { describe('brief selectors', () => {
it('unresolvedPlaceholders returns deduped manual keys only (auto excluded)', () => { it('unresolvedPlaceholders returns deduped manual keys only (auto excluded)', () => {
const b = brief([ const b = brief([
{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1', 'naam', 'reden')] }, {
{ sectionKey: 's2', title: 'S2', required: false, locked: false, blocks: [passage('local-2', 'reden')] }, sectionKey: 's1',
title: 'S1',
required: true,
locked: false,
blocks: [passage('local-1', 'naam', 'reden')],
},
{
sectionKey: 's2',
title: 'S2',
required: false,
locked: false,
blocks: [passage('local-2', 'reden')],
},
]); ]);
expect(unresolvedPlaceholders(b)).toEqual(['reden']); // 'naam' is auto; 'reden' deduped expect(unresolvedPlaceholders(b)).toEqual(['reden']); // 'naam' is auto; 'reden' deduped
}); });
it('allDiagnostics flattens across sections and blocks', () => { it('allDiagnostics flattens across sections and blocks', () => {
const b = brief([ const b = brief([
{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1', 'reden', 'onbekend')] }, {
sectionKey: 's1',
title: 'S1',
required: true,
locked: false,
blocks: [passage('local-1', 'reden', 'onbekend')],
},
]); ]);
const codes = allDiagnostics(b).map((d) => d.code); const codes = allDiagnostics(b).map((d) => d.code);
expect(codes).toContain('unresolved-at-send'); // reden expect(codes).toContain('unresolved-at-send'); // reden
@@ -42,8 +75,28 @@ describe('brief selectors', () => {
}); });
it('canSubmit is false when a required section is empty, true otherwise', () => { it('canSubmit is false when a required section is empty, true otherwise', () => {
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [] }]))).toBe(false); expect(
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: false, locked: false, blocks: [] }]))).toBe(true); canSubmit(
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1')] }]))).toBe(true); brief([{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [] }]),
),
).toBe(false);
expect(
canSubmit(
brief([{ sectionKey: 's1', title: 'S1', required: false, locked: false, blocks: [] }]),
),
).toBe(true);
expect(
canSubmit(
brief([
{
sectionKey: 's1',
title: 'S1',
required: true,
locked: false,
blocks: [passage('local-1')],
},
]),
),
).toBe(true);
}); });
}); });

View File

@@ -59,7 +59,12 @@ export type BriefStatus =
| { readonly tag: 'draft' } | { readonly tag: 'draft' }
| { readonly tag: 'submitted'; readonly submittedBy: string; readonly submittedAt: string } | { readonly tag: 'submitted'; readonly submittedBy: string; readonly submittedAt: string }
| { readonly tag: 'approved'; readonly approvedBy: string; readonly approvedAt: string } | { readonly tag: 'approved'; readonly approvedBy: string; readonly approvedAt: string }
| { readonly tag: 'rejected'; readonly rejectedBy: string; readonly rejectedAt: string; readonly comments: string } | {
readonly tag: 'rejected';
readonly rejectedBy: string;
readonly rejectedAt: string;
readonly comments: string;
}
| { readonly tag: 'sent'; readonly sentAt: string }; | { readonly tag: 'sent'; readonly sentAt: string };
export interface Brief { export interface Brief {
@@ -81,7 +86,9 @@ export function allBlocks(brief: Brief): LetterBlock[] {
/** Every diagnostic in the letter, in section→block→node order. This is what the /** Every diagnostic in the letter, in section→block→node order. This is what the
diagnostics panel renders and what the send gate checks. */ diagnostics panel renders and what the send gate checks. */
export function allDiagnostics(brief: Brief): Diagnostic[] { export function allDiagnostics(brief: Brief): Diagnostic[] {
return allBlocks(brief).flatMap((b) => lintPlaceholders(b.content, brief.placeholders, b.blockId)); return allBlocks(brief).flatMap((b) =>
lintPlaceholders(b.content, brief.placeholders, b.blockId),
);
} }
export function hasBlockingErrors(diagnostics: readonly Diagnostic[]): boolean { export function hasBlockingErrors(diagnostics: readonly Diagnostic[]): boolean {

View File

@@ -9,8 +9,12 @@ const valid: PlaceholderDef[] = [
{ key: 'niet_invulbaar', label: 'Niet invulbaar', autoResolvable: true, fillable: false }, { key: 'niet_invulbaar', label: 'Niet invulbaar', autoResolvable: true, fillable: false },
]; ];
const withPlaceholder = (key: string): RichTextBlock => ({ paragraphs: [{ nodes: [{ type: 'placeholder', key }] }] }); const withPlaceholder = (key: string): RichTextBlock => ({
const withText = (text: string): RichTextBlock => ({ paragraphs: [{ nodes: [{ type: 'text', text }] }] }); paragraphs: [{ nodes: [{ type: 'placeholder', key }] }],
});
const withText = (text: string): RichTextBlock => ({
paragraphs: [{ nodes: [{ type: 'text', text }] }],
});
describe('lintPlaceholders', () => { describe('lintPlaceholders', () => {
it('clean content (auto-resolvable, fillable, current key) yields no diagnostics', () => { it('clean content (auto-resolvable, fillable, current key) yields no diagnostics', () => {
@@ -55,10 +59,17 @@ describe('lintPlaceholders', () => {
const content: RichTextBlock = { const content: RichTextBlock = {
paragraphs: [ paragraphs: [
{ nodes: [{ type: 'placeholder', key: 'onbekend' }] }, { nodes: [{ type: 'placeholder', key: 'onbekend' }] },
{ nodes: [{ type: 'text', text: 'ok' }, { type: 'placeholder', key: 'reden' }] }, {
nodes: [
{ type: 'text', text: 'ok' },
{ type: 'placeholder', key: 'reden' },
],
},
], ],
}; };
const codes = lintPlaceholders(content, valid, 'b1').map((d) => `${d.code}@${d.location.paragraphIndex}.${d.location.nodeIndex}`); const codes = lintPlaceholders(content, valid, 'b1').map(
(d) => `${d.code}@${d.location.paragraphIndex}.${d.location.nodeIndex}`,
);
expect(codes).toEqual(['unknown-placeholder@0.0', 'unresolved-at-send@1.1']); expect(codes).toEqual(['unknown-placeholder@0.0', 'unresolved-at-send@1.1']);
}); });

View File

@@ -77,7 +77,13 @@ function messageFor(code: DiagnosticCode, key?: string): string {
} }
function diag(code: DiagnosticCode, location: DiagnosticLocation, key?: string): Diagnostic { function diag(code: DiagnosticCode, location: DiagnosticLocation, key?: string): Diagnostic {
return { severity: severityOf(code), code, placeholderKey: key, location, message: messageFor(code, key) }; return {
severity: severityOf(code),
code,
placeholderKey: key,
location,
message: messageFor(code, key),
};
} }
/** /**

View File

@@ -19,14 +19,32 @@ const view: BriefViewDto = {
title: 'Aanhef', title: 'Aanhef',
required: true, required: true,
blocks: [ blocks: [
{ type: 'passage', blockId: 'local-1', content: { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }] }] }, sourcePassageId: 'p1', sourceVersion: 2, edited: true }, {
{ type: 'freeText', blockId: 'local-2', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'hoi', marks: ['bold'] }] }] } }, type: 'passage',
blockId: 'local-1',
content: { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }] }] },
sourcePassageId: 'p1',
sourceVersion: 2,
edited: true,
},
{
type: 'freeText',
blockId: 'local-2',
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'hoi', marks: ['bold'] }] }] },
},
], ],
}, },
], ],
}, },
availablePassages: [ availablePassages: [
{ passageId: 'p1', scope: 'global', sectionKey: 'aanhef', label: 'Aanhef', content: { paragraphs: [{ nodes: [] }] }, version: 1 }, {
passageId: 'p1',
scope: 'global',
sectionKey: 'aanhef',
label: 'Aanhef',
content: { paragraphs: [{ nodes: [] }] },
version: 1,
},
], ],
}; };
@@ -35,16 +53,31 @@ describe('brief.adapter parse boundary', () => {
const r = parseBriefView(view); const r = parseBriefView(view);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
expect(r.value.brief.status).toEqual({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }); expect(r.value.brief.status).toEqual({
tag: 'submitted',
submittedBy: 'demo-drafter',
submittedAt: '2026-07-01',
});
const [passage, free] = r.value.brief.sections[0].blocks; const [passage, free] = r.value.brief.sections[0].blocks;
expect(passage.type === 'passage' && passage.edited).toBe(true); expect(passage.type === 'passage' && passage.edited).toBe(true);
expect(free.type).toBe('freeText'); expect(free.type).toBe('freeText');
expect(r.value.brief.placeholders[1]).toEqual({ key: 'code', label: 'Code', autoResolvable: true, fillable: false }); expect(r.value.brief.placeholders[1]).toEqual({
key: 'code',
label: 'Code',
autoResolvable: true,
fillable: false,
});
}); });
it('narrows node variants and rejects unknown ones', () => { it('narrows node variants and rejects unknown ones', () => {
expect(parseNode({ type: 'text', text: 'x' })).toEqual({ ok: true, value: { type: 'text', text: 'x' } }); expect(parseNode({ type: 'text', text: 'x' })).toEqual({
expect(parseNode({ type: 'placeholder', key: 'k' })).toEqual({ ok: true, value: { type: 'placeholder', key: 'k' } }); ok: true,
value: { type: 'text', text: 'x' },
});
expect(parseNode({ type: 'placeholder', key: 'k' })).toEqual({
ok: true,
value: { type: 'placeholder', key: 'k' },
});
expect(parseNode({ type: 'lineBreak' })).toEqual({ ok: true, value: { type: 'lineBreak' } }); expect(parseNode({ type: 'lineBreak' })).toEqual({ ok: true, value: { type: 'lineBreak' } });
expect(parseNode({ type: 'text' }).ok).toBe(false); // missing text expect(parseNode({ type: 'text' }).ok).toBe(false); // missing text
expect(parseNode({ type: 'bogus' } as never).ok).toBe(false); expect(parseNode({ type: 'bogus' } as never).ok).toBe(false);
@@ -87,13 +120,24 @@ describe('brief.adapter parse boundary', () => {
const [aanhef, kern] = r.value.sections; const [aanhef, kern] = r.value.sections;
expect(aanhef.locked).toBe(true); expect(aanhef.locked).toBe(true);
expect(kern.locked).toBe(false); expect(kern.locked).toBe(false);
expect(aanhef.blocks[0].content.paragraphs.map((p) => p.list)).toEqual(['bullet', 'number', undefined]); expect(aanhef.blocks[0].content.paragraphs.map((p) => p.list)).toEqual([
'bullet',
'number',
undefined,
]);
}); });
it('rejects a passage block missing provenance', () => { it('rejects a passage block missing provenance', () => {
const r = parseBrief({ const r = parseBrief({
...view.brief, ...view.brief,
sections: [{ sectionKey: 's', title: 'S', required: false, blocks: [{ type: 'passage', blockId: 'b', content: { paragraphs: [] } }] }], sections: [
{
sectionKey: 's',
title: 'S',
required: false,
blocks: [{ type: 'passage', blockId: 'b', content: { paragraphs: [] } }],
},
],
}); });
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
}); });

View File

@@ -13,7 +13,14 @@ import {
RichTextBlockDto, RichTextBlockDto,
RichTextNodeDto, RichTextNodeDto,
} from '@shared/infrastructure/api-client'; } from '@shared/infrastructure/api-client';
import { Brief, BriefStatus, LetterBlock, LetterSection, LibraryPassage, PassageScope } from '@brief/domain/brief'; import {
Brief,
BriefStatus,
LetterBlock,
LetterSection,
LibraryPassage,
PassageScope,
} from '@brief/domain/brief';
import { PlaceholderDef } from '@brief/domain/placeholders'; import { PlaceholderDef } from '@brief/domain/placeholders';
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text'; import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
@@ -43,7 +50,10 @@ export class BriefAdapter {
} }
async save(sections: readonly LetterSection[]): Promise<Result<string, Brief>> { async save(sections: readonly LetterSection[]): Promise<Result<string, Brief>> {
const r = await runSubmit(() => this.client.briefPUT({ sections: sections.map(sectionToDto) }), BRIEF_ACTION_FAILED); const r = await runSubmit(
() => this.client.briefPUT({ sections: sections.map(sectionToDto) }),
BRIEF_ACTION_FAILED,
);
return r.ok ? parseBrief(r.value) : r; return r.ok ? parseBrief(r.value) : r;
} }
@@ -83,10 +93,16 @@ export function parseNode(dto: RichTextNodeDto): Result<string, RichTextNode> {
case 'text': { case 'text': {
if (typeof dto.text !== 'string') return err('node: text missing text'); if (typeof dto.text !== 'string') return err('node: text missing text');
const marks = dto.marks?.filter((m): m is Mark => MARKS.includes(m)); const marks = dto.marks?.filter((m): m is Mark => MARKS.includes(m));
return ok(marks && marks.length ? { type: 'text', text: dto.text, marks } : { type: 'text', text: dto.text }); return ok(
marks && marks.length
? { type: 'text', text: dto.text, marks }
: { type: 'text', text: dto.text },
);
} }
case 'placeholder': case 'placeholder':
return typeof dto.key === 'string' ? ok({ type: 'placeholder', key: dto.key }) : err('node: placeholder missing key'); return typeof dto.key === 'string'
? ok({ type: 'placeholder', key: dto.key })
: err('node: placeholder missing key');
case 'lineBreak': case 'lineBreak':
return ok({ type: 'lineBreak' }); return ok({ type: 'lineBreak' });
default: default:
@@ -94,7 +110,9 @@ export function parseNode(dto: RichTextNodeDto): Result<string, RichTextNode> {
} }
} }
export function parseBlockContent(dto: RichTextBlockDto | undefined): Result<string, RichTextBlock> { export function parseBlockContent(
dto: RichTextBlockDto | undefined,
): Result<string, RichTextBlock> {
if (!dto || !Array.isArray(dto.paragraphs)) return err('content: paragraphs not an array'); if (!dto || !Array.isArray(dto.paragraphs)) return err('content: paragraphs not an array');
const paragraphs: Paragraph[] = []; const paragraphs: Paragraph[] = [];
for (const p of dto.paragraphs) { for (const p of dto.paragraphs) {
@@ -116,7 +134,8 @@ function parseBlock(dto: LetterBlockDto): Result<string, LetterBlock> {
if (!content.ok) return content; if (!content.ok) return content;
switch (dto.type) { switch (dto.type) {
case 'passage': case 'passage':
if (typeof dto.sourcePassageId !== 'string' || typeof dto.sourceVersion !== 'number') return err('block: bad passage provenance'); if (typeof dto.sourcePassageId !== 'string' || typeof dto.sourceVersion !== 'number')
return err('block: bad passage provenance');
return ok({ return ok({
type: 'passage', type: 'passage',
blockId: dto.blockId, blockId: dto.blockId,
@@ -133,7 +152,11 @@ function parseBlock(dto: LetterBlockDto): Result<string, LetterBlock> {
} }
function parseSection(dto: LetterSectionDto): Result<string, LetterSection> { function parseSection(dto: LetterSectionDto): Result<string, LetterSection> {
if (typeof dto.sectionKey !== 'string' || typeof dto.title !== 'string' || typeof dto.required !== 'boolean') { if (
typeof dto.sectionKey !== 'string' ||
typeof dto.title !== 'string' ||
typeof dto.required !== 'boolean'
) {
return err('section: bad shape'); return err('section: bad shape');
} }
const blocks: LetterBlock[] = []; const blocks: LetterBlock[] = [];
@@ -142,11 +165,21 @@ function parseSection(dto: LetterSectionDto): Result<string, LetterSection> {
if (!parsed.ok) return parsed; if (!parsed.ok) return parsed;
blocks.push(parsed.value); blocks.push(parsed.value);
} }
return ok({ sectionKey: dto.sectionKey, title: dto.title, required: dto.required, locked: dto.locked ?? false, blocks }); return ok({
sectionKey: dto.sectionKey,
title: dto.title,
required: dto.required,
locked: dto.locked ?? false,
blocks,
});
} }
function parsePlaceholderDef(dto: PlaceholderDefDto): Result<string, PlaceholderDef> { function parsePlaceholderDef(dto: PlaceholderDefDto): Result<string, PlaceholderDef> {
if (typeof dto.key !== 'string' || typeof dto.label !== 'string' || typeof dto.autoResolvable !== 'boolean') { if (
typeof dto.key !== 'string' ||
typeof dto.label !== 'string' ||
typeof dto.autoResolvable !== 'boolean'
) {
return err('placeholder: bad shape'); return err('placeholder: bad shape');
} }
return ok({ return ok({
@@ -163,14 +196,26 @@ export function parseStatus(dto: BriefStatusDto | undefined): Result<string, Bri
case 'draft': case 'draft':
return ok({ tag: 'draft' }); return ok({ tag: 'draft' });
case 'submitted': case 'submitted':
if (typeof dto.submittedBy !== 'string' || typeof dto.submittedAt !== 'string') return err('status: bad submitted'); if (typeof dto.submittedBy !== 'string' || typeof dto.submittedAt !== 'string')
return err('status: bad submitted');
return ok({ tag: 'submitted', submittedBy: dto.submittedBy, submittedAt: dto.submittedAt }); return ok({ tag: 'submitted', submittedBy: dto.submittedBy, submittedAt: dto.submittedAt });
case 'approved': case 'approved':
if (typeof dto.approvedBy !== 'string' || typeof dto.approvedAt !== 'string') return err('status: bad approved'); if (typeof dto.approvedBy !== 'string' || typeof dto.approvedAt !== 'string')
return err('status: bad approved');
return ok({ tag: 'approved', approvedBy: dto.approvedBy, approvedAt: dto.approvedAt }); return ok({ tag: 'approved', approvedBy: dto.approvedBy, approvedAt: dto.approvedAt });
case 'rejected': case 'rejected':
if (typeof dto.rejectedBy !== 'string' || typeof dto.rejectedAt !== 'string' || typeof dto.comments !== 'string') return err('status: bad rejected'); if (
return ok({ tag: 'rejected', rejectedBy: dto.rejectedBy, rejectedAt: dto.rejectedAt, comments: dto.comments }); typeof dto.rejectedBy !== 'string' ||
typeof dto.rejectedAt !== 'string' ||
typeof dto.comments !== 'string'
)
return err('status: bad rejected');
return ok({
tag: 'rejected',
rejectedBy: dto.rejectedBy,
rejectedAt: dto.rejectedAt,
comments: dto.comments,
});
case 'sent': case 'sent':
if (typeof dto.sentAt !== 'string') return err('status: bad sent'); if (typeof dto.sentAt !== 'string') return err('status: bad sent');
return ok({ tag: 'sent', sentAt: dto.sentAt }); return ok({ tag: 'sent', sentAt: dto.sentAt });
@@ -180,8 +225,14 @@ export function parseStatus(dto: BriefStatusDto | undefined): Result<string, Bri
} }
function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> { function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep')) return err('passage: bad shape'); if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep'))
if (typeof dto.sectionKey !== 'string' || typeof dto.label !== 'string' || typeof dto.version !== 'number') return err('passage: bad shape'); return err('passage: bad shape');
if (
typeof dto.sectionKey !== 'string' ||
typeof dto.label !== 'string' ||
typeof dto.version !== 'number'
)
return err('passage: bad shape');
const content = parseBlockContent(dto.content); const content = parseBlockContent(dto.content);
if (!content.ok) return content; if (!content.ok) return content;
return ok({ return ok({
@@ -196,7 +247,12 @@ function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
} }
export function parseBrief(dto: BriefDto): Result<string, Brief> { export function parseBrief(dto: BriefDto): Result<string, Brief> {
if (typeof dto.briefId !== 'string' || typeof dto.drafterId !== 'string' || typeof dto.beroep !== 'string' || typeof dto.templateId !== 'string') { if (
typeof dto.briefId !== 'string' ||
typeof dto.drafterId !== 'string' ||
typeof dto.beroep !== 'string' ||
typeof dto.templateId !== 'string'
) {
return err('brief: missing ids'); return err('brief: missing ids');
} }
const status = parseStatus(dto.status); const status = parseStatus(dto.status);
@@ -252,15 +308,33 @@ function nodeToDto(n: RichTextNode): RichTextNodeDto {
} }
function contentToDto(content: RichTextBlock): RichTextBlockDto { function contentToDto(content: RichTextBlock): RichTextBlockDto {
return { paragraphs: content.paragraphs.map((p) => ({ nodes: p.nodes.map(nodeToDto), ...(p.list ? { list: p.list } : {}) })) }; return {
paragraphs: content.paragraphs.map((p) => ({
nodes: p.nodes.map(nodeToDto),
...(p.list ? { list: p.list } : {}),
})),
};
} }
function blockToDto(b: LetterBlock): LetterBlockDto { function blockToDto(b: LetterBlock): LetterBlockDto {
return b.type === 'passage' return b.type === 'passage'
? { type: 'passage', blockId: b.blockId, content: contentToDto(b.content), sourcePassageId: b.sourcePassageId, sourceVersion: b.sourceVersion, edited: b.edited } ? {
type: 'passage',
blockId: b.blockId,
content: contentToDto(b.content),
sourcePassageId: b.sourcePassageId,
sourceVersion: b.sourceVersion,
edited: b.edited,
}
: { type: 'freeText', blockId: b.blockId, content: contentToDto(b.content) }; : { type: 'freeText', blockId: b.blockId, content: contentToDto(b.content) };
} }
function sectionToDto(s: LetterSection): LetterSectionDto { function sectionToDto(s: LetterSection): LetterSectionDto {
return { sectionKey: s.sectionKey, title: s.title, required: s.required, locked: s.locked, blocks: s.blocks.map(blockToDto) }; return {
sectionKey: s.sectionKey,
title: s.title,
required: s.required,
locked: s.locked,
blocks: s.blocks.map(blockToDto),
};
} }

View File

@@ -11,20 +11,38 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
this just wires signals to the organism and events back to store commands. */ this just wires signals to the organism and events back to store commands. */
@Component({ @Component({
selector: 'app-brief-page', selector: 'app-brief-page',
imports: [PageShellComponent, SpinnerComponent, AlertComponent, ButtonComponent, LetterComposerComponent], imports: [
styles: [` PageShellComponent,
.brief-toolbar{display:flex;justify-content:space-between;align-items:center;gap:var(--rhc-space-max-md);margin-block-end:var(--rhc-space-max-lg)} SpinnerComponent,
.save{color:var(--rhc-color-foreground-subtle);font-size:0.9em} AlertComponent,
`], ButtonComponent,
LetterComposerComponent,
],
styles: [
`
.brief-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--rhc-space-max-md);
margin-block-end: var(--rhc-space-max-lg);
}
.save {
color: var(--rhc-color-foreground-subtle);
font-size: 0.9em;
}
`,
],
template: ` template: `
<app-page-shell <app-page-shell [heading]="heading" [intro]="intro" backLink="/dashboard">
[heading]="heading" @if (lastError(); as err) {
[intro]="intro" <app-alert type="error">{{ err }}</app-alert>
backLink="/dashboard"> }
@if (lastError(); as err) { <app-alert type="error">{{ err }}</app-alert> }
@switch (model().tag) { @switch (model().tag) {
@case ('loading') { <app-spinner /> } @case ('loading') {
<app-spinner />
}
@case ('failed') { @case ('failed') {
<app-alert type="error">{{ failedText }}</app-alert> <app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button> <app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
@@ -32,7 +50,9 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
@case ('loaded') { @case ('loaded') {
<div class="brief-toolbar"> <div class="brief-toolbar">
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span> <span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{ resetLabel }}</app-button> <app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
resetLabel
}}</app-button>
</div> </div>
<app-letter-composer <app-letter-composer
[brief]="brief()!" [brief]="brief()!"
@@ -46,7 +66,8 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
(submit)="store.submit()" (submit)="store.submit()"
(approve)="store.approve()" (approve)="store.approve()"
(reject)="store.reject($event)" (reject)="store.reject($event)"
(send)="store.send()" /> (send)="store.send()"
/>
} }
} }
</app-page-shell> </app-page-shell>
@@ -70,10 +91,14 @@ export class BriefPage {
/** Debounced-save state, surfaced in a polite live region. */ /** Debounced-save state, surfaced in a polite live region. */
protected saveText = computed(() => { protected saveText = computed(() => {
switch (this.store.saveState()) { switch (this.store.saveState()) {
case 'saving': return this.savingText; case 'saving':
case 'saved': return this.savedText; return this.savingText;
case 'error': return this.saveErrorText; case 'saved':
default: return ''; return this.savedText;
case 'error':
return this.saveErrorText;
default:
return '';
} }
}); });

View File

@@ -8,17 +8,38 @@ import { Diagnostic } from '@brief/domain/placeholders';
@Component({ @Component({
selector: 'app-diagnostics-panel', selector: 'app-diagnostics-panel',
imports: [AlertComponent], imports: [AlertComponent],
styles: [` styles: [
:host{display:block} `
ul{margin:0;padding-inline-start:1.1rem;display:grid;gap:0.15rem} :host {
button{background:none;border:0;padding:0;color:var(--rhc-color-foreground-link);cursor:pointer;text-align:start;text-decoration:underline} display: block;
`], }
ul {
margin: 0;
padding-inline-start: 1.1rem;
display: grid;
gap: 0.15rem;
}
button {
background: none;
border: 0;
padding: 0;
color: var(--rhc-color-foreground-link);
cursor: pointer;
text-align: start;
text-decoration: underline;
}
`,
],
template: ` template: `
@if (errors().length) { @if (errors().length) {
<app-alert type="error"> <app-alert type="error">
<strong>{{ errorsTitle() }}</strong> <strong>{{ errorsTitle() }}</strong>
<ul> <ul>
@for (d of errors(); track $index) { <li><button type="button" (click)="locate.emit(d)">{{ d.message }}</button></li> } @for (d of errors(); track $index) {
<li>
<button type="button" (click)="locate.emit(d)">{{ d.message }}</button>
</li>
}
</ul> </ul>
</app-alert> </app-alert>
} }
@@ -26,7 +47,11 @@ import { Diagnostic } from '@brief/domain/placeholders';
<app-alert type="warning"> <app-alert type="warning">
<strong>{{ warningsTitle() }}</strong> <strong>{{ warningsTitle() }}</strong>
<ul> <ul>
@for (d of warnings(); track $index) { <li><button type="button" (click)="locate.emit(d)">{{ d.message }}</button></li> } @for (d of warnings(); track $index) {
<li>
<button type="button" (click)="locate.emit(d)">{{ d.message }}</button>
</li>
}
</ul> </ul>
</app-alert> </app-alert>
} }

View File

@@ -1,6 +1,9 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { RichTextBlock } from '@shared/kernel/rich-text'; import { RichTextBlock } from '@shared/kernel/rich-text';
import { RichTextEditorComponent, PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component'; import {
RichTextEditorComponent,
PlaceholderOption,
} from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { LetterBlock } from '@brief/domain/brief'; import { LetterBlock } from '@brief/domain/brief';
@@ -9,21 +12,43 @@ import { LetterBlock } from '@brief/domain/brief';
@Component({ @Component({
selector: 'app-letter-block', selector: 'app-letter-block',
imports: [RichTextEditorComponent, ButtonComponent], imports: [RichTextEditorComponent, ButtonComponent],
styles: [` styles: [
:host{display:block} `
.block{border-inline-start:var(--rhc-border-width-lg) solid var(--rhc-color-border-subtle);padding-inline-start:var(--rhc-space-max-md)} :host {
.meta{display:flex;justify-content:space-between;align-items:center;gap:var(--rhc-space-max-md);margin-block-end:var(--rhc-space-max-sm)} display: block;
.controls{display:flex;gap:var(--rhc-space-max-sm)} }
`], .block {
border-inline-start: var(--rhc-border-width-lg) solid var(--rhc-color-border-subtle);
padding-inline-start: var(--rhc-space-max-md);
}
.meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--rhc-space-max-md);
margin-block-end: var(--rhc-space-max-sm);
}
.controls {
display: flex;
gap: var(--rhc-space-max-sm);
}
`,
],
template: ` template: `
<div class="block"> <div class="block">
<div class="meta"> <div class="meta">
<span class="app-text-subtle">{{ provenance() }}</span> <span class="app-text-subtle">{{ provenance() }}</span>
@if (editable()) { @if (editable()) {
<span class="controls"> <span class="controls">
<app-button variant="subtle" (click)="moved.emit(-1)" i18n="@@brief.block.moveUp">Omhoog</app-button> <app-button variant="subtle" (click)="moved.emit(-1)" i18n="@@brief.block.moveUp"
<app-button variant="subtle" (click)="moved.emit(1)" i18n="@@brief.block.moveDown">Omlaag</app-button> >Omhoog</app-button
<app-button variant="subtle" (click)="removed.emit()" i18n="@@brief.block.remove">Verwijderen</app-button> >
<app-button variant="subtle" (click)="moved.emit(1)" i18n="@@brief.block.moveDown"
>Omlaag</app-button
>
<app-button variant="subtle" (click)="removed.emit()" i18n="@@brief.block.remove"
>Verwijderen</app-button
>
</span> </span>
} }
</div> </div>
@@ -31,7 +56,8 @@ import { LetterBlock } from '@brief/domain/brief';
[content]="block().content" [content]="block().content"
[placeholders]="placeholders()" [placeholders]="placeholders()"
[editable]="editable()" [editable]="editable()"
(contentChanged)="contentChanged.emit($event)" /> (contentChanged)="contentChanged.emit($event)"
/>
</div> </div>
`, `,
}) })

View File

@@ -19,16 +19,44 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
@Component({ @Component({
selector: 'app-letter-composer', selector: 'app-letter-composer',
imports: [ imports: [
HeadingComponent, StatusBadgeComponent, ButtonComponent, AlertComponent, HeadingComponent,
LetterSectionComponent, LetterPreviewComponent, DiagnosticsPanelComponent, RejectionCommentsComponent, StatusBadgeComponent,
ButtonComponent,
AlertComponent,
LetterSectionComponent,
LetterPreviewComponent,
DiagnosticsPanelComponent,
RejectionCommentsComponent,
],
styles: [
`
:host {
display: block;
}
.head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--rhc-space-max-md);
flex-wrap: wrap;
margin-block-end: var(--rhc-space-max-lg);
}
.sections {
display: grid;
gap: var(--rhc-space-max-2xl);
}
.panel {
margin-block: var(--rhc-space-max-xl);
}
.bar {
display: flex;
flex-wrap: wrap;
gap: var(--rhc-space-max-md);
align-items: center;
margin-block-start: var(--rhc-space-max-xl);
}
`,
], ],
styles: [`
:host{display:block}
.head{display:flex;justify-content:space-between;align-items:center;gap:var(--rhc-space-max-md);flex-wrap:wrap;margin-block-end:var(--rhc-space-max-lg)}
.sections{display:grid;gap:var(--rhc-space-max-2xl)}
.panel{margin-block:var(--rhc-space-max-xl)}
.bar{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;margin-block-start:var(--rhc-space-max-xl)}
`],
template: ` template: `
<div class="head"> <div class="head">
<app-heading [level]="2">{{ title() }}</app-heading> <app-heading [level]="2">{{ title() }}</app-heading>
@@ -47,7 +75,8 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
[availablePassages]="availablePassages()" [availablePassages]="availablePassages()"
[placeholders]="menu()" [placeholders]="menu()"
[editable]="!section.locked" [editable]="!section.locked"
(edit)="edit.emit($event)" /> (edit)="edit.emit($event)"
/>
} }
</div> </div>
} @else { } @else {
@@ -62,25 +91,41 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
@switch (status()) { @switch (status()) {
@case ('draft') { @case ('draft') {
@if (editable()) { @if (editable()) {
<app-button variant="primary" [disabled]="!canSubmit() || busy()" (click)="submit.emit()">{{ submitLabel() }}</app-button> <app-button
@if (!canSubmit()) { <span class="app-text-subtle">{{ submitHint() }}</span> } variant="primary"
[disabled]="!canSubmit() || busy()"
(click)="submit.emit()"
>{{ submitLabel() }}</app-button
>
@if (!canSubmit()) {
<span class="app-text-subtle">{{ submitHint() }}</span>
}
} }
} }
@case ('rejected') { @case ('rejected') {
@if (editable()) { @if (editable()) {
<app-button variant="primary" [disabled]="!canSubmit() || busy()" (click)="submit.emit()">{{ resubmitLabel() }}</app-button> <app-button
variant="primary"
[disabled]="!canSubmit() || busy()"
(click)="submit.emit()"
>{{ resubmitLabel() }}</app-button
>
} }
} }
@case ('submitted') { @case ('submitted') {
@if (role() === 'approver') { @if (role() === 'approver') {
<app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{ approveLabel() }}</app-button> <app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{
approveLabel()
}}</app-button>
<app-rejection-comments mode="entry" [busy]="busy()" (reject)="reject.emit($event)" /> <app-rejection-comments mode="entry" [busy]="busy()" (reject)="reject.emit($event)" />
} @else { } @else {
<app-alert type="info">{{ awaitingText() }}</app-alert> <app-alert type="info">{{ awaitingText() }}</app-alert>
} }
} }
@case ('approved') { @case ('approved') {
<app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{ sendLabel() }}</app-button> <app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{
sendLabel()
}}</app-button>
} }
@case ('sent') { @case ('sent') {
<app-alert type="ok">{{ sentText() }}</app-alert> <app-alert type="ok">{{ sentText() }}</app-alert>
@@ -108,10 +153,14 @@ export class LetterComposerComponent {
title = input($localize`:@@brief.title:Brief aan de zorgverlener`); title = input($localize`:@@brief.title:Brief aan de zorgverlener`);
submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`); submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`); resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
submitHint = input($localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`); submitHint = input(
$localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`,
);
approveLabel = input($localize`:@@brief.approve:Goedkeuren`); approveLabel = input($localize`:@@brief.approve:Goedkeuren`);
sendLabel = input($localize`:@@brief.send:Versturen`); sendLabel = input($localize`:@@brief.send:Versturen`);
awaitingText = input($localize`:@@brief.awaiting:De brief wacht op beoordeling door een collega.`); awaitingText = input(
$localize`:@@brief.awaiting:De brief wacht op beoordeling door een collega.`,
);
sentText = input($localize`:@@brief.sent:De brief is verzonden.`); sentText = input($localize`:@@brief.sent:De brief is verzonden.`);
protected status = computed(() => this.brief().status.tag); protected status = computed(() => this.brief().status.tag);
@@ -130,21 +179,30 @@ export class LetterComposerComponent {
protected statusLabel = computed(() => { protected statusLabel = computed(() => {
switch (this.status()) { switch (this.status()) {
case 'draft': return $localize`:@@brief.status.draft:Concept`; case 'draft':
case 'submitted': return $localize`:@@brief.status.submitted:Ter beoordeling`; return $localize`:@@brief.status.draft:Concept`;
case 'approved': return $localize`:@@brief.status.approved:Goedgekeurd`; case 'submitted':
case 'rejected': return $localize`:@@brief.status.rejected:Afgewezen`; return $localize`:@@brief.status.submitted:Ter beoordeling`;
case 'sent': return $localize`:@@brief.status.sent:Verzonden`; case 'approved':
return $localize`:@@brief.status.approved:Goedgekeurd`;
case 'rejected':
return $localize`:@@brief.status.rejected:Afgewezen`;
case 'sent':
return $localize`:@@brief.status.sent:Verzonden`;
} }
}); });
protected statusColor = computed(() => { protected statusColor = computed(() => {
switch (this.status()) { switch (this.status()) {
case 'draft': return 'var(--rhc-color-border-strong)'; case 'draft':
case 'submitted': return 'var(--rhc-color-oranje-500)'; return 'var(--rhc-color-border-strong)';
case 'submitted':
return 'var(--rhc-color-oranje-500)';
case 'approved': case 'approved':
case 'sent': return 'var(--rhc-color-groen-500)'; case 'sent':
case 'rejected': return 'var(--rhc-color-rood-500)'; return 'var(--rhc-color-groen-500)';
case 'rejected':
return 'var(--rhc-color-rood-500)';
} }
}); });
} }

View File

@@ -4,8 +4,33 @@ import { Brief, BriefStatus, LibraryPassage } from '@brief/domain/brief';
import { allDiagnostics } from '@brief/domain/brief'; import { allDiagnostics } from '@brief/domain/brief';
const passages: LibraryPassage[] = [ const passages: LibraryPassage[] = [
{ passageId: 'p1', scope: 'global', sectionKey: 'aanhef', label: 'Standaard aanhef', version: 1, content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw ' }, { type: 'placeholder', key: 'naam_zorgverlener' }, { type: 'text', text: ',' }] }] } }, {
{ passageId: 'p2', scope: 'beroep', beroep: 'arts', sectionKey: 'kern', label: 'Toelichting arts', version: 1, content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] } }, passageId: 'p1',
scope: 'global',
sectionKey: 'aanhef',
label: 'Standaard aanhef',
version: 1,
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Geachte heer/mevrouw ' },
{ type: 'placeholder', key: 'naam_zorgverlener' },
{ type: 'text', text: ',' },
],
},
],
},
},
{
passageId: 'p2',
scope: 'beroep',
beroep: 'arts',
sectionKey: 'kern',
label: 'Toelichting arts',
version: 1,
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] },
},
]; ];
function brief(status: BriefStatus): Brief { function brief(status: BriefStatus): Brief {
@@ -25,22 +50,69 @@ function brief(status: BriefStatus): Brief {
title: 'Aanhef', title: 'Aanhef',
required: true, required: true,
locked: true, locked: true,
blocks: [{ type: 'passage', blockId: 'local-1', sourcePassageId: 'p1', sourceVersion: 1, edited: false, content: passages[0].content }], blocks: [
{
type: 'passage',
blockId: 'local-1',
sourcePassageId: 'p1',
sourceVersion: 1,
edited: false,
content: passages[0].content,
},
],
}, },
{ {
sectionKey: 'kern', sectionKey: 'kern',
title: 'Kern van het besluit', title: 'Kern van het besluit',
required: true, required: true,
locked: false, locked: false,
blocks: [{ type: 'freeText', blockId: 'local-2', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Wij hebben besloten om reden ' }, { type: 'placeholder', key: 'reden_besluit' }, { type: 'text', text: '.' }] }] } }], blocks: [
{
type: 'freeText',
blockId: 'local-2',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Wij hebben besloten om reden ' },
{ type: 'placeholder', key: 'reden_besluit' },
{ type: 'text', text: '.' },
],
},
],
},
},
],
},
{
sectionKey: 'slot',
title: 'Slot',
required: false,
locked: true,
blocks: [
{
type: 'freeText',
blockId: 'local-3',
content: {
paragraphs: [{ nodes: [{ type: 'text', text: 'Met vriendelijke groet,' }] }],
},
},
],
}, },
{ sectionKey: 'slot', title: 'Slot', required: false, locked: true, blocks: [{ type: 'freeText', blockId: 'local-3', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Met vriendelijke groet,' }] }] } }] },
], ],
}; };
} }
const render = (b: Brief, editable: boolean, role: 'drafter' | 'approver') => ({ const render = (b: Brief, editable: boolean, role: 'drafter' | 'approver') => ({
props: { brief: b, availablePassages: passages, diagnostics: allDiagnostics(b), editable, role, canSubmit: true, busy: false }, props: {
brief: b,
availablePassages: passages,
diagnostics: allDiagnostics(b),
editable,
role,
canSubmit: true,
busy: false,
},
template: `<app-letter-composer [brief]="brief" [availablePassages]="availablePassages" [diagnostics]="diagnostics" template: `<app-letter-composer [brief]="brief" [availablePassages]="availablePassages" [diagnostics]="diagnostics"
[editable]="editable" [role]="role" [canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`, [editable]="editable" [role]="role" [canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`,
}); });
@@ -52,7 +124,30 @@ const meta: Meta<LetterComposerComponent> = {
export default meta; export default meta;
type Story = StoryObj<LetterComposerComponent>; type Story = StoryObj<LetterComposerComponent>;
export const DraftDrafter: Story = { render: () => render(brief({ tag: 'draft' }), true, 'drafter') }; export const DraftDrafter: Story = {
export const SubmittedApprover: Story = { render: () => render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), false, 'approver') }; render: () => render(brief({ tag: 'draft' }), true, 'drafter'),
export const Rejected: Story = { render: () => render(brief({ tag: 'rejected', rejectedBy: 'demo-approver', rejectedAt: '2026-07-01', comments: 'Graag de aanhef formeler.' }), true, 'drafter') }; };
export const Sent: Story = { render: () => render(brief({ tag: 'sent', sentAt: '2026-07-01' }), false, 'drafter') }; export const SubmittedApprover: Story = {
render: () =>
render(
brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }),
false,
'approver',
),
};
export const Rejected: Story = {
render: () =>
render(
brief({
tag: 'rejected',
rejectedBy: 'demo-approver',
rejectedAt: '2026-07-01',
comments: 'Graag de aanhef formeler.',
}),
true,
'drafter',
),
};
export const Sent: Story = {
render: () => render(brief({ tag: 'sent', sentAt: '2026-07-01' }), false, 'drafter'),
};

View File

@@ -8,7 +8,10 @@ import { Brief, LetterBlock } from '@brief/domain/brief';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */ /** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
type PreviewSegment = { readonly list: 'bullet' | 'number' | null; readonly items: readonly Paragraph[] }; type PreviewSegment = {
readonly list: 'bullet' | 'number' | null;
readonly items: readonly Paragraph[];
};
function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] { function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = []; const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];
@@ -34,25 +37,54 @@ const SAMPLE_VALUES: Record<string, string> = {
@Component({ @Component({
selector: 'app-letter-preview', selector: 'app-letter-preview',
imports: [NgTemplateOutlet, HeadingComponent, ButtonComponent, PlaceholderChipComponent], imports: [NgTemplateOutlet, HeadingComponent, ButtonComponent, PlaceholderChipComponent],
styles: [` styles: [
:host{display:block} `
.toolbar{display:flex;justify-content:flex-end;margin-block-end:var(--rhc-space-max-sm)} :host {
.letter{background:var(--rhc-color-wit);border:var(--rhc-border-width-sm) solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-md);padding:var(--rhc-space-max-2xl)} display: block;
section{margin-block-end:var(--rhc-space-max-xl)} }
p{margin:0 0 var(--rhc-space-max-sm)} .toolbar {
ul,ol{margin:0 0 var(--rhc-space-max-sm);padding-inline-start:1.4em} display: flex;
`], justify-content: flex-end;
margin-block-end: var(--rhc-space-max-sm);
}
.letter {
background: var(--rhc-color-wit);
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-2xl);
}
section {
margin-block-end: var(--rhc-space-max-xl);
}
p {
margin: 0 0 var(--rhc-space-max-sm);
}
ul,
ol {
margin: 0 0 var(--rhc-space-max-sm);
padding-inline-start: 1.4em;
}
`,
],
template: ` template: `
<ng-template #line let-nodes> <ng-template #line let-nodes>
@for (node of nodes; track $index) { @for (node of nodes; track $index) {
@switch (node.type) { @switch (node.type) {
@case ('text') { <span>{{ node.text }}</span> } @case ('text') {
@case ('lineBreak') { <br> } <span>{{ node.text }}</span>
}
@case ('lineBreak') {
<br />
}
@case ('placeholder') { @case ('placeholder') {
@if (showSample() && autoFor(node.key)) { @if (showSample() && autoFor(node.key)) {
<span>{{ sampleFor(node.key) }}</span> <span>{{ sampleFor(node.key) }}</span>
} @else { } @else {
<app-placeholder-chip [label]="labelFor(node.key)" [autoResolvable]="autoFor(node.key)" [state]="stateFor(node.key)" /> <app-placeholder-chip
[label]="labelFor(node.key)"
[autoResolvable]="autoFor(node.key)"
[state]="stateFor(node.key)"
/>
} }
} }
} }
@@ -60,7 +92,11 @@ const SAMPLE_VALUES: Record<string, string> = {
</ng-template> </ng-template>
<div class="toolbar"> <div class="toolbar">
<app-button variant="subtle" (click)="showSample.set(!showSample())" [attr.aria-pressed]="showSample()"> <app-button
variant="subtle"
(click)="showSample.set(!showSample())"
[attr.aria-pressed]="showSample()"
>
{{ showSample() ? hideSampleLabel() : showSampleLabel() }} {{ showSample() ? hideSampleLabel() : showSampleLabel() }}
</app-button> </app-button>
</div> </div>
@@ -72,11 +108,34 @@ const SAMPLE_VALUES: Record<string, string> = {
@for (block of section.blocks; track block.blockId) { @for (block of section.blocks; track block.blockId) {
@for (seg of segmentsOf(block); track $index) { @for (seg of segmentsOf(block); track $index) {
@if (seg.list === 'bullet') { @if (seg.list === 'bullet') {
<ul>@for (para of seg.items; track $index) { <li><ng-container [ngTemplateOutlet]="line" [ngTemplateOutletContext]="{ $implicit: para.nodes }" /></li>}</ul> <ul>
@for (para of seg.items; track $index) {
<li>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
/>
</li>
}
</ul>
} @else if (seg.list === 'number') { } @else if (seg.list === 'number') {
<ol>@for (para of seg.items; track $index) { <li><ng-container [ngTemplateOutlet]="line" [ngTemplateOutletContext]="{ $implicit: para.nodes }" /></li>}</ol> <ol>
@for (para of seg.items; track $index) {
<li>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
/>
</li>
}
</ol>
} @else { } @else {
<p><ng-container [ngTemplateOutlet]="line" [ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }" /></p> <p>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }"
/>
</p>
} }
} }
} }
@@ -93,7 +152,11 @@ export class LetterPreviewComponent {
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`); hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
protected showSample = signal(false); protected showSample = signal(false);
private today = new Date().toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' }); private today = new Date().toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p]))); private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
private worst = computed(() => { private worst = computed(() => {
@@ -110,5 +173,6 @@ export class LetterPreviewComponent {
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key; protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false; protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok'; protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
protected sampleFor = (key: string) => SAMPLE_VALUES[key] ?? (key === 'datum' ? this.today : this.labelFor(key)); protected sampleFor = (key: string) =>
SAMPLE_VALUES[key] ?? (key === 'datum' ? this.today : this.labelFor(key));
} }

View File

@@ -14,17 +14,36 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.
@Component({ @Component({
selector: 'app-letter-section', selector: 'app-letter-section',
imports: [ButtonComponent, HeadingComponent, LetterBlockComponent, PassagePickerComponent], imports: [ButtonComponent, HeadingComponent, LetterBlockComponent, PassagePickerComponent],
styles: [` styles: [
:host{display:block} `
.blocks{display:grid;gap:var(--rhc-space-max-lg);margin-block:var(--rhc-space-max-md)} :host {
.actions{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-sm)} display: block;
.required{color:var(--rhc-color-foreground-subtle)} }
.empty{color:var(--rhc-color-foreground-subtle);font-style:italic} .blocks {
`], display: grid;
gap: var(--rhc-space-max-lg);
margin-block: var(--rhc-space-max-md);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--rhc-space-max-sm);
}
.required {
color: var(--rhc-color-foreground-subtle);
}
.empty {
color: var(--rhc-color-foreground-subtle);
font-style: italic;
}
`,
],
template: ` template: `
<app-heading [level]="3"> <app-heading [level]="3">
{{ section().title }} {{ section().title }}
@if (section().required) { <span class="required">· {{ requiredLabel() }}</span> } @if (section().required) {
<span class="required">· {{ requiredLabel() }}</span>
}
</app-heading> </app-heading>
<div class="blocks"> <div class="blocks">
@@ -35,7 +54,8 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.
[editable]="editable()" [editable]="editable()"
(contentChanged)="onContent(block.blockId, $event)" (contentChanged)="onContent(block.blockId, $event)"
(removed)="edit.emit({ tag: 'BlockRemoved', blockId: block.blockId })" (removed)="edit.emit({ tag: 'BlockRemoved', blockId: block.blockId })"
(moved)="onMove(block.blockId, $event)" /> (moved)="onMove(block.blockId, $event)"
/>
} @empty { } @empty {
<p class="empty">{{ emptyLabel() }}</p> <p class="empty">{{ emptyLabel() }}</p>
} }
@@ -43,8 +63,14 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.
@if (editable()) { @if (editable()) {
<div class="actions"> <div class="actions">
<app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{ addPassageLabel() }}</app-button> <app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{
<app-button variant="subtle" (click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })">{{ addFreeLabel() }}</app-button> addPassageLabel()
}}</app-button>
<app-button
variant="subtle"
(click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })"
>{{ addFreeLabel() }}</app-button
>
</div> </div>
@if (pickerOpen()) { @if (pickerOpen()) {
<app-passage-picker [passages]="sectionPassages()" (insert)="onInsert($event)" /> <app-passage-picker [passages]="sectionPassages()" (insert)="onInsert($event)" />
@@ -65,7 +91,9 @@ export class LetterSectionComponent {
addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`); addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`);
protected pickerOpen = signal(false); protected pickerOpen = signal(false);
protected sectionPassages = computed(() => this.availablePassages().filter((p) => p.sectionKey === this.section().sectionKey)); protected sectionPassages = computed(() =>
this.availablePassages().filter((p) => p.sectionKey === this.section().sectionKey),
);
protected onContent(blockId: string, content: RichTextBlock) { protected onContent(blockId: string, content: RichTextBlock) {
this.edit.emit({ tag: 'BlockContentEdited', blockId, content }); this.edit.emit({ tag: 'BlockContentEdited', blockId, content });

View File

@@ -10,11 +10,26 @@ import { LibraryPassage } from '@brief/domain/brief';
@Component({ @Component({
selector: 'app-passage-picker', selector: 'app-passage-picker',
imports: [FormsModule, CheckboxComponent, ButtonComponent], imports: [FormsModule, CheckboxComponent, ButtonComponent],
styles: [` styles: [
:host{display:block;border:var(--rhc-border-width-sm) solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-md);padding:var(--rhc-space-max-md)} `
ul{list-style:none;margin:0 0 var(--rhc-space-max-md);padding:0;display:grid;gap:var(--rhc-space-max-sm)} :host {
.scope{color:var(--rhc-color-foreground-subtle)} display: block;
`], border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-md);
}
ul {
list-style: none;
margin: 0 0 var(--rhc-space-max-md);
padding: 0;
display: grid;
gap: var(--rhc-space-max-sm);
}
.scope {
color: var(--rhc-color-foreground-subtle);
}
`,
],
template: ` template: `
<ul> <ul>
@for (p of passages(); track p.passageId) { @for (p of passages(); track p.passageId) {
@@ -22,12 +37,15 @@ import { LibraryPassage } from '@brief/domain/brief';
<app-checkbox <app-checkbox
[label]="p.label" [label]="p.label"
[ngModel]="!!checked()[p.passageId]" [ngModel]="!!checked()[p.passageId]"
(ngModelChange)="set(p.passageId, $event)" /> (ngModelChange)="set(p.passageId, $event)"
/>
<span class="scope"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span> <span class="scope"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span>
</li> </li>
} }
</ul> </ul>
<app-button variant="secondary" [disabled]="count() === 0" (click)="add()">{{ addLabel() }} ({{ count() }})</app-button> <app-button variant="secondary" [disabled]="count() === 0" (click)="add()"
>{{ addLabel() }} ({{ count() }})</app-button
>
`, `,
}) })
export class PassagePickerComponent { export class PassagePickerComponent {

View File

@@ -8,18 +8,33 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
@Component({ @Component({
selector: 'app-rejection-comments', selector: 'app-rejection-comments',
imports: [FormsModule, AlertComponent, ButtonComponent], imports: [FormsModule, AlertComponent, ButtonComponent],
styles: [` styles: [
:host{display:block} `
textarea{inline-size:100%;box-sizing:border-box;min-block-size:4rem;margin-block:var(--rhc-space-max-sm)} :host {
label{font-weight:600} display: block;
`], }
textarea {
inline-size: 100%;
box-sizing: border-box;
min-block-size: 4rem;
margin-block: var(--rhc-space-max-sm);
}
label {
font-weight: 600;
}
`,
],
template: ` template: `
@if (mode() === 'show') { @if (mode() === 'show') {
<app-alert type="warning"><strong>{{ rejectedTitle() }}</strong> {{ comments() }}</app-alert> <app-alert type="warning"
><strong>{{ rejectedTitle() }}</strong> {{ comments() }}</app-alert
>
} @else { } @else {
<label for="reject-comments">{{ entryLabel() }}</label> <label for="reject-comments">{{ entryLabel() }}</label>
<textarea id="reject-comments" [(ngModel)]="draft"></textarea> <textarea id="reject-comments" [(ngModel)]="draft"></textarea>
<app-button variant="danger" [disabled]="!draft().trim() || busy()" (click)="submit()">{{ rejectLabel() }}</app-button> <app-button variant="danger" [disabled]="!draft().trim() || busy()" (click)="submit()">{{
rejectLabel()
}}</app-button>
} }
`, `,
}) })

View File

@@ -15,5 +15,7 @@ export class IntakePolicyStore {
private policy = inject(IntakePolicyAdapter); private policy = inject(IntakePolicyAdapter);
private policyRes = this.policy.policyResource(); private policyRes = this.policy.policyResource();
readonly scholingThreshold = computed(() => this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT); readonly scholingThreshold = computed(
() => this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
);
} }

View File

@@ -1,11 +1,38 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp'; import { ok, err } from '@shared/kernel/fp';
import { initialUpload } from '@shared/upload/upload.machine'; import { initialUpload } from '@shared/upload/upload.machine';
import { initial, next, back, gaNaarStap, submit, resolve, reduce, WizardState } from './herregistratie.machine'; import {
initial,
next,
back,
gaNaarStap,
submit,
resolve,
reduce,
WizardState,
} from './herregistratie.machine';
const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload }); const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({
const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload }); tag: 'Editing',
const editing3 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 3, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload }); step: 1,
draft: { uren, jaren, punten },
errors: {},
upload: initialUpload,
});
const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({
tag: 'Editing',
step: 2,
draft: { uren, jaren, punten },
errors: {},
upload: initialUpload,
});
const editing3 = (uren: string, punten: string, jaren = '5'): WizardState => ({
tag: 'Editing',
step: 3,
draft: { uren, jaren, punten },
errors: {},
upload: initialUpload,
});
describe('wizard.machine', () => { describe('wizard.machine', () => {
it('next advances only when step 1 parses', () => { it('next advances only when step 1 parses', () => {
@@ -76,19 +103,37 @@ describe('reduce (message-driven)', () => {
}); });
it('blocks submit until required documents are satisfied', () => { it('blocks submit until required documents are satisfied', () => {
const cat = { categoryId: 'bewijs', label: 'Bewijs', description: '', required: true, acceptedTypes: [], maxSizeMb: 10, multiple: false, allowPostDelivery: true }; const cat = {
let s = reduce(editing3('4160', '200'), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } }); categoryId: 'bewijs',
label: 'Bewijs',
description: '',
required: true,
acceptedTypes: [],
maxSizeMb: 10,
multiple: false,
allowPostDelivery: true,
};
let s = reduce(editing3('4160', '200'), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
});
s = reduce(s, { tag: 'Submit' }); s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Editing'); expect(s.tag).toBe('Editing');
expect((s as any).errors.documenten).toBeTruthy(); expect((s as any).errors.documenten).toBeTruthy();
s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'bewijs', channel: 'post' } }); s = reduce(s, {
tag: 'Upload',
msg: { type: 'DeliveryChannelChanged', categoryId: 'bewijs', channel: 'post' },
});
s = reduce(s, { tag: 'Submit' }); s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Submitting'); expect(s.tag).toBe('Submitting');
expect((s as any).data.documents).toEqual([{ categoryId: 'bewijs', channel: 'post' }]); expect((s as any).data.documents).toEqual([{ categoryId: 'bewijs', channel: 'post' }]);
}); });
it('SubmitFailed then Retry returns to Submitting with the same data', () => { it('SubmitFailed then Retry returns to Submitting with the same data', () => {
let s = reduce(reduce(editing3('4160', '200'), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' }); let s = reduce(reduce(editing3('4160', '200'), { tag: 'Submit' }), {
tag: 'SubmitFailed',
error: 'boom',
});
expect(s.tag).toBe('Failed'); expect(s.tag).toBe('Failed');
s = reduce(s, { tag: 'Retry' }); s = reduce(s, { tag: 'Retry' });
expect(s.tag).toBe('Submitting'); expect(s.tag).toBe('Submitting');

View File

@@ -38,11 +38,23 @@ export type WizardState =
| { tag: 'Submitted'; data: Valid } | { tag: 'Submitted'; data: Valid }
| { tag: 'Failed'; data: Valid; error: string }; | { tag: 'Failed'; data: Valid; error: string };
export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {}, upload: initialUpload }; export const initial: WizardState = {
tag: 'Editing',
step: 1,
draft: { uren: '', jaren: '', punten: '' },
errors: {},
upload: initialUpload,
};
/** Has the user meaningfully started, so it's worth persisting as a Concept? */ /** Has the user meaningfully started, so it's worth persisting as a Concept? */
export function hasProgress(s: Extract<WizardState, { tag: 'Editing' }>): boolean { export function hasProgress(s: Extract<WizardState, { tag: 'Editing' }>): boolean {
return s.step > 1 || !!s.draft.uren || !!s.draft.jaren || !!s.draft.punten || deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId); return (
s.step > 1 ||
!!s.draft.uren ||
!!s.draft.jaren ||
!!s.draft.punten ||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)
);
} }
/** Parse every field; on success hand back a Valid, else the per-field errors. */ /** Parse every field; on success hand back a Valid, else the per-field errors. */
@@ -58,7 +70,15 @@ function validate(draft: Draft, upload: UploadState): Result<StepErrors, Valid>
errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`; errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`;
} }
if (uren.ok && jaren.ok && punten.ok && !errors.documenten) { if (uren.ok && jaren.ok && punten.ok && !errors.documenten) {
return { ok: true, value: { uren: uren.value, jaren: jaren.value, punten: punten.value, documents: deliveryRefs(upload) } }; return {
ok: true,
value: {
uren: uren.value,
jaren: jaren.value,
punten: punten.value,
documents: deliveryRefs(upload),
},
};
} }
return { ok: false, error: errors }; return { ok: false, error: errors };
} }
@@ -110,7 +130,9 @@ export function upload(s: WizardState, msg: UploadMsg): WizardState {
/** Resolve the async submit. Only meaningful while Submitting. */ /** Resolve the async submit. Only meaningful while Submitting. */
export function resolve(s: WizardState, r: Result<string, void>): WizardState { export function resolve(s: WizardState, r: Result<string, void>): WizardState {
if (s.tag !== 'Submitting') return s; if (s.tag !== 'Submitting') return s;
return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error }; return r.ok
? { tag: 'Submitted', data: s.data }
: { tag: 'Failed', data: s.data, error: r.error };
} }
/** Update one draft field while editing; ignored in any other state. */ /** Update one draft field while editing; ignored in any other state. */

View File

@@ -15,7 +15,13 @@ import {
IntakeState, IntakeState,
} from './intake.machine'; } from './intake.machine';
const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {}, scholingThreshold }); const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({
tag: 'Answering',
answers,
cursor,
errors: {},
scholingThreshold,
});
describe('STEPS (fixed) and inline questions', () => { describe('STEPS (fixed) and inline questions', () => {
it('always has the same three steps', () => { it('always has the same three steps', () => {
@@ -40,7 +46,9 @@ describe('STEPS (fixed) and inline questions', () => {
expect(lageUren({ uren: '1500' }, 1000)).toBe(false); expect(lageUren({ uren: '1500' }, 1000)).toBe(false);
expect(lageUren({ uren: '1500' }, 2000)).toBe(true); expect(lageUren({ uren: '1500' }, 2000)).toBe(true);
// And the threshold from state flows through submit: // And the threshold from state flows through submit:
const lowThreshold = submit(answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000)); const lowThreshold = submit(
answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000),
);
expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked
expect((lowThreshold as any).errors.scholingGevolgd).toBeTruthy(); expect((lowThreshold as any).errors.scholingGevolgd).toBeTruthy();
}); });
@@ -61,7 +69,11 @@ describe('navigation', () => {
}); });
it('editing an answer leaves the cursor fixed (steps never collapse)', () => { it('editing an answer leaves the cursor fixed (steps never collapse)', () => {
const edited = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }); const edited = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), {
tag: 'SetAnswer',
key: 'buitenlandGewerkt',
value: 'nee',
});
expect((edited as any).cursor).toBe(1); // cursor untouched; only inline questions change expect((edited as any).cursor).toBe(1); // cursor untouched; only inline questions change
}); });
@@ -89,7 +101,11 @@ describe('submit', () => {
it('reaches Submitting ONLY with valid answers', () => { it('reaches Submitting ONLY with valid answers', () => {
// Bad punten only blocks when scholing was followed (otherwise punten is ignored). // Bad punten only blocks when scholing was followed (otherwise punten is ignored).
expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' })).tag).toBe('Answering'); expect(
submit(
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' }),
).tag,
).toBe('Answering');
const good = submit(answering(complete)); const good = submit(answering(complete));
expect(good.tag).toBe('Submitting'); expect(good.tag).toBe('Submitting');
expect((good as any).data.uren).toBe(4160); expect((good as any).data.uren).toBe(4160);
@@ -98,17 +114,23 @@ describe('submit', () => {
it('punten is required only when aanvullende scholing was gevolgd', () => { it('punten is required only when aanvullende scholing was gevolgd', () => {
// scholing = ja but punten missing -> blocked on punten. // scholing = ja but punten missing -> blocked on punten.
const missing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' })); const missing = submit(
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' }),
);
expect(missing.tag).toBe('Answering'); expect(missing.tag).toBe('Answering');
expect((missing as any).errors.punten).toBeTruthy(); expect((missing as any).errors.punten).toBeTruthy();
// scholing = nee -> punten not required, submits without it. // scholing = nee -> punten not required, submits without it.
expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag).toBe('Submitting'); expect(
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag,
).toBe('Submitting');
}); });
it('low hours requires the scholing answer before submit', () => { it('low hours requires the scholing answer before submit', () => {
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' })); const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' }));
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
const withScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' })); const withScholing = submit(
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }),
);
expect(withScholing.tag).toBe('Submitting'); expect(withScholing.tag).toBe('Submitting');
expect((withScholing as any).data.aanvullendeScholing).toBe(true); expect((withScholing as any).data.aanvullendeScholing).toBe(true);
expect((withScholing as any).data.punten).toBe(200); expect((withScholing as any).data.punten).toBe(200);

View File

@@ -56,12 +56,24 @@ export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
type Errors = Partial<Record<keyof Answers, string>>; type Errors = Partial<Record<keyof Answers, string>>;
export type IntakeState = export type IntakeState =
| { tag: 'Answering'; answers: Answers; cursor: number; errors: Errors; scholingThreshold: number } | {
tag: 'Answering';
answers: Answers;
cursor: number;
errors: Errors;
scholingThreshold: number;
}
| { tag: 'Submitting'; data: ValidIntake } | { tag: 'Submitting'; data: ValidIntake }
| { tag: 'Submitted'; data: ValidIntake } | { tag: 'Submitted'; data: ValidIntake }
| { tag: 'Failed'; data: ValidIntake; error: string }; | { tag: 'Failed'; data: ValidIntake; error: string };
export const initial: IntakeState = { tag: 'Answering', answers: {}, cursor: 0, errors: {}, scholingThreshold: SCHOLING_THRESHOLD_DEFAULT }; export const initial: IntakeState = {
tag: 'Answering',
answers: {},
cursor: 0,
errors: {},
scholingThreshold: SCHOLING_THRESHOLD_DEFAULT,
};
/** Which step the cursor currently points at (clamped to the fixed list). */ /** Which step the cursor currently points at (clamped to the fixed list). */
export function currentStep(s: Extract<IntakeState, { tag: 'Answering' }>): StepId { export function currentStep(s: Extract<IntakeState, { tag: 'Answering' }>): StepId {
@@ -79,9 +91,11 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
const errors: Errors = {}; const errors: Errors = {};
switch (step) { switch (step) {
case 'buitenland': case 'buitenland':
if (!a.buitenlandGewerkt) errors.buitenlandGewerkt = $localize`:@@validation.maakKeuze:Maak een keuze.`; if (!a.buitenlandGewerkt)
errors.buitenlandGewerkt = $localize`:@@validation.maakKeuze:Maak een keuze.`;
else if (a.buitenlandGewerkt === 'ja') { else if (a.buitenlandGewerkt === 'ja') {
if (!a.land || a.land.trim() === '') errors.land = $localize`:@@validation.land:Vul een land in.`; if (!a.land || a.land.trim() === '')
errors.land = $localize`:@@validation.land:Vul een land in.`;
const u = parseUren(a.buitenlandseUren ?? ''); const u = parseUren(a.buitenlandseUren ?? '');
if (!u.ok) errors.buitenlandseUren = u.error; if (!u.ok) errors.buitenlandseUren = u.error;
} }
@@ -89,7 +103,8 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
case 'werk': { case 'werk': {
const u = parseUren(a.uren ?? ''); const u = parseUren(a.uren ?? '');
if (!u.ok) errors.uren = u.error; if (!u.ok) errors.uren = u.error;
if (lageUren(a, scholingThreshold) && !a.scholingGevolgd) errors.scholingGevolgd = $localize`:@@validation.maakKeuze:Maak een keuze.`; if (lageUren(a, scholingThreshold) && !a.scholingGevolgd)
errors.scholingGevolgd = $localize`:@@validation.maakKeuze:Maak een keuze.`;
// Nascholingspunten are only asked (and required) when scholing was followed. // Nascholingspunten are only asked (and required) when scholing was followed.
if (a.scholingGevolgd === 'ja') { if (a.scholingGevolgd === 'ja') {
const p = parseUren(a.punten ?? ''); const p = parseUren(a.punten ?? '');
@@ -171,7 +186,9 @@ export function submit(s: IntakeState): IntakeState {
export function resolve(s: IntakeState, r: Result<string, void>): IntakeState { export function resolve(s: IntakeState, r: Result<string, void>): IntakeState {
if (s.tag !== 'Submitting') return s; if (s.tag !== 'Submitting') return s;
return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error }; return r.ok
? { tag: 'Submitted', data: s.data }
: { tag: 'Failed', data: s.data, error: r.error };
} }
export type IntakeMsg = export type IntakeMsg =

View File

@@ -3,12 +3,24 @@ import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/alert/alert.component';
import { WizardShellComponent, WizardError, WizardStatus, naarStapLabel } from '@shared/layout/wizard-shell/wizard-shell.component'; import {
WizardShellComponent,
WizardError,
WizardStatus,
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component'; import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp'; import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
import { WizardState, WizardMsg, Draft, initial, reduce, hasProgress } from '@herregistratie/domain/herregistratie.machine'; import {
WizardState,
WizardMsg,
Draft,
initial,
reduce,
hasProgress,
} from '@herregistratie/domain/herregistratie.machine';
import { createDraftSync } from '@registratie/application/draft-sync'; import { createDraftSync } from '@registratie/application/draft-sync';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component'; import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/upload/upload-controller'; import { createUploadController } from '@shared/upload/upload-controller';
@@ -23,13 +35,22 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
the dashboard shows "in behandeling" immediately. */ the dashboard shows "in behandeling" immediately. */
@Component({ @Component({
selector: 'app-herregistratie-wizard', selector: 'app-herregistratie-wizard',
imports: [FormsModule, FormFieldComponent, TextInputComponent, AlertComponent, ConfirmationComponent, WizardShellComponent, DocumentUploadComponent], imports: [
FormsModule,
FormFieldComponent,
TextInputComponent,
AlertComponent,
ConfirmationComponent,
WizardShellComponent,
DocumentUploadComponent,
],
template: ` template: `
<app-wizard-shell <app-wizard-shell
[steps]="stepLabels" [steps]="stepLabels"
[current]="step() - 1" [current]="step() - 1"
[stepTitle]="stepTitle()" [stepTitle]="stepTitle()"
i18n-processName="@@herregWizard.processName" processName="Herregistratie aanvragen" i18n-processName="@@herregWizard.processName"
processName="Herregistratie aanvragen"
[status]="shellStatus()" [status]="shellStatus()"
[primaryLabel]="primaryLabel()" [primaryLabel]="primaryLabel()"
[canGoBack]="step() > 1" [canGoBack]="step() > 1"
@@ -39,23 +60,62 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
(back)="dispatch({ tag: 'Back' })" (back)="dispatch({ tag: 'Back' })"
(cancel)="restart()" (cancel)="restart()"
(retry)="onRetry()" (retry)="onRetry()"
(goToStep)="goToStep($event)"> (goToStep)="goToStep($event)"
>
@switch (step()) { @switch (step()) {
@case (1) { @case (1) {
<app-form-field i18n-label="@@herregWizard.urenLabel" label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" required [error]="errUren()"> <app-form-field
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })" i18n-label="@@herregWizard.urenLabel"
name="uren" [invalid]="!!errUren()" i18n-placeholder="@@herregWizard.urenPlaceholder" placeholder="bijv. 4160" /> label="Gewerkte uren (afgelopen 5 jaar)"
fieldId="uren"
required
[error]="errUren()"
>
<app-text-input
inputId="uren"
[ngModel]="draft().uren"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
name="uren"
[invalid]="!!errUren()"
i18n-placeholder="@@herregWizard.urenPlaceholder"
placeholder="bijv. 4160"
/>
</app-form-field> </app-form-field>
<app-form-field i18n-label="@@herregWizard.jarenLabel" label="Aantal jaren werkzaam" fieldId="jaren" required [error]="errJaren()"> <app-form-field
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })" i18n-label="@@herregWizard.jarenLabel"
name="jaren" [invalid]="!!errJaren()" i18n-placeholder="@@herregWizard.jarenPlaceholder" placeholder="bijv. 5" /> label="Aantal jaren werkzaam"
fieldId="jaren"
required
[error]="errJaren()"
>
<app-text-input
inputId="jaren"
[ngModel]="draft().jaren"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
name="jaren"
[invalid]="!!errJaren()"
i18n-placeholder="@@herregWizard.jarenPlaceholder"
placeholder="bijv. 5"
/>
</app-form-field> </app-form-field>
} }
@case (2) { @case (2) {
<app-form-field i18n-label="@@herregWizard.puntenLabel" label="Behaalde nascholingspunten" fieldId="punten" required [error]="errPunten()"> <app-form-field
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })" i18n-label="@@herregWizard.puntenLabel"
name="punten" [invalid]="!!errPunten()" i18n-placeholder="@@herregWizard.puntenPlaceholder" placeholder="bijv. 200" /> label="Behaalde nascholingspunten"
fieldId="punten"
required
[error]="errPunten()"
>
<app-text-input
inputId="punten"
[ngModel]="draft().punten"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
name="punten"
[invalid]="!!errPunten()"
i18n-placeholder="@@herregWizard.puntenPlaceholder"
placeholder="bijv. 200"
/>
</app-form-field> </app-form-field>
} }
@case (3) { @case (3) {
@@ -66,7 +126,8 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
(removeUpload)="uploadCtl.onRemove($event)" (removeUpload)="uploadCtl.onRemove($event)"
(retryUpload)="uploadCtl.onRetry($event)" (retryUpload)="uploadCtl.onRetry($event)"
(deleteUpload)="uploadCtl.onDelete($event)" (deleteUpload)="uploadCtl.onDelete($event)"
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)" /> (channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)"
/>
@if (errDocumenten()) { @if (errDocumenten()) {
<app-alert type="warning">{{ errDocumenten() }}</app-alert> <app-alert type="warning">{{ errDocumenten() }}</app-alert>
} }
@@ -74,7 +135,10 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
} }
<div wizardSuccess> <div wizardSuccess>
<app-confirmation i18n-title="@@herregWizard.success.title" title="Uw aanvraag tot herregistratie is ontvangen" /> <app-confirmation
i18n-title="@@herregWizard.success.title"
title="Uw aanvraag tot herregistratie is ontvangen"
/>
</div> </div>
</app-wizard-shell> </app-wizard-shell>
`, `,
@@ -102,7 +166,9 @@ export class HerregistratieWizardComponent {
snapshot: () => { snapshot: () => {
const s = this.state(); const s = this.state();
if (s.tag !== 'Editing' || !hasProgress(s)) return null; if (s.tag !== 'Editing' || !hasProgress(s)) return null;
const documentIds = deliveryRefs(s.upload).filter((r) => r.channel === 'digital' && r.documentId).map((r) => r.documentId!); const documentIds = deliveryRefs(s.upload)
.filter((r) => r.channel === 'digital' && r.documentId)
.map((r) => r.documentId!);
return { draft: s, stepIndex: s.step - 1, stepCount: this.stepLabels.length, documentIds }; return { draft: s, stepIndex: s.step - 1, stepCount: this.stepLabels.length, documentIds };
}, },
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }), onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }),
@@ -110,12 +176,22 @@ export class HerregistratieWizardComponent {
}); });
// Stepper labels + per-step heading titles (presentational only). // Stepper labels + per-step heading titles (presentational only).
readonly stepLabels = [$localize`:@@herregWizard.step.werkervaring:Werkervaring`, $localize`:@@herregWizard.step.nascholing:Nascholing`, $localize`:@@herregWizard.step.documenten:Documenten`]; readonly stepLabels = [
private stepTitles = [$localize`:@@herregWizard.title.werkervaring:Werkervaring (afgelopen 5 jaar)`, $localize`:@@herregWizard.title.nascholing:Nascholing`, $localize`:@@herregWizard.title.documenten:Documenten aanleveren`]; $localize`:@@herregWizard.step.werkervaring:Werkervaring`,
$localize`:@@herregWizard.step.nascholing:Nascholing`,
$localize`:@@herregWizard.step.documenten:Documenten`,
];
private stepTitles = [
$localize`:@@herregWizard.title.werkervaring:Werkervaring (afgelopen 5 jaar)`,
$localize`:@@herregWizard.title.nascholing:Nascholing`,
$localize`:@@herregWizard.title.documenten:Documenten aanleveren`,
];
private editing = computed(() => whenTag(this.state(), 'Editing')); private editing = computed(() => whenTag(this.state(), 'Editing'));
protected step = computed(() => this.editing()?.step ?? 1); protected step = computed(() => this.editing()?.step ?? 1);
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' }); protected draft = computed<Draft>(
() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' },
);
protected upload = computed<UploadState>(() => this.editing()?.upload ?? initialUpload); protected upload = computed<UploadState>(() => this.editing()?.upload ?? initialUpload);
protected errUren = computed(() => this.editing()?.errors.uren ?? ''); protected errUren = computed(() => this.editing()?.errors.uren ?? '');
protected errJaren = computed(() => this.editing()?.errors.jaren ?? ''); protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
@@ -132,20 +208,28 @@ export class HerregistratieWizardComponent {
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]); protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
protected primaryLabel = computed(() => { protected primaryLabel = computed(() => {
const step = this.step(); const step = this.step();
return step < 3 ? naarStapLabel(step + 1, this.stepLabels[step]) : $localize`:@@herregWizard.indienen:Herregistratie aanvragen`; return step < 3
? naarStapLabel(step + 1, this.stepLabels[step])
: $localize`:@@herregWizard.indienen:Herregistratie aanvragen`;
}); });
/** Stepper emits a 0-based index for an earlier (visited) step. */ /** Stepper emits a 0-based index for an earlier (visited) step. */
protected goToStep(index: number) { protected goToStep(index: number) {
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 }); this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 });
} }
protected errorMessage = computed(() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`); protected errorMessage = computed(
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
);
protected shellStatus = computed<WizardStatus>(() => { protected shellStatus = computed<WizardStatus>(() => {
switch (this.state().tag) { switch (this.state().tag) {
case 'Editing': return 'editing'; case 'Editing':
case 'Submitting': return 'submitting'; return 'editing';
case 'Submitted': return 'submitted'; case 'Submitting':
case 'Failed': return 'failed'; return 'submitting';
case 'Submitted':
return 'submitted';
case 'Failed':
return 'failed';
} }
}); });
/** Current step's field errors, flattened for the shell's error summary. */ /** Current step's field errors, flattened for the shell's error summary. */
@@ -160,7 +244,9 @@ export class HerregistratieWizardComponent {
// An explicit seed (stories/tests) wins; otherwise resume the backend draft // An explicit seed (stories/tests) wins; otherwise resume the backend draft
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job. // (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
const seeded = this.seed(); const seeded = this.seed();
queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume())); queueMicrotask(() =>
seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume(),
);
} }
onPrimary() { onPrimary() {

View File

@@ -20,12 +20,55 @@ export default meta;
type Story = StoryObj<HerregistratieWizardComponent>; type Story = StoryObj<HerregistratieWizardComponent>;
// Each story seeds one state of the machine — one render per union variant. // Each story seeds one state of the machine — one render per union variant.
export const Step1: Story = { args: { seed: { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {}, upload: initialUpload } } }; export const Step1: Story = {
export const Step1Error: Story = { args: {
args: { seed: { tag: 'Editing', step: 1, draft: { uren: 'abc', jaren: '', punten: '' }, errors: { uren: 'Vul een geheel aantal in (0 of meer).', jaren: 'Vul een geheel aantal in (0 of meer).' }, upload: initialUpload } satisfies WizardState }, seed: {
tag: 'Editing',
step: 1,
draft: { uren: '', jaren: '', punten: '' },
errors: {},
upload: initialUpload,
},
},
};
export const Step1Error: Story = {
args: {
seed: {
tag: 'Editing',
step: 1,
draft: { uren: 'abc', jaren: '', punten: '' },
errors: {
uren: 'Vul een geheel aantal in (0 of meer).',
jaren: 'Vul een geheel aantal in (0 of meer).',
},
upload: initialUpload,
} satisfies WizardState,
},
};
export const Step2: Story = {
args: {
seed: {
tag: 'Editing',
step: 2,
draft: { uren: '4160', jaren: '5', punten: '' },
errors: {},
upload: initialUpload,
},
},
};
export const Step3: Story = {
args: {
seed: {
tag: 'Editing',
step: 3,
draft: { uren: '4160', jaren: '5', punten: '200' },
errors: {},
upload: initialUpload,
},
},
}; };
export const Step2: Story = { args: { seed: { tag: 'Editing', step: 2, draft: { uren: '4160', jaren: '5', punten: '' }, errors: {}, upload: initialUpload } } };
export const Step3: Story = { args: { seed: { tag: 'Editing', step: 3, draft: { uren: '4160', jaren: '5', punten: '200' }, errors: {}, upload: initialUpload } } };
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } }; export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } }; export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } }; export const Failed: Story = {
args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } },
};

View File

@@ -13,7 +13,11 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie
selector: 'app-herregistratie-page', selector: 'app-herregistratie-page',
imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent], imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent],
template: ` template: `
<app-page-shell i18n-heading="@@herregistratie.heading" heading="Herregistratie aanvragen" backLink="/dashboard"> <app-page-shell
i18n-heading="@@herregistratie.heading"
heading="Herregistratie aanvragen"
backLink="/dashboard"
>
<app-async [data]="eligibility()"> <app-async [data]="eligibility()">
<ng-template appAsyncLoaded let-eligible> <ng-template appAsyncLoaded let-eligible>
@if (eligible) { @if (eligible) {

View File

@@ -8,7 +8,12 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component'; import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component'; import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import { WizardShellComponent, WizardError, WizardStatus, naarStapLabel } from '@shared/layout/wizard-shell/wizard-shell.component'; import {
WizardShellComponent,
WizardError,
WizardStatus,
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp'; import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
@@ -34,13 +39,25 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
sessionStorage so a page reload keeps the user's progress (cleared on tab close). */ sessionStorage so a page reload keeps the user's progress (cleared on tab close). */
@Component({ @Component({
selector: 'app-intake-wizard', selector: 'app-intake-wizard',
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, DataRowComponent, ReviewSectionComponent, ConfirmationComponent, WizardShellComponent], imports: [
FormsModule,
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
ButtonComponent,
AlertComponent,
DataRowComponent,
ReviewSectionComponent,
ConfirmationComponent,
WizardShellComponent,
],
template: ` template: `
<app-wizard-shell <app-wizard-shell
[steps]="stepLabels" [steps]="stepLabels"
[current]="cursor()" [current]="cursor()"
[stepTitle]="stepTitle()" [stepTitle]="stepTitle()"
i18n-processName="@@intake.processName" processName="Herregistratie-intake" i18n-processName="@@intake.processName"
processName="Herregistratie-intake"
[status]="shellStatus()" [status]="shellStatus()"
[primaryLabel]="primaryLabel()" [primaryLabel]="primaryLabel()"
[canGoBack]="cursor() > 0" [canGoBack]="cursor() > 0"
@@ -50,68 +67,186 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
(back)="dispatch({ tag: 'Back' })" (back)="dispatch({ tag: 'Back' })"
(cancel)="restart()" (cancel)="restart()"
(retry)="onRetry()" (retry)="onRetry()"
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"> (goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
>
@switch (step()) { @switch (step()) {
@case ('buitenland') { @case ('buitenland') {
<app-form-field i18n-label="@@intake.q.buitenland" label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" fieldId="buitenlandGewerkt" required [error]="err('buitenlandGewerkt')"> <app-form-field
<app-radio-group name="buitenlandGewerkt" [options]="jaNee" i18n-label="@@intake.q.buitenland"
[ngModel]="answers().buitenlandGewerkt ?? ''" (ngModelChange)="set('buitenlandGewerkt', $event)" /> label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
fieldId="buitenlandGewerkt"
required
[error]="err('buitenlandGewerkt')"
>
<app-radio-group
name="buitenlandGewerkt"
[options]="jaNee"
[ngModel]="answers().buitenlandGewerkt ?? ''"
(ngModelChange)="set('buitenlandGewerkt', $event)"
/>
</app-form-field> </app-form-field>
@if (answers().buitenlandGewerkt === 'ja') { @if (answers().buitenlandGewerkt === 'ja') {
<app-form-field i18n-label="@@intake.q.land" label="In welk land?" fieldId="land" required [error]="err('land')"> <app-form-field
<app-text-input inputId="land" [ngModel]="answers().land ?? ''" (ngModelChange)="set('land', $event)" name="land" i18n-placeholder="@@intake.q.landPlaceholder" placeholder="bijv. België" /> i18n-label="@@intake.q.land"
label="In welk land?"
fieldId="land"
required
[error]="err('land')"
>
<app-text-input
inputId="land"
[ngModel]="answers().land ?? ''"
(ngModelChange)="set('land', $event)"
name="land"
i18n-placeholder="@@intake.q.landPlaceholder"
placeholder="bijv. België"
/>
</app-form-field> </app-form-field>
<app-form-field i18n-label="@@intake.q.buitenlandseUren" label="Hoeveel uur heeft u daar gewerkt?" fieldId="buitenlandseUren" required [error]="err('buitenlandseUren')"> <app-form-field
<app-text-input inputId="buitenlandseUren" [ngModel]="answers().buitenlandseUren ?? ''" (ngModelChange)="set('buitenlandseUren', $event)" name="buitenlandseUren" i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder" placeholder="bijv. 800" /> i18n-label="@@intake.q.buitenlandseUren"
label="Hoeveel uur heeft u daar gewerkt?"
fieldId="buitenlandseUren"
required
[error]="err('buitenlandseUren')"
>
<app-text-input
inputId="buitenlandseUren"
[ngModel]="answers().buitenlandseUren ?? ''"
(ngModelChange)="set('buitenlandseUren', $event)"
name="buitenlandseUren"
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
placeholder="bijv. 800"
/>
</app-form-field> </app-form-field>
} }
} }
@case ('werk') { @case ('werk') {
<app-form-field i18n-label="@@intake.q.urenNl" label="Gewerkte uren in Nederland (afgelopen 5 jaar)" fieldId="uren" required [error]="err('uren')"> <app-form-field
<app-text-input inputId="uren" [ngModel]="answers().uren ?? ''" (ngModelChange)="set('uren', $event)" name="uren" i18n-placeholder="@@intake.q.urenNlPlaceholder" placeholder="bijv. 4160" /> i18n-label="@@intake.q.urenNl"
label="Gewerkte uren in Nederland (afgelopen 5 jaar)"
fieldId="uren"
required
[error]="err('uren')"
>
<app-text-input
inputId="uren"
[ngModel]="answers().uren ?? ''"
(ngModelChange)="set('uren', $event)"
name="uren"
i18n-placeholder="@@intake.q.urenNlPlaceholder"
placeholder="bijv. 4160"
/>
</app-form-field> </app-form-field>
@if (scholingZichtbaar()) { @if (scholingZichtbaar()) {
<app-form-field i18n-label="@@intake.q.scholing" label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholingGevolgd" required [error]="err('scholingGevolgd')"> <app-form-field
<app-radio-group name="scholingGevolgd" [options]="jaNee" i18n-label="@@intake.q.scholing"
[ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" /> label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?"
fieldId="scholingGevolgd"
required
[error]="err('scholingGevolgd')"
>
<app-radio-group
name="scholingGevolgd"
[options]="jaNee"
[ngModel]="answers().scholingGevolgd ?? ''"
(ngModelChange)="set('scholingGevolgd', $event)"
/>
</app-form-field> </app-form-field>
} }
@if (answers().scholingGevolgd === 'ja') { @if (answers().scholingGevolgd === 'ja') {
<app-form-field i18n-label="@@intake.q.punten" label="Behaalde nascholingspunten" fieldId="punten" required [error]="err('punten')"> <app-form-field
<app-text-input inputId="punten" [ngModel]="answers().punten ?? ''" (ngModelChange)="set('punten', $event)" name="punten" i18n-placeholder="@@intake.q.puntenPlaceholder" placeholder="bijv. 200" /> i18n-label="@@intake.q.punten"
label="Behaalde nascholingspunten"
fieldId="punten"
required
[error]="err('punten')"
>
<app-text-input
inputId="punten"
[ngModel]="answers().punten ?? ''"
(ngModelChange)="set('punten', $event)"
name="punten"
i18n-placeholder="@@intake.q.puntenPlaceholder"
placeholder="bijv. 200"
/>
</app-form-field> </app-form-field>
} }
} }
@case ('review') { @case ('review') {
<app-alert type="info" i18n="@@intake.review.controleer">Controleer uw antwoorden en dien de aanvraag in.</app-alert> <app-alert type="info" i18n="@@intake.review.controleer"
<app-review-section i18n-heading="@@intake.sectie.buitenland" heading="Buitenland" >Controleer uw antwoorden en dien de aanvraag in.</app-alert
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria" editAriaLabel="Wijzigen buitenland" >
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"> <app-review-section
<div app-data-row i18n-key="@@intake.review.buitenNl" key="Buiten NL gewerkt" [value]="answers().buitenlandGewerkt ?? '—'"></div> i18n-heading="@@intake.sectie.buitenland"
heading="Buitenland"
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
editAriaLabel="Wijzigen buitenland"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
>
<div
app-data-row
i18n-key="@@intake.review.buitenNl"
key="Buiten NL gewerkt"
[value]="answers().buitenlandGewerkt ?? '—'"
></div>
@if (answers().buitenlandGewerkt === 'ja') { @if (answers().buitenlandGewerkt === 'ja') {
<div app-data-row i18n-key="@@intake.review.land" key="Land" [value]="answers().land ?? ''"></div> <div
<div app-data-row i18n-key="@@intake.review.buitenlandseUren" key="Buitenlandse uren" [value]="answers().buitenlandseUren ?? ''"></div> app-data-row
i18n-key="@@intake.review.land"
key="Land"
[value]="answers().land ?? ''"
></div>
<div
app-data-row
i18n-key="@@intake.review.buitenlandseUren"
key="Buitenlandse uren"
[value]="answers().buitenlandseUren ?? ''"
></div>
} }
</app-review-section> </app-review-section>
<app-review-section class="app-section" i18n-heading="@@intake.sectie.werk" heading="Werk in Nederland" <app-review-section
i18n-editAriaLabel="@@intake.werkWijzigenAria" editAriaLabel="Wijzigen werk in Nederland" class="app-section"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"> i18n-heading="@@intake.sectie.werk"
<div app-data-row i18n-key="@@intake.review.urenNl" key="Uren NL" [value]="answers().uren ?? ''"></div> heading="Werk in Nederland"
i18n-editAriaLabel="@@intake.werkWijzigenAria"
editAriaLabel="Wijzigen werk in Nederland"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
>
<div
app-data-row
i18n-key="@@intake.review.urenNl"
key="Uren NL"
[value]="answers().uren ?? ''"
></div>
@if (scholingZichtbaar()) { @if (scholingZichtbaar()) {
<div app-data-row i18n-key="@@intake.review.scholing" key="Aanvullende scholing" [value]="answers().scholingGevolgd ?? ''"></div> <div
app-data-row
i18n-key="@@intake.review.scholing"
key="Aanvullende scholing"
[value]="answers().scholingGevolgd ?? ''"
></div>
} }
@if (answers().scholingGevolgd === 'ja') { @if (answers().scholingGevolgd === 'ja') {
<div app-data-row i18n-key="@@intake.review.punten" key="Nascholingspunten" [value]="answers().punten ?? ''"></div> <div
app-data-row
i18n-key="@@intake.review.punten"
key="Nascholingspunten"
[value]="answers().punten ?? ''"
></div>
} }
</app-review-section> </app-review-section>
} }
} }
<div wizardSuccess> <div wizardSuccess>
<app-confirmation i18n-title="@@intake.success.title" title="Uw aanvraag tot herregistratie is ontvangen"> <app-confirmation
i18n-title="@@intake.success.title"
title="Uw aanvraag tot herregistratie is ontvangen"
>
<div class="app-section"> <div class="app-section">
<app-button variant="secondary" (click)="restart()" i18n="@@intake.opnieuw">Opnieuw beginnen</app-button> <app-button variant="secondary" (click)="restart()" i18n="@@intake.opnieuw"
>Opnieuw beginnen</app-button
>
</div> </div>
</app-confirmation> </app-confirmation>
</div> </div>
@@ -151,13 +286,19 @@ export class IntakeWizardComponent {
protected answers = computed<Answers>(() => this.answering()?.answers ?? {}); protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]); protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
/** Server-owned threshold from the policy endpoint (mirrored into machine state). */ /** Server-owned threshold from the policy endpoint (mirrored into machine state). */
protected scholingThreshold = computed(() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT); protected scholingThreshold = computed(
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
);
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */ /** Whether the inline scholing question is shown (and required) in the 'werk' step. */
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold())); protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
// --- Presentational wiring for the shared wizard shell --------------------- // --- Presentational wiring for the shared wizard shell ---------------------
readonly stepLabels = [$localize`:@@intake.step.buitenland:Buitenland`, $localize`:@@intake.step.werk:Werk`, $localize`:@@intake.step.controle:Controle`]; readonly stepLabels = [
$localize`:@@intake.step.buitenland:Buitenland`,
$localize`:@@intake.step.werk:Werk`,
$localize`:@@intake.step.controle:Controle`,
];
private stepTitles: Record<StepId, string> = { private stepTitles: Record<StepId, string> = {
buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`, buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,
werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`, werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,
@@ -169,13 +310,19 @@ export class IntakeWizardComponent {
const next = this.cursor() + 1; const next = this.cursor() + 1;
return naarStapLabel(next + 1, this.stepLabels[next]); return naarStapLabel(next + 1, this.stepLabels[next]);
}); });
protected errorMessage = computed(() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`); protected errorMessage = computed(
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
);
protected shellStatus = computed<WizardStatus>(() => { protected shellStatus = computed<WizardStatus>(() => {
switch (this.state().tag) { switch (this.state().tag) {
case 'Answering': return 'editing'; case 'Answering':
case 'Submitting': return 'submitting'; return 'editing';
case 'Submitted': return 'submitted'; case 'Submitting':
case 'Failed': return 'failed'; return 'submitting';
case 'Submitted':
return 'submitted';
case 'Failed':
return 'failed';
} }
}); });
/** Current step's field errors, flattened for the shell's error summary. The /** Current step's field errors, flattened for the shell's error summary. The
@@ -188,13 +335,16 @@ export class IntakeWizardComponent {
}); });
protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? ''; protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value }); protected set = (key: keyof Answers, value: string) =>
this.dispatch({ tag: 'SetAnswer', key, value });
constructor() { constructor() {
// An explicit seed (stories/tests) wins; otherwise resume the backend draft // An explicit seed (stories/tests) wins; otherwise resume the backend draft
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job. // (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
const seeded = this.seed(); const seeded = this.seed();
queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume())); queueMicrotask(() =>
seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume(),
);
// Apply the server-owned threshold into machine state as it arrives. Track // Apply the server-owned threshold into machine state as it arrives. Track
// only the policy value; untrack the dispatch (it reads the state signal // only the policy value; untrack the dispatch (it reads the state signal
// internally, which would otherwise make this effect loop on its own write). // internally, which would otherwise make this effect loop on its own write).

View File

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

View File

@@ -9,11 +9,14 @@ import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-w
selector: 'app-intake-page', selector: 'app-intake-page',
imports: [PageShellComponent, AlertComponent, IntakeWizardComponent], imports: [PageShellComponent, AlertComponent, IntakeWizardComponent],
template: ` template: `
<app-page-shell i18n-heading="@@intake.heading" heading="Herregistratie — intake" backLink="/dashboard"> <app-page-shell
i18n-heading="@@intake.heading"
heading="Herregistratie — intake"
backLink="/dashboard"
>
<app-alert type="info" i18n="@@intake.intro"> <app-alert type="info" i18n="@@intake.intro">
Een paar vragen bepalen welke gegevens we nodig hebben. Afhankelijk van uw Een paar vragen bepalen welke gegevens we nodig hebben. Afhankelijk van uw antwoorden
antwoorden verschijnen er extra vragen. Uw antwoorden blijven bewaard als u verschijnen er extra vragen. Uw antwoorden blijven bewaard als u de pagina herlaadt.
de pagina herlaadt.
</app-alert> </app-alert>
<div class="app-section"> <div class="app-section">
<app-intake-wizard /> <app-intake-wizard />

View File

@@ -1,7 +1,10 @@
import { Injectable, inject, signal } from '@angular/core'; import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data'; import { RemoteData } from '@shared/application/remote-data';
import { Aanvraag } from '@registratie/domain/aanvraag'; import { Aanvraag } from '@registratie/domain/aanvraag';
import { ApplicationsAdapter, parseApplications } from '@registratie/infrastructure/applications.adapter'; import {
ApplicationsAdapter,
parseApplications,
} from '@registratie/infrastructure/applications.adapter';
type Err = Error | undefined; type Err = Error | undefined;
@@ -31,7 +34,11 @@ export class ApplicationsStore {
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' }); if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
try { try {
const parsed = parseApplications(await this.adapter.list()); const parsed = parseApplications(await this.adapter.list());
this.state.set(parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) }); this.state.set(
parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) },
);
} catch (e) { } catch (e) {
this.state.set({ tag: 'Failure', error: e as Error }); this.state.set({ tag: 'Failure', error: e as Error });
} }

View File

@@ -4,7 +4,11 @@ import { Aantekening } from '../domain/registration';
import { BigProfile } from '../domain/big-profile'; import { BigProfile } from '../domain/big-profile';
import { HerregistratieDecisions } from '../contracts/dashboard-view.dto'; import { HerregistratieDecisions } from '../contracts/dashboard-view.dto';
import { BigRegisterAdapter } from '../infrastructure/big-register.adapter'; import { BigRegisterAdapter } from '../infrastructure/big-register.adapter';
import { DashboardView, DashboardViewAdapter, parseDashboardView } from '../infrastructure/dashboard-view.adapter'; import {
DashboardView,
DashboardViewAdapter,
parseDashboardView,
} from '../infrastructure/dashboard-view.adapter';
type Err = Error | undefined; type Err = Error | undefined;
@@ -32,14 +36,20 @@ export class BigProfileStore {
const rd = fromResource(this.viewRes); const rd = fromResource(this.viewRes);
if (rd.tag !== 'Success') return rd; if (rd.tag !== 'Success') return rd;
const parsed = parseDashboardView(rd.value); const parsed = parseDashboardView(rd.value);
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) }; return parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) };
}); });
/** Registration + person, from the single aggregated call. */ /** Registration + person, from the single aggregated call. */
readonly profile = computed<RemoteData<Err, BigProfile>>(() => map(this.view(), (v) => v.profile)); readonly profile = computed<RemoteData<Err, BigProfile>>(() =>
map(this.view(), (v) => v.profile),
);
/** Server-computed decisions (e.g. herregistratie eligibility) — rendered, not recomputed. */ /** Server-computed decisions (e.g. herregistratie eligibility) — rendered, not recomputed. */
readonly decisions = computed<RemoteData<Err, HerregistratieDecisions>>(() => map(this.view(), (v) => v.decisions)); readonly decisions = computed<RemoteData<Err, HerregistratieDecisions>>(() =>
map(this.view(), (v) => v.decisions),
);
/** Specialisms/notes stay a separate stream (they have their own empty state). */ /** Specialisms/notes stay a separate stream (they have their own empty state). */
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() => { readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() => {

View File

@@ -2,9 +2,15 @@ import { DestroyRef, effect, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { Result } from '@shared/kernel/fp'; import { Result } from '@shared/kernel/fp';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import type { SubmitApplicationRequest, SubmitApplicationResponse } from '@shared/infrastructure/api-client'; import type {
SubmitApplicationRequest,
SubmitApplicationResponse,
} from '@shared/infrastructure/api-client';
import { AanvraagType } from '@registratie/domain/aanvraag'; import { AanvraagType } from '@registratie/domain/aanvraag';
import { ApplicationsAdapter, parseApplications } from '@registratie/infrastructure/applications.adapter'; import {
ApplicationsAdapter,
parseApplications,
} from '@registratie/infrastructure/applications.adapter';
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */ /** What a wizard persists per step: the opaque machine snapshot + progress + docs. */
export interface DraftSnapshot { export interface DraftSnapshot {
@@ -59,7 +65,12 @@ export function createDraftSync(deps: DraftSyncDeps) {
ensuring ??= adapter.create(deps.type).then((newId) => { ensuring ??= adapter.create(deps.type).then((newId) => {
id = newId; id = newId;
// Stamp the id into the URL (no navigation) so a reload resumes this Concept. // Stamp the id into the URL (no navigation) so a reload resumes this Concept.
void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: newId }, queryParamsHandling: 'merge', replaceUrl: true }); void router!.navigate([], {
relativeTo: route!,
queryParams: { aanvraag: newId },
queryParamsHandling: 'merge',
replaceUrl: true,
});
return newId; return newId;
}); });
return ensuring; return ensuring;
@@ -78,7 +89,12 @@ export function createDraftSync(deps: DraftSyncDeps) {
const snap = deps.snapshot(); const snap = deps.snapshot();
if (!snap) return; if (!snap) return;
const theId = await ensureId(); const theId = await ensureId();
await adapter.syncDraft(theId, { draft: snap.draft, stepIndex: snap.stepIndex, stepCount: snap.stepCount, documentIds: snap.documentIds }); await adapter.syncDraft(theId, {
draft: snap.draft,
stepIndex: snap.stepIndex,
stepCount: snap.stepCount,
documentIds: snap.documentIds,
});
}; };
// One effect watches the snapshot; each change resets a debounce timer. The timer's // One effect watches the snapshot; each change resets a debounce timer. The timer's
@@ -117,7 +133,9 @@ export function createDraftSync(deps: DraftSyncDeps) {
const findConcept = async (): Promise<string | undefined> => { const findConcept = async (): Promise<string | undefined> => {
try { try {
const parsed = parseApplications(await adapter.list()); const parsed = parseApplications(await adapter.list());
return parsed.ok ? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id : undefined; return parsed.ok
? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id
: undefined;
} catch { } catch {
return undefined; return undefined;
} }
@@ -143,7 +161,12 @@ export function createDraftSync(deps: DraftSyncDeps) {
if (existing) { if (existing) {
await load(existing); await load(existing);
// Stamp the id into the URL so a reload resumes the same Concept. // Stamp the id into the URL so a reload resumes the same Concept.
void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: existing }, queryParamsHandling: 'merge', replaceUrl: true }); void router!.navigate([], {
relativeTo: route!,
queryParams: { aanvraag: existing },
queryParamsHandling: 'merge',
replaceUrl: true,
});
return; return;
} }
applyResume(null); applyResume(null);
@@ -169,7 +192,13 @@ export function createDraftSync(deps: DraftSyncDeps) {
id = undefined; id = undefined;
ensuring = undefined; ensuring = undefined;
} }
if (active()) void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: null }, queryParamsHandling: 'merge', replaceUrl: true }); if (active())
void router!.navigate([], {
relativeTo: route!,
queryParams: { aanvraag: null },
queryParamsHandling: 'merge',
replaceUrl: true,
});
}, },
}; };
} }

View File

@@ -36,19 +36,23 @@ export class RegistratieLookupStore {
/** The address to prefill the draft with, once BRP resolves with a found address; /** The address to prefill the draft with, once BRP resolves with a found address;
null otherwise (loading, error, no address, malformed). */ null otherwise (loading, error, no address, malformed). */
readonly prefillAdres = computed<{ straat: string; postcode: string; woonplaats: string } | null>(() => { readonly prefillAdres = computed<{ straat: string; postcode: string; woonplaats: string } | null>(
() => {
const json = this.adresRes.value(); const json = this.adresRes.value();
if (json === undefined) return null; if (json === undefined) return null;
const parsed = parseBrpAddress(json); const parsed = parseBrpAddress(json);
return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null; return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null;
}); },
);
/** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */ /** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */
readonly duoLookup = computed<RemoteData<Err, DuoLookupDto>>(() => { readonly duoLookup = computed<RemoteData<Err, DuoLookupDto>>(() => {
const rd = fromResource(this.diplomasRes); const rd = fromResource(this.diplomasRes);
if (rd.tag !== 'Success') return rd; if (rd.tag !== 'Success') return rd;
const parsed = parseDuoLookup(rd.value); const parsed = parseDuoLookup(rd.value);
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) }; 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. */ /** Reload the BRP lookup (e.g. when the wizard restarts) so the address re-prefills. */

View File

@@ -2,33 +2,57 @@ import { describe, it, expect } from 'vitest';
import { submittedRow, detailRows, purposeLabel, statusLabel, TYPE_LABELS } from './aanvraag-view'; import { submittedRow, detailRows, purposeLabel, statusLabel, TYPE_LABELS } from './aanvraag-view';
import { Aanvraag } from './aanvraag'; import { Aanvraag } from './aanvraag';
const base = { id: '1', type: 'herregistratie' as const, documentIds: [], createdAt: '', updatedAt: '', submittedAt: '2024-05-12' }; const base = {
id: '1',
type: 'herregistratie' as const,
documentIds: [],
createdAt: '',
updatedAt: '',
submittedAt: '2024-05-12',
};
describe('submittedRow', () => { describe('submittedRow', () => {
it('heading is the type, subtitle is the purpose', () => { it('heading is the type, subtitle is the purpose', () => {
const row = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: false } } as Aanvraag); const row = submittedRow({
...base,
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
} as Aanvraag);
expect(row.heading).toBe(TYPE_LABELS.herregistratie); expect(row.heading).toBe(TYPE_LABELS.herregistratie);
expect(row.subtitle).toBe(purposeLabel('herregistratie')); expect(row.subtitle).toBe(purposeLabel('herregistratie'));
}); });
it('status line carries the status label, reference and submit date', () => { it('status line carries the status label, reference and submit date', () => {
const row = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: false } } as Aanvraag); const row = submittedRow({
expect(row.status).toContain(statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false })); ...base,
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
} as Aanvraag);
expect(row.status).toContain(
statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }),
);
expect(row.status).toContain('R1'); expect(row.status).toContain('R1');
expect(row.status).toContain('12 mei 2024'); expect(row.status).toContain('12 mei 2024');
}); });
it('manual review adds a note; rejection adds its reason', () => { it('manual review adds a note; rejection adds its reason', () => {
const manual = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: true } } as Aanvraag); const manual = submittedRow({
...base,
status: { tag: 'InBehandeling', referentie: 'R1', manual: true },
} as Aanvraag);
expect(manual.status).toContain('handmatig'); expect(manual.status).toContain('handmatig');
const rejected = submittedRow({ ...base, status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' } } as Aanvraag); const rejected = submittedRow({
...base,
status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' },
} as Aanvraag);
expect(rejected.status).toContain('Onvoldoende uren'); expect(rejected.status).toContain('Onvoldoende uren');
}); });
}); });
describe('detailRows', () => { describe('detailRows', () => {
it('lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected', () => { it('lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected', () => {
const rows = detailRows({ ...base, status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' } } as Aanvraag); const rows = detailRows({
...base,
status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' },
} as Aanvraag);
const values = rows.map((r) => r.value); const values = rows.map((r) => r.value);
expect(values).toContain(TYPE_LABELS.herregistratie); expect(values).toContain(TYPE_LABELS.herregistratie);
expect(values).toContain('R2'); expect(values).toContain('R2');
@@ -37,7 +61,11 @@ describe('detailRows', () => {
}); });
it('reference falls back to em dash for a Concept', () => { it('reference falls back to em dash for a Concept', () => {
const rows = detailRows({ ...base, submittedAt: undefined, status: { tag: 'Concept', stepIndex: 0, stepCount: 3 } } as Aanvraag); const rows = detailRows({
...base,
submittedAt: undefined,
status: { tag: 'Concept', stepIndex: 0, stepCount: 3 },
} as Aanvraag);
const ref = rows.find((r) => r.value === '—'); const ref = rows.find((r) => r.value === '—');
expect(ref).toBeTruthy(); expect(ref).toBeTruthy();
expect(rows.length).toBe(5); expect(rows.length).toBe(5);

View File

@@ -13,19 +13,26 @@ export const TYPE_LABELS: Record<AanvraagType, string> = {
/** What the aanvraag is for (shown under the title). */ /** What the aanvraag is for (shown under the title). */
export function purposeLabel(type: AanvraagType): string { export function purposeLabel(type: AanvraagType): string {
switch (type) { switch (type) {
case 'registratie': return $localize`:@@aanvraag.purpose.registratie:Inschrijving in het BIG-register`; case 'registratie':
case 'herregistratie': return $localize`:@@aanvraag.purpose.herregistratie:Verlenging van uw BIG-registratie`; return $localize`:@@aanvraag.purpose.registratie:Inschrijving in het BIG-register`;
case 'intake': return $localize`:@@aanvraag.purpose.intake:Intake-vragenlijst voor uw herregistratie`; case 'herregistratie':
return $localize`:@@aanvraag.purpose.herregistratie:Verlenging van uw BIG-registratie`;
case 'intake':
return $localize`:@@aanvraag.purpose.intake:Intake-vragenlijst voor uw herregistratie`;
} }
} }
/** The status as a plain label (what state the aanvraag is in). */ /** The status as a plain label (what state the aanvraag is in). */
export function statusLabel(status: AanvraagStatus): string { export function statusLabel(status: AanvraagStatus): string {
switch (status.tag) { switch (status.tag) {
case 'Concept': return $localize`:@@aanvraag.status.concept:Concept (nog niet ingediend)`; case 'Concept':
case 'InBehandeling': return $localize`:@@aanvraag.status.inBehandeling:In behandeling`; return $localize`:@@aanvraag.status.concept:Concept (nog niet ingediend)`;
case 'Goedgekeurd': return $localize`:@@aanvraag.status.goedgekeurd:Goedgekeurd`; case 'InBehandeling':
case 'Afgewezen': return $localize`:@@aanvraag.status.afgewezen:Afgewezen`; return $localize`:@@aanvraag.status.inBehandeling:In behandeling`;
case 'Goedgekeurd':
return $localize`:@@aanvraag.status.goedgekeurd:Goedgekeurd`;
case 'Afgewezen':
return $localize`:@@aanvraag.status.afgewezen:Afgewezen`;
} }
} }
@@ -43,7 +50,9 @@ export interface AanvraagRow {
} }
function formatNL(iso?: string): string { function formatNL(iso?: string): string {
return iso ? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' }) : ''; return iso
? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })
: '';
} }
/** Fields for a submitted aanvraag's row in the dashboard "aanvragen" list (Concept /** Fields for a submitted aanvraag's row in the dashboard "aanvragen" list (Concept
@@ -53,10 +62,18 @@ export function submittedRow(a: Aanvraag): AanvraagRow {
const parts = [statusLabel(s)]; const parts = [statusLabel(s)];
const ref = referentie(s); const ref = referentie(s);
if (ref) parts.push($localize`:@@aanvraag.row.ref:Referentie ${ref}:ref:`); if (ref) parts.push($localize`:@@aanvraag.row.ref:Referentie ${ref}:ref:`);
if (a.submittedAt) parts.push($localize`:@@aanvraag.row.ingediend:ingediend op ${formatNL(a.submittedAt)}:datum:`); if (a.submittedAt)
if (s.tag === 'InBehandeling' && s.manual) parts.push($localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`); parts.push($localize`:@@aanvraag.row.ingediend:ingediend op ${formatNL(a.submittedAt)}:datum:`);
if (s.tag === 'InBehandeling' && s.manual)
parts.push(
$localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`,
);
if (s.tag === 'Afgewezen') parts.push(s.reden); if (s.tag === 'Afgewezen') parts.push(s.reden);
return { heading: TYPE_LABELS[a.type], subtitle: purposeLabel(a.type), status: parts.join(' · ') }; return {
heading: TYPE_LABELS[a.type],
subtitle: purposeLabel(a.type),
status: parts.join(' · '),
};
} }
/** Key/value rows for the case-detail page (CIBG Datablock). */ /** Key/value rows for the case-detail page (CIBG Datablock). */
@@ -65,11 +82,20 @@ export function detailRows(a: Aanvraag): { key: string; value: string }[] {
{ key: $localize`:@@aanvraag.detail.soort:Soort aanvraag`, value: TYPE_LABELS[a.type] }, { key: $localize`:@@aanvraag.detail.soort:Soort aanvraag`, value: TYPE_LABELS[a.type] },
{ key: $localize`:@@aanvraag.detail.waarvoor:Waarvoor`, value: purposeLabel(a.type) }, { key: $localize`:@@aanvraag.detail.waarvoor:Waarvoor`, value: purposeLabel(a.type) },
{ key: $localize`:@@aanvraag.detail.status:Status`, value: statusLabel(a.status) }, { key: $localize`:@@aanvraag.detail.status:Status`, value: statusLabel(a.status) },
{ key: $localize`:@@aanvraag.detail.referentie:Referentie`, value: referentie(a.status) || '—' }, {
{ key: $localize`:@@aanvraag.detail.ingediend:Ingediend op`, value: a.submittedAt ? formatNL(a.submittedAt) : '—' }, key: $localize`:@@aanvraag.detail.referentie:Referentie`,
value: referentie(a.status) || '—',
},
{
key: $localize`:@@aanvraag.detail.ingediend:Ingediend op`,
value: a.submittedAt ? formatNL(a.submittedAt) : '—',
},
]; ];
if (a.status.tag === 'Afgewezen') { if (a.status.tag === 'Afgewezen') {
rows.push({ key: $localize`:@@aanvraag.detail.reden:Reden van afwijzing`, value: a.status.reden }); rows.push({
key: $localize`:@@aanvraag.detail.reden:Reden van afwijzing`,
value: a.status.reden,
});
} }
return rows; return rows;
} }

View File

@@ -3,11 +3,16 @@ import { blockActions } from './block-actions';
describe('blockActions', () => { describe('blockActions', () => {
it('a Concept can be resumed or cancelled', () => { it('a Concept can be resumed or cancelled', () => {
expect(blockActions({ tag: 'Concept', stepIndex: 1, stepCount: 3 })).toEqual(['resume', 'cancel']); expect(blockActions({ tag: 'Concept', stepIndex: 1, stepCount: 3 })).toEqual([
'resume',
'cancel',
]);
}); });
it('an in-behandeling aanvraag only exposes its documents', () => { it('an in-behandeling aanvraag only exposes its documents', () => {
expect(blockActions({ tag: 'InBehandeling', referentie: 'BIG-1', manual: true })).toEqual(['viewDocuments']); expect(blockActions({ tag: 'InBehandeling', referentie: 'BIG-1', manual: true })).toEqual([
'viewDocuments',
]);
}); });
it('resolved aanvragen have no actions', () => { it('resolved aanvragen have no actions', () => {

View File

@@ -1,7 +1,9 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { State, reduce, initial } from './change-request.machine'; import { State, reduce, initial } from './change-request.machine';
const editingWith = (draft: Partial<{ straat: string; postcode: string; woonplaats: string }>): State => ({ const editingWith = (
draft: Partial<{ straat: string; postcode: string; woonplaats: string }>,
): State => ({
tag: 'Editing', tag: 'Editing',
draft: { straat: '', postcode: '', woonplaats: '', ...draft }, draft: { straat: '', postcode: '', woonplaats: '', ...draft },
errors: {}, errors: {},
@@ -23,13 +25,17 @@ describe('change-request reduce', () => {
}); });
it('Submit with a valid draft moves to Submitting with parsed (normalised) data', () => { it('Submit with a valid draft moves to Submitting with parsed (normalised) data', () => {
const s = reduce(editingWith({ straat: 'Lange Voorhout 9', postcode: '2514ea' }), { tag: 'Submit' }); const s = reduce(editingWith({ straat: 'Lange Voorhout 9', postcode: '2514ea' }), {
tag: 'Submit',
});
expect(s.tag).toBe('Submitting'); expect(s.tag).toBe('Submitting');
expect((s as Extract<State, { tag: 'Submitting' }>).data.postcode).toBe('2514 EA'); expect((s as Extract<State, { tag: 'Submitting' }>).data.postcode).toBe('2514 EA');
}); });
it('confirms and fails only from Submitting; Retry re-submits a failure', () => { it('confirms and fails only from Submitting; Retry re-submits a failure', () => {
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), { tag: 'Submit' }); const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), {
tag: 'Submit',
});
const ok = reduce(submitting, { tag: 'SubmitConfirmed', referentie: 'BIG-2026-1' }); const ok = reduce(submitting, { tag: 'SubmitConfirmed', referentie: 'BIG-2026-1' });
expect(ok).toMatchObject({ tag: 'Submitted', referentie: 'BIG-2026-1' }); expect(ok).toMatchObject({ tag: 'Submitted', referentie: 'BIG-2026-1' });
@@ -39,7 +45,9 @@ describe('change-request reduce', () => {
}); });
it('Reset returns to the initial editing state', () => { it('Reset returns to the initial editing state', () => {
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), { tag: 'Submit' }); const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), {
tag: 'Submit',
});
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial); expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
}); });
}); });

View File

@@ -43,7 +43,10 @@ function validate(draft: Draft): Result<Errors, Valid> {
if (!straat) errors.straat = $localize`:@@validation.straat:Vul straat en huisnummer in.`; if (!straat) errors.straat = $localize`:@@validation.straat:Vul straat en huisnummer in.`;
if (!postcode.ok) errors.postcode = postcode.error; if (!postcode.ok) errors.postcode = postcode.error;
if (straat && postcode.ok) { if (straat && postcode.ok) {
return { ok: true, value: { straat, postcode: postcode.value, woonplaats: draft.woonplaats.trim() } }; return {
ok: true,
value: { straat, postcode: postcode.value, woonplaats: draft.woonplaats.trim() },
};
} }
return { ok: false, error: errors }; return { ok: false, error: errors };
} }
@@ -69,7 +72,9 @@ export function reduce(s: State, m: Msg): State {
case 'Retry': case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s; return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed': case 'SubmitConfirmed':
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data, referentie: m.referentie } : s; return s.tag === 'Submitting'
? { tag: 'Submitted', data: s.data, referentie: m.referentie }
: s;
case 'SubmitFailed': case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s; return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'Reset': case 'Reset':

View File

@@ -1,8 +1,10 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { hasProgress, initial, RegistratieState } from './registratie-wizard.machine'; import { hasProgress, initial, RegistratieState } from './registratie-wizard.machine';
const invullen = (over: Partial<Extract<RegistratieState, { tag: 'Invullen' }>>) => const invullen = (over: Partial<Extract<RegistratieState, { tag: 'Invullen' }>>) => ({
({ ...(initial as Extract<RegistratieState, { tag: 'Invullen' }>), ...over }); ...(initial as Extract<RegistratieState, { tag: 'Invullen' }>),
...over,
});
describe('hasProgress', () => { describe('hasProgress', () => {
it('is false for a fresh wizard', () => { it('is false for a fresh wizard', () => {
@@ -10,13 +12,23 @@ describe('hasProgress', () => {
}); });
it('ignores an auto-prefilled BRP address at step 0', () => { it('ignores an auto-prefilled BRP address at step 0', () => {
const s = invullen({ draft: { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', adresHerkomst: 'brp', antwoorden: {} } }); const s = invullen({
draft: {
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
adresHerkomst: 'brp',
antwoorden: {},
},
});
expect(hasProgress(s)).toBe(false); expect(hasProgress(s)).toBe(false);
}); });
it('is true once the user advances, picks correspondence/diploma, or is past step 0', () => { it('is true once the user advances, picks correspondence/diploma, or is past step 0', () => {
expect(hasProgress(invullen({ cursor: 1 }))).toBe(true); expect(hasProgress(invullen({ cursor: 1 }))).toBe(true);
expect(hasProgress(invullen({ draft: { correspondentie: 'post', antwoorden: {} } }))).toBe(true); expect(hasProgress(invullen({ draft: { correspondentie: 'post', antwoorden: {} } }))).toBe(
true,
);
expect(hasProgress(invullen({ draft: { diplomaId: 'd1', antwoorden: {} } }))).toBe(true); expect(hasProgress(invullen({ draft: { diplomaId: 'd1', antwoorden: {} } }))).toBe(true);
}); });
}); });

View File

@@ -29,8 +29,19 @@ const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({
upload: initialUpload, upload: initialUpload,
}); });
const validAdres = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', correspondentie: 'post' as const, adresHerkomst: 'brp' as const }; const validAdres = {
const validDraft: Partial<Draft> = { ...validAdres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo' }; straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
correspondentie: 'post' as const,
adresHerkomst: 'brp' as const,
};
const validDraft: Partial<Draft> = {
...validAdres,
diplomaId: 'd1',
beroep: 'Arts',
diplomaHerkomst: 'duo',
};
describe('STEPS (fixed)', () => { describe('STEPS (fixed)', () => {
it('always has the same three steps', () => { it('always has the same three steps', () => {
@@ -106,7 +117,18 @@ describe('adres origin (BRP vs handmatig)', () => {
}); });
it('a manually entered address still submits (only manual diploma is gated)', () => { it('a manually entered address still submits (only manual diploma is gated)', () => {
const s = submit(invullen({ straat: 'Kerkstraat 1', postcode: '1234 AB', woonplaats: 'Utrecht', correspondentie: 'post', adresHerkomst: 'handmatig', diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo' })); const s = submit(
invullen({
straat: 'Kerkstraat 1',
postcode: '1234 AB',
woonplaats: 'Utrecht',
correspondentie: 'post',
adresHerkomst: 'handmatig',
diplomaId: 'd1',
beroep: 'Arts',
diplomaHerkomst: 'duo',
}),
);
expect(s.tag).toBe('Indienen'); expect(s.tag).toBe('Indienen');
expect((s as any).data.adresHerkomst).toBe('handmatig'); expect((s as any).data.adresHerkomst).toBe('handmatig');
}); });
@@ -185,7 +207,12 @@ describe('submit', () => {
describe('reduce (message-driven happy path)', () => { describe('reduce (message-driven happy path)', () => {
it('drives the full flow via messages', () => { it('drives the full flow via messages', () => {
let s: RegistratieState = initial; let s: RegistratieState = initial;
s = reduce(s, { tag: 'PrefillAdres', straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' }); s = reduce(s, {
tag: 'PrefillAdres',
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
});
s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' }); s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' });
s = reduce(s, { tag: 'Next' }); s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('beroep'); expect(currentStep(s as any)).toBe('beroep');
@@ -199,7 +226,10 @@ describe('reduce (message-driven happy path)', () => {
}); });
it('SubmitFailed then Retry returns to Indienen with the same data', () => { it('SubmitFailed then Retry returns to Indienen with the same data', () => {
let s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' }); let s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), {
tag: 'SubmitFailed',
error: 'boom',
});
expect(s.tag).toBe('Mislukt'); expect(s.tag).toBe('Mislukt');
s = reduce(s, { tag: 'Retry' }); s = reduce(s, { tag: 'Retry' });
expect(s.tag).toBe('Indienen'); expect(s.tag).toBe('Indienen');
@@ -208,27 +238,51 @@ describe('reduce (message-driven happy path)', () => {
}); });
describe('inline document upload (beroep step)', () => { describe('inline document upload (beroep step)', () => {
const cat = { categoryId: 'diploma', label: 'Diploma', description: '', required: true, acceptedTypes: [], maxSizeMb: 10, multiple: false, allowPostDelivery: true }; const cat = {
categoryId: 'diploma',
label: 'Diploma',
description: '',
required: true,
acceptedTypes: [],
maxSizeMb: 10,
multiple: false,
allowPostDelivery: true,
};
it('routes Upload messages through the upload reducer', () => { it('routes Upload messages through the upload reducer', () => {
const s = reduce(invullen(validDraft), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } }); const s = reduce(invullen(validDraft), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
});
expect((s as any).upload.categories).toHaveLength(1); expect((s as any).upload.categories).toHaveLength(1);
}); });
it('blocks the beroep step until a required category is satisfied', () => { it('blocks the beroep step until a required category is satisfied', () => {
let s = reduce(invullen(validDraft, 1), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } }); let s = reduce(invullen(validDraft, 1), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
});
s = reduce(s, { tag: 'Next' }); // beroep → controle blocked s = reduce(s, { tag: 'Next' }); // beroep → controle blocked
expect(currentStep(s as any)).toBe('beroep'); expect(currentStep(s as any)).toBe('beroep');
expect((s as any).errors.documenten).toBeTruthy(); expect((s as any).errors.documenten).toBeTruthy();
// choosing post delivery satisfies the requirement // choosing post delivery satisfies the requirement
s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' } }); s = reduce(s, {
tag: 'Upload',
msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' },
});
s = reduce(s, { tag: 'Next' }); s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('controle'); expect(currentStep(s as any)).toBe('controle');
}); });
it('includes delivery refs in the submitted data', () => { it('includes delivery refs in the submitted data', () => {
let s = reduce(invullen(validDraft), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } }); let s = reduce(invullen(validDraft), {
s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' } }); tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
});
s = reduce(s, {
tag: 'Upload',
msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' },
});
const done = submit(s as any); const done = submit(s as any);
expect(done.tag).toBe('Indienen'); expect(done.tag).toBe('Indienen');
expect((done as any).data.documents).toEqual([{ categoryId: 'diploma', channel: 'post' }]); expect((done as any).data.documents).toEqual([{ categoryId: 'diploma', channel: 'post' }]);

View File

@@ -84,7 +84,13 @@ export type RegistratieState =
| { tag: 'Mislukt'; data: ValidRegistratie; error: string }; | { tag: 'Mislukt'; data: ValidRegistratie; error: string };
const emptyDraft: Draft = { antwoorden: {} }; const emptyDraft: Draft = { antwoorden: {} };
export const initial: RegistratieState = { tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {}, upload: initialUpload }; export const initial: RegistratieState = {
tag: 'Invullen',
draft: emptyDraft,
cursor: 0,
errors: {},
upload: initialUpload,
};
/** Which step the cursor currently points at (clamped to the fixed list). */ /** Which step the cursor currently points at (clamped to the fixed list). */
export function currentStep(s: Extract<RegistratieState, { tag: 'Invullen' }>): StepId { export function currentStep(s: Extract<RegistratieState, { tag: 'Invullen' }>): StepId {
@@ -112,11 +118,14 @@ function validateStep(step: StepId, d: Draft, upload: UploadState): Result<Error
const errors: Errors = {}; const errors: Errors = {};
switch (step) { switch (step) {
case 'adres': { case 'adres': {
if (!d.straat || d.straat.trim() === '') errors.straat = $localize`:@@validation.straat2:Vul een straat en huisnummer in.`; if (!d.straat || d.straat.trim() === '')
errors.straat = $localize`:@@validation.straat2:Vul een straat en huisnummer in.`;
const pc = parsePostcode(d.postcode ?? ''); const pc = parsePostcode(d.postcode ?? '');
if (!pc.ok) errors.postcode = pc.error; if (!pc.ok) errors.postcode = pc.error;
if (!d.woonplaats || d.woonplaats.trim() === '') errors.woonplaats = $localize`:@@validation.woonplaats:Vul een woonplaats in.`; if (!d.woonplaats || d.woonplaats.trim() === '')
if (!d.correspondentie) errors.correspondentie = $localize`:@@validation.maakKeuze:Maak een keuze.`; errors.woonplaats = $localize`:@@validation.woonplaats:Vul een woonplaats in.`;
if (!d.correspondentie)
errors.correspondentie = $localize`:@@validation.maakKeuze:Maak een keuze.`;
// E-mail is only required when 'email' is the chosen channel. // E-mail is only required when 'email' is the chosen channel.
if (d.correspondentie === 'email') { if (d.correspondentie === 'email') {
const e = parseEmail(d.email ?? ''); const e = parseEmail(d.email ?? '');
@@ -135,7 +144,8 @@ function validateStep(step: StepId, d: Draft, upload: UploadState): Result<Error
// they're answered. // they're answered.
const open: Record<string, string> = {}; const open: Record<string, string> = {};
for (const id of d.vraagIds ?? []) { for (const id of d.vraagIds ?? []) {
if (!(d.antwoorden[id] ?? '').trim()) open[id] = $localize`:@@validation.beantwoordVraag:Beantwoord deze vraag.`; if (!(d.antwoorden[id] ?? '').trim())
open[id] = $localize`:@@validation.beantwoordVraag:Beantwoord deze vraag.`;
} }
if (Object.keys(open).length > 0) errors.antwoorden = open; if (Object.keys(open).length > 0) errors.antwoorden = open;
// Required documents for this wizard attach to the beroep step (inline upload). // Required documents for this wizard attach to the beroep step (inline upload).
@@ -186,7 +196,8 @@ export function setField(s: RegistratieState, key: DraftField, value: string): R
if (s.tag !== 'Invullen') return s; if (s.tag !== 'Invullen') return s;
const draft: Draft = { ...s.draft, [key]: value }; const draft: Draft = { ...s.draft, [key]: value };
// Editing an address field means the user owns it now — not the BRP copy. // Editing an address field means the user owns it now — not the BRP copy.
if (key === 'straat' || key === 'postcode' || key === 'woonplaats') draft.adresHerkomst = 'handmatig'; if (key === 'straat' || key === 'postcode' || key === 'woonplaats')
draft.adresHerkomst = 'handmatig';
return { ...s, draft }; return { ...s, draft };
} }
@@ -196,16 +207,30 @@ export function setCorrespondentie(s: RegistratieState, value: Correspondentie):
} }
/** Prefill the address from a BRP lookup and flag its origin (PRD §7). */ /** Prefill the address from a BRP lookup and flag its origin (PRD §7). */
export function prefillAdres(s: RegistratieState, straat: string, postcode: string, woonplaats: string): RegistratieState { export function prefillAdres(
s: RegistratieState,
straat: string,
postcode: string,
woonplaats: string,
): RegistratieState {
if (s.tag !== 'Invullen') return s; if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, straat, postcode, woonplaats, adresHerkomst: 'brp' } }; return { ...s, draft: { ...s.draft, straat, postcode, woonplaats, adresHerkomst: 'brp' } };
} }
/** Pick a DUO diploma; the beroep is derived from it and the applicable policy /** Pick a DUO diploma; the beroep is derived from it and the applicable policy
questions (`vraagIds`) come with it (both server-computed, passed in). */ questions (`vraagIds`) come with it (both server-computed, passed in). */
export function kiesDiploma(s: RegistratieState, diplomaId: string, beroep: string, vraagIds: string[]): RegistratieState { export function kiesDiploma(
s: RegistratieState,
diplomaId: string,
beroep: string,
vraagIds: string[],
): RegistratieState {
if (s.tag !== 'Invullen') return s; if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, diplomaId, beroep, vraagIds, diplomaHerkomst: 'duo' }, errors: {} }; return {
...s,
draft: { ...s.draft, diplomaId, beroep, vraagIds, diplomaHerkomst: 'duo' },
errors: {},
};
} }
/** Switch to manual diploma entry: the diploma isn't in DUO, so the MAXIMAL /** Switch to manual diploma entry: the diploma isn't in DUO, so the MAXIMAL
@@ -213,7 +238,17 @@ export function kiesDiploma(s: RegistratieState, diplomaId: string, beroep: stri
beroep is declared separately (declareerBeroep). */ beroep is declared separately (declareerBeroep). */
export function kiesHandmatig(s: RegistratieState, vraagIds: string[]): RegistratieState { export function kiesHandmatig(s: RegistratieState, vraagIds: string[]): RegistratieState {
if (s.tag !== 'Invullen') return s; if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, diplomaId: 'handmatig', beroep: undefined, vraagIds, diplomaHerkomst: 'handmatig' }, errors: {} }; return {
...s,
draft: {
...s.draft,
diplomaId: 'handmatig',
beroep: undefined,
vraagIds,
diplomaHerkomst: 'handmatig',
},
errors: {},
};
} }
/** Declare the beroep for a manually-entered diploma (chosen from a fixed list). */ /** Declare the beroep for a manually-entered diploma (chosen from a fixed list). */
@@ -260,7 +295,9 @@ export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState {
export function resolve(s: RegistratieState, r: Result<string, string>): RegistratieState { export function resolve(s: RegistratieState, r: Result<string, string>): RegistratieState {
if (s.tag !== 'Indienen') return s; if (s.tag !== 'Indienen') return s;
return r.ok ? { tag: 'Ingediend', data: s.data, referentie: r.value } : { tag: 'Mislukt', data: s.data, error: r.error }; return r.ok
? { tag: 'Ingediend', data: s.data, referentie: r.value }
: { tag: 'Mislukt', data: s.data, error: r.error };
} }
export type RegistratieMsg = export type RegistratieMsg =
@@ -308,7 +345,9 @@ export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState
case 'Retry': case 'Retry':
return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s; return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s;
case 'SubmitConfirmed': case 'SubmitConfirmed':
return s.tag === 'Indienen' ? { tag: 'Ingediend', data: s.data, referentie: m.referentie } : s; return s.tag === 'Indienen'
? { tag: 'Ingediend', data: s.data, referentie: m.referentie }
: s;
case 'SubmitFailed': case 'SubmitFailed':
return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s; return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s;
case 'Upload': case 'Upload':

View File

@@ -3,8 +3,12 @@ import { Registration } from './registration';
import { isHerregistratieEligible, statusColor } from './registration.policy'; import { isHerregistratieEligible, statusColor } from './registration.policy';
const reg = (status: Registration['status']): Registration => ({ const reg = (status: Registration['status']): Registration => ({
bigNummer: '19012345601', naam: 'Test', beroep: 'Arts', bigNummer: '19012345601',
registratiedatum: '2012-09-01', geboortedatum: '1985-03-14', status, naam: 'Test',
beroep: 'Arts',
registratiedatum: '2012-09-01',
geboortedatum: '1985-03-14',
status,
}); });
describe('registration.policy', () => { describe('registration.policy', () => {
@@ -15,8 +19,18 @@ describe('registration.policy', () => {
}); });
it('struck-off / suspended registrations are never eligible', () => { it('struck-off / suspended registrations are never eligible', () => {
expect(isHerregistratieEligible(reg({ tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'x' }), new Date('2027-01-01'))).toBe(false); expect(
expect(isHerregistratieEligible(reg({ tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'x' }), new Date('2027-01-01'))).toBe(false); isHerregistratieEligible(
reg({ tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'x' }),
new Date('2027-01-01'),
),
).toBe(false);
expect(
isHerregistratieEligible(
reg({ tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'x' }),
new Date('2027-01-01'),
),
).toBe(false);
}); });
it('statusColor is total over the union', () => { it('statusColor is total over the union', () => {

View File

@@ -38,7 +38,11 @@ export function herregistratieDeadline(reg: Registration): Date | null {
SERVER-OWNED RULE: this now runs on the backend (BFF), which ships the result SERVER-OWNED RULE: this now runs on the backend (BFF), which ships the result
as `decisions.eligibleForHerregistratie` in the dashboard view. Kept here as as `decisions.eligibleForHerregistratie` in the dashboard view. Kept here as
the reference implementation + unit test; the frontend no longer calls it. */ the reference implementation + unit test; the frontend no longer calls it. */
export function isHerregistratieEligible(reg: Registration, today: Date, windowMonths = 12): boolean { export function isHerregistratieEligible(
reg: Registration,
today: Date,
windowMonths = 12,
): boolean {
const deadline = herregistratieDeadline(reg); const deadline = herregistratieDeadline(reg);
if (!deadline) return false; if (!deadline) return false;
const windowStart = new Date(deadline); const windowStart = new Date(deadline);

View File

@@ -24,7 +24,10 @@ describe('tasksFromProfile', () => {
}); });
it('surfaces a notice for a suspended registration (independent of eligibility)', () => { it('surfaces a notice for a suspended registration (independent of eligibility)', () => {
const reg: Registration = { ...base, status: { tag: 'Geschorst', geschorstTot: '2027-01-01', reden: 'Onderzoek' } }; const reg: Registration = {
...base,
status: { tag: 'Geschorst', geschorstTot: '2027-01-01', reden: 'Onderzoek' },
};
const tasks = tasksFromProfile(reg, false); const tasks = tasksFromProfile(reg, false);
expect(tasks).toHaveLength(1); expect(tasks).toHaveLength(1);
expect(tasks[0].title).toContain('geschorst'); expect(tasks[0].title).toContain('geschorst');
@@ -32,7 +35,10 @@ describe('tasksFromProfile', () => {
}); });
it('surfaces a notice for a struck-off registration', () => { it('surfaces a notice for a struck-off registration', () => {
const reg: Registration = { ...base, status: { tag: 'Doorgehaald', doorgehaaldOp: '2025-01-01', reden: 'Op eigen verzoek' } }; const reg: Registration = {
...base,
status: { tag: 'Doorgehaald', doorgehaaldOp: '2025-01-01', reden: 'Op eigen verzoek' },
};
const tasks = tasksFromProfile(reg, false); const tasks = tasksFromProfile(reg, false);
expect(tasks).toHaveLength(1); expect(tasks).toHaveLength(1);
expect(tasks[0].title).toContain('doorgehaald'); expect(tasks[0].title).toContain('doorgehaald');

View File

@@ -22,7 +22,10 @@ function formatNL(d: Date): string {
* it does not recompute the rule (ADR-0001). The deadline is still formatted * it does not recompute the rule (ADR-0001). The deadline is still formatted
* client-side for the task copy (presentation, not a rule). * client-side for the task copy (presentation, not a rule).
*/ */
export function tasksFromProfile(reg: Registration, eligibleForHerregistratie: boolean): PortalTask[] { export function tasksFromProfile(
reg: Registration,
eligibleForHerregistratie: boolean,
): PortalTask[] {
const tasks: PortalTask[] = []; const tasks: PortalTask[] = [];
if (eligibleForHerregistratie) { if (eligibleForHerregistratie) {

View File

@@ -5,5 +5,7 @@ export type BigNummer = Brand<string, 'BigNummer'>;
export function parseBigNummer(raw: string): Result<string, BigNummer> { export function parseBigNummer(raw: string): Result<string, BigNummer> {
const t = raw.trim(); const t = raw.trim();
return /^\d{11}$/.test(t) ? ok(t as BigNummer) : err($localize`:@@validation.bigNummer:Een BIG-nummer bestaat uit 11 cijfers.`); return /^\d{11}$/.test(t)
? ok(t as BigNummer)
: err($localize`:@@validation.bigNummer:Een BIG-nummer bestaat uit 11 cijfers.`);
} }

View File

@@ -13,7 +13,9 @@ export function parseEmail(raw: string): Result<string, Email> {
// Deliberately lax: a single @ with non-empty, dot-bearing parts. Good enough // Deliberately lax: a single @ with non-empty, dot-bearing parts. Good enough
// for instant feedback; the server re-validates. // for instant feedback; the server re-validates.
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) {
return err($localize`:@@validation.email:Voer een geldig e-mailadres in, bijv. naam@voorbeeld.nl.`); return err(
$localize`:@@validation.email:Voer een geldig e-mailadres in, bijv. naam@voorbeeld.nl.`,
);
} }
return ok(t as Email); return ok(t as Email);
} }

View File

@@ -3,7 +3,11 @@ import { parseUren } from './uren';
describe('parseUren', () => { describe('parseUren', () => {
it('accepts non-negative whole numbers, including 0', () => { it('accepts non-negative whole numbers, including 0', () => {
for (const [raw, n] of [['0', 0], [' 40 ', 40], ['1000', 1000]] as const) { for (const [raw, n] of [
['0', 0],
[' 40 ', 40],
['1000', 1000],
] as const) {
const r = parseUren(raw); const r = parseUren(raw);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe(n); if (r.ok) expect(r.value).toBe(n);

View File

@@ -1,14 +1,33 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { parseAanvraagStatus, parseApplicationSummary, parseApplications, parseApplicationDetail } from './applications.adapter'; import {
parseAanvraagStatus,
parseApplicationSummary,
parseApplications,
parseApplicationDetail,
} from './applications.adapter';
const concept = { id: 'a1', type: 'registratie', status: { tag: 'Concept', stepIndex: 1, stepCount: 4 }, documentIds: [], createdAt: '2026-07-01T10:00:00Z', updatedAt: '2026-07-01T10:05:00Z' }; const concept = {
id: 'a1',
type: 'registratie',
status: { tag: 'Concept', stepIndex: 1, stepCount: 4 },
documentIds: [],
createdAt: '2026-07-01T10:00:00Z',
updatedAt: '2026-07-01T10:05:00Z',
};
describe('parseAanvraagStatus', () => { describe('parseAanvraagStatus', () => {
it('parses each tag with its required fields', () => { it('parses each tag with its required fields', () => {
expect(parseAanvraagStatus({ tag: 'Concept', stepIndex: 2, stepCount: 4 })).toEqual({ ok: true, value: { tag: 'Concept', stepIndex: 2, stepCount: 4 } }); expect(parseAanvraagStatus({ tag: 'Concept', stepIndex: 2, stepCount: 4 })).toEqual({
expect(parseAanvraagStatus({ tag: 'InBehandeling', referentie: 'BIG-1', manual: true }).ok).toBe(true); ok: true,
value: { tag: 'Concept', stepIndex: 2, stepCount: 4 },
});
expect(
parseAanvraagStatus({ tag: 'InBehandeling', referentie: 'BIG-1', manual: true }).ok,
).toBe(true);
expect(parseAanvraagStatus({ tag: 'Goedgekeurd', referentie: 'BIG-1' }).ok).toBe(true); expect(parseAanvraagStatus({ tag: 'Goedgekeurd', referentie: 'BIG-1' }).ok).toBe(true);
expect(parseAanvraagStatus({ tag: 'Afgewezen', referentie: 'BIG-1', reden: 'geen uren' }).ok).toBe(true); expect(
parseAanvraagStatus({ tag: 'Afgewezen', referentie: 'BIG-1', reden: 'geen uren' }).ok,
).toBe(true);
}); });
it('rejects a missing status, unknown tag, and wrong-typed fields', () => { it('rejects a missing status, unknown tag, and wrong-typed fields', () => {

View File

@@ -9,7 +9,12 @@ import {
SubmitApplicationRequest, SubmitApplicationRequest,
SubmitApplicationResponse, SubmitApplicationResponse,
} from '@shared/infrastructure/api-client'; } from '@shared/infrastructure/api-client';
import { Aanvraag, AanvraagDetail, AanvraagStatus, AanvraagType } from '@registratie/domain/aanvraag'; import {
Aanvraag,
AanvraagDetail,
AanvraagStatus,
AanvraagType,
} from '@registratie/domain/aanvraag';
/** /**
* Infrastructure adapter for the backend-owned Aanvraag aggregate — the only place * Infrastructure adapter for the backend-owned Aanvraag aggregate — the only place
@@ -54,20 +59,25 @@ export class ApplicationsAdapter {
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake']; const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
/** Trust-boundary parse of the status union — the tag drives which fields must exist. */ /** Trust-boundary parse of the status union — the tag drives which fields must exist. */
export function parseAanvraagStatus(s: AanvraagStatusDto | undefined): Result<string, AanvraagStatus> { export function parseAanvraagStatus(
s: AanvraagStatusDto | undefined,
): Result<string, AanvraagStatus> {
if (!s || typeof s.tag !== 'string') return err('aanvraag: missing status'); if (!s || typeof s.tag !== 'string') return err('aanvraag: missing status');
switch (s.tag) { switch (s.tag) {
case 'Concept': case 'Concept':
if (typeof s.stepIndex !== 'number' || typeof s.stepCount !== 'number') return err('aanvraag: bad Concept status'); if (typeof s.stepIndex !== 'number' || typeof s.stepCount !== 'number')
return err('aanvraag: bad Concept status');
return ok({ tag: 'Concept', stepIndex: s.stepIndex, stepCount: s.stepCount }); return ok({ tag: 'Concept', stepIndex: s.stepIndex, stepCount: s.stepCount });
case 'InBehandeling': case 'InBehandeling':
if (typeof s.referentie !== 'string' || typeof s.manual !== 'boolean') return err('aanvraag: bad InBehandeling status'); if (typeof s.referentie !== 'string' || typeof s.manual !== 'boolean')
return err('aanvraag: bad InBehandeling status');
return ok({ tag: 'InBehandeling', referentie: s.referentie, manual: s.manual }); return ok({ tag: 'InBehandeling', referentie: s.referentie, manual: s.manual });
case 'Goedgekeurd': case 'Goedgekeurd':
if (typeof s.referentie !== 'string') return err('aanvraag: bad Goedgekeurd status'); if (typeof s.referentie !== 'string') return err('aanvraag: bad Goedgekeurd status');
return ok({ tag: 'Goedgekeurd', referentie: s.referentie }); return ok({ tag: 'Goedgekeurd', referentie: s.referentie });
case 'Afgewezen': case 'Afgewezen':
if (typeof s.referentie !== 'string' || typeof s.reden !== 'string') return err('aanvraag: bad Afgewezen status'); if (typeof s.referentie !== 'string' || typeof s.reden !== 'string')
return err('aanvraag: bad Afgewezen status');
return ok({ tag: 'Afgewezen', referentie: s.referentie, reden: s.reden }); return ok({ tag: 'Afgewezen', referentie: s.referentie, reden: s.reden });
default: default:
return err(`aanvraag: unknown status tag ${s.tag}`); return err(`aanvraag: unknown status tag ${s.tag}`);
@@ -76,8 +86,10 @@ export function parseAanvraagStatus(s: AanvraagStatusDto | undefined): Result<st
function parseCommon(dto: ApplicationSummaryDto): Result<string, Aanvraag> { function parseCommon(dto: ApplicationSummaryDto): Result<string, Aanvraag> {
if (typeof dto.id !== 'string') return err('aanvraag: missing id'); if (typeof dto.id !== 'string') return err('aanvraag: missing id');
if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type)) return err(`aanvraag: bad type ${dto.type}`); if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type))
if (typeof dto.createdAt !== 'string' || typeof dto.updatedAt !== 'string') return err('aanvraag: missing timestamps'); return err(`aanvraag: bad type ${dto.type}`);
if (typeof dto.createdAt !== 'string' || typeof dto.updatedAt !== 'string')
return err('aanvraag: missing timestamps');
const status = parseAanvraagStatus(dto.status); const status = parseAanvraagStatus(dto.status);
if (!status.ok) return status; if (!status.ok) return status;
return ok({ return ok({

View File

@@ -22,5 +22,9 @@ export class BigRegisterAdapter {
/** Map the wire DTO (all fields optional) onto our domain type. */ /** Map the wire DTO (all fields optional) onto our domain type. */
function toAantekening(n: AantekeningDto): Aantekening { function toAantekening(n: AantekeningDto): Aantekening {
return { type: n.type as AantekeningType, omschrijving: n.omschrijving ?? '', datum: n.datum ?? '' }; return {
type: n.type as AantekeningType,
omschrijving: n.omschrijving ?? '',
datum: n.datum ?? '',
};
} }

View File

@@ -3,7 +3,10 @@ import { parseBrpAddress } from './brp.adapter';
describe('parseBrpAddress (trust boundary)', () => { describe('parseBrpAddress (trust boundary)', () => {
it('accepts a found address', () => { it('accepts a found address', () => {
const r = parseBrpAddress({ gevonden: true, adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' } }); const r = parseBrpAddress({
gevonden: true,
adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' },
});
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (r.ok) expect(r.value.adres?.postcode).toBe('2514 EA'); if (r.ok) expect(r.value.adres?.postcode).toBe('2514 EA');
}); });

View File

@@ -27,7 +27,12 @@ export function parseBrpAddress(json: unknown): Result<string, BrpAddressDto> {
if (typeof dto.gevonden !== 'boolean') return err('brp-address: missing/invalid gevonden'); if (typeof dto.gevonden !== 'boolean') return err('brp-address: missing/invalid gevonden');
if (dto.gevonden) { if (dto.gevonden) {
const a = dto.adres; const a = dto.adres;
if (!a || typeof a.straat !== 'string' || typeof a.postcode !== 'string' || typeof a.woonplaats !== 'string') { if (
!a ||
typeof a.straat !== 'string' ||
typeof a.postcode !== 'string' ||
typeof a.woonplaats !== 'string'
) {
return err('brp-address: missing/invalid adres'); return err('brp-address: missing/invalid adres');
} }
} }

View File

@@ -10,7 +10,11 @@ const valid = {
geboortedatum: '1985-03-14', geboortedatum: '1985-03-14',
status: { tag: 'Geregistreerd', herregistratieDatum: '2027-03-01' }, status: { tag: 'Geregistreerd', herregistratieDatum: '2027-03-01' },
}, },
person: { naam: 'Dr. A. de Vries', geboortedatum: '1985-03-14', adres: { straat: 'X 1', postcode: '2514 EA', woonplaats: 'Den Haag' } }, person: {
naam: 'Dr. A. de Vries',
geboortedatum: '1985-03-14',
adres: { straat: 'X 1', postcode: '2514 EA', woonplaats: 'Den Haag' },
},
decisions: { eligibleForHerregistratie: true, herregistratieReason: 'within window' }, decisions: { eligibleForHerregistratie: true, herregistratieReason: 'within window' },
}; };
@@ -27,6 +31,8 @@ describe('parseDashboardView (trust boundary)', () => {
it('rejects malformed responses instead of trusting them', () => { it('rejects malformed responses instead of trusting them', () => {
expect(parseDashboardView(null).ok).toBe(false); expect(parseDashboardView(null).ok).toBe(false);
expect(parseDashboardView({ ...valid, registration: undefined }).ok).toBe(false); expect(parseDashboardView({ ...valid, registration: undefined }).ok).toBe(false);
expect(parseDashboardView({ ...valid, decisions: { eligibleForHerregistratie: 'yes' } }).ok).toBe(false); expect(
parseDashboardView({ ...valid, decisions: { eligibleForHerregistratie: 'yes' } }).ok,
).toBe(false);
}); });
}); });

View File

@@ -1,6 +1,9 @@
import { Injectable, inject, resource } from '@angular/core'; import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp'; import { Result, ok, err } from '@shared/kernel/fp';
import { DashboardViewDto, HerregistratieDecisions } from '@registratie/contracts/dashboard-view.dto'; import {
DashboardViewDto,
HerregistratieDecisions,
} from '@registratie/contracts/dashboard-view.dto';
import { Registration } from '@registratie/domain/registration'; import { Registration } from '@registratie/domain/registration';
import { Person } from '@registratie/domain/person'; import { Person } from '@registratie/domain/person';
import { BigProfile } from '@registratie/domain/big-profile'; import { BigProfile } from '@registratie/domain/big-profile';
@@ -49,7 +52,12 @@ export function parseDashboardView(json: unknown): Result<string, DashboardView>
const dto = json as Partial<DashboardViewDto>; const dto = json as Partial<DashboardViewDto>;
const reg = dto.registration; const reg = dto.registration;
if (!reg || typeof reg.bigNummer !== 'string' || !reg.status || typeof reg.status.tag !== 'string') { if (
!reg ||
typeof reg.bigNummer !== 'string' ||
!reg.status ||
typeof reg.status.tag !== 'string'
) {
return err('dashboard-view: missing/invalid registration'); return err('dashboard-view: missing/invalid registration');
} }
const person = dto.person; const person = dto.person;
@@ -72,10 +80,17 @@ export function parseDashboardView(json: unknown): Result<string, DashboardView>
geboortedatum: reg.geboortedatum, geboortedatum: reg.geboortedatum,
status: reg.status, status: reg.status,
}; };
const persoon: Person = { naam: person.naam, geboortedatum: person.geboortedatum, adres: person.adres }; const persoon: Person = {
naam: person.naam,
geboortedatum: person.geboortedatum,
adres: person.adres,
};
return ok({ return ok({
profile: { registration, person: persoon }, profile: { registration, person: persoon },
decisions: { eligibleForHerregistratie: d.eligibleForHerregistratie, herregistratieReason: d.herregistratieReason }, decisions: {
eligibleForHerregistratie: d.eligibleForHerregistratie,
herregistratieReason: d.herregistratieReason,
},
}); });
} }

View File

@@ -3,8 +3,22 @@ import { parseDuoLookup } from './duo.adapter';
const valid = { const valid = {
diplomas: [ diplomas: [
{ id: 'd1', naam: 'Geneeskunde', instelling: 'Universiteit Leiden', jaar: 2011, beroep: 'Arts', policyQuestions: [] }, {
{ id: 'd2', naam: 'Medicine', instelling: 'University of Edinburgh', jaar: 2013, beroep: 'Arts', policyQuestions: [{ id: 'nl-taal', vraag: 'Toon taalvaardigheid', type: 'ja-nee' }] }, id: 'd1',
naam: 'Geneeskunde',
instelling: 'Universiteit Leiden',
jaar: 2011,
beroep: 'Arts',
policyQuestions: [],
},
{
id: 'd2',
naam: 'Medicine',
instelling: 'University of Edinburgh',
jaar: 2013,
beroep: 'Arts',
policyQuestions: [{ id: 'nl-taal', vraag: 'Toon taalvaardigheid', type: 'ja-nee' }],
},
], ],
handmatig: { handmatig: {
beroepen: ['Arts', 'Verpleegkundige'], beroepen: ['Arts', 'Verpleegkundige'],
@@ -34,6 +48,14 @@ describe('parseDuoLookup (trust boundary)', () => {
expect(parseDuoLookup({}).ok).toBe(false); // no diplomas expect(parseDuoLookup({}).ok).toBe(false); // no diplomas
expect(parseDuoLookup({ diplomas: [] }).ok).toBe(false); // no handmatig expect(parseDuoLookup({ diplomas: [] }).ok).toBe(false); // no handmatig
expect(parseDuoLookup({ diplomas: [{ id: 'd1' }], handmatig: valid.handmatig }).ok).toBe(false); // bad diploma expect(parseDuoLookup({ diplomas: [{ id: 'd1' }], handmatig: valid.handmatig }).ok).toBe(false); // bad diploma
expect(parseDuoLookup({ diplomas: [], handmatig: { beroepen: ['Arts'], policyQuestions: [{ id: 'x', vraag: 'y', type: 'bogus' }] } }).ok).toBe(false); // bad question type expect(
parseDuoLookup({
diplomas: [],
handmatig: {
beroepen: ['Arts'],
policyQuestions: [{ id: 'x', vraag: 'y', type: 'bogus' }],
},
}).ok,
).toBe(false); // bad question type
}); });
}); });

View File

@@ -1,6 +1,11 @@
import { Injectable, inject, resource } from '@angular/core'; import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp'; import { Result, ok, err } from '@shared/kernel/fp';
import { DuoLookupDto, DuoDiplomaDto, PolicyQuestionDto, ManualDiplomaPolicyDto } from '@registratie/contracts/duo-diplomas.dto'; import {
DuoLookupDto,
DuoDiplomaDto,
PolicyQuestionDto,
ManualDiplomaPolicyDto,
} from '@registratie/contracts/duo-diplomas.dto';
import { ApiClient } from '@shared/infrastructure/api-client'; import { ApiClient } from '@shared/infrastructure/api-client';
/** /**
@@ -25,7 +30,12 @@ function parseQuestions(json: unknown): PolicyQuestionDto[] | null {
for (const q of json) { for (const q of json) {
if (typeof q !== 'object' || q === null) return null; if (typeof q !== 'object' || q === null) return null;
const p = q as Partial<PolicyQuestionDto>; const p = q as Partial<PolicyQuestionDto>;
if (typeof p.id !== 'string' || typeof p.vraag !== 'string' || (p.type !== 'ja-nee' && p.type !== 'tekst')) return null; if (
typeof p.id !== 'string' ||
typeof p.vraag !== 'string' ||
(p.type !== 'ja-nee' && p.type !== 'tekst')
)
return null;
out.push({ id: p.id, vraag: p.vraag, type: p.type }); out.push({ id: p.id, vraag: p.vraag, type: p.type });
} }
return out; return out;
@@ -43,10 +53,22 @@ export function parseDuoLookup(json: unknown): Result<string, DuoLookupDto> {
if (typeof item !== 'object' || item === null) return err('duo-lookup: invalid diploma'); if (typeof item !== 'object' || item === null) return err('duo-lookup: invalid diploma');
const d = item as Partial<DuoDiplomaDto>; const d = item as Partial<DuoDiplomaDto>;
const vragen = parseQuestions(d.policyQuestions); const vragen = parseQuestions(d.policyQuestions);
if (typeof d.id !== 'string' || typeof d.naam !== 'string' || typeof d.beroep !== 'string' || vragen === null) { if (
typeof d.id !== 'string' ||
typeof d.naam !== 'string' ||
typeof d.beroep !== 'string' ||
vragen === null
) {
return err('duo-lookup: missing/invalid diploma fields'); return err('duo-lookup: missing/invalid diploma fields');
} }
diplomas.push({ id: d.id, naam: d.naam, instelling: d.instelling ?? '', jaar: typeof d.jaar === 'number' ? d.jaar : 0, beroep: d.beroep, policyQuestions: vragen }); diplomas.push({
id: d.id,
naam: d.naam,
instelling: d.instelling ?? '',
jaar: typeof d.jaar === 'number' ? d.jaar : 0,
beroep: d.beroep,
policyQuestions: vragen,
});
} }
const hm = dto.handmatig; const hm = dto.handmatig;

View File

@@ -13,9 +13,16 @@ import { blockActions } from '@registratie/domain/block-actions';
@Component({ @Component({
selector: 'app-aanvraag-block', selector: 'app-aanvraag-block',
imports: [ButtonComponent, AlertComponent], imports: [ButtonComponent, AlertComponent],
styles: [` styles: [
.actions{display:flex;align-items:center;gap:var(--rhc-space-max-md);margin-block-start:var(--rhc-space-max-sm)} `
`], .actions {
display: flex;
align-items: center;
gap: var(--rhc-space-max-md);
margin-block-start: var(--rhc-space-max-sm);
}
`,
],
template: ` template: `
@if (aanvraag().status.tag === 'Concept') { @if (aanvraag().status.tag === 'Concept') {
<app-alert type="warning"> <app-alert type="warning">
@@ -23,10 +30,14 @@ import { blockActions } from '@registratie/domain/block-actions';
<p>{{ conceptText() }}</p> <p>{{ conceptText() }}</p>
<div class="actions"> <div class="actions">
@if (actions().includes('cancel')) { @if (actions().includes('cancel')) {
<app-button variant="subtle" (click)="cancel.emit()" i18n="@@aanvraagBlock.verwijderen">Verwijderen</app-button> <app-button variant="subtle" (click)="cancel.emit()" i18n="@@aanvraagBlock.verwijderen"
>Verwijderen</app-button
>
} }
@if (actions().includes('resume')) { @if (actions().includes('resume')) {
<app-button (click)="resume.emit()" i18n="@@aanvraagBlock.openen">Aanvraag openen</app-button> <app-button (click)="resume.emit()" i18n="@@aanvraagBlock.openen"
>Aanvraag openen</app-button
>
} }
</div> </div>
</app-alert> </app-alert>
@@ -59,5 +70,7 @@ export class AanvraagBlockComponent {
} }
function formatNL(iso?: string): string { function formatNL(iso?: string): string {
return iso ? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' }) : ''; return iso
? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })
: '';
} }

View File

@@ -38,7 +38,36 @@ export const Concept: Story = {
args: { aanvraag: { ...base, status: { tag: 'Concept', stepIndex: 1, stepCount: 3 } } }, args: { aanvraag: { ...base, status: { tag: 'Concept', stepIndex: 1, stepCount: 3 } } },
render: (args) => ({ props: args, template: `<app-aanvraag-block [aanvraag]="aanvraag" />` }), render: (args) => ({ props: args, template: `<app-aanvraag-block [aanvraag]="aanvraag" />` }),
}; };
export const InBehandelingAuto: Story = { args: { aanvraag: { ...base, status: { tag: 'InBehandeling', referentie: 'BIG-2026-456789', manual: false } } } }; export const InBehandelingAuto: Story = {
export const InBehandelingManual: Story = { args: { aanvraag: { ...base, type: 'registratie', status: { tag: 'InBehandeling', referentie: 'BIG-2026-456789', manual: true } } } }; args: {
export const Goedgekeurd: Story = { args: { aanvraag: { ...base, status: { tag: 'Goedgekeurd', referentie: 'BIG-2026-456789' } } } }; aanvraag: {
export const Afgewezen: Story = { args: { aanvraag: { ...base, type: 'herregistratie', status: { tag: 'Afgewezen', referentie: 'BIG-2026-456789', reden: 'Aanvraag afgewezen: geen gewerkte uren geregistreerd.' } } } }; ...base,
status: { tag: 'InBehandeling', referentie: 'BIG-2026-456789', manual: false },
},
},
};
export const InBehandelingManual: Story = {
args: {
aanvraag: {
...base,
type: 'registratie',
status: { tag: 'InBehandeling', referentie: 'BIG-2026-456789', manual: true },
},
},
};
export const Goedgekeurd: Story = {
args: { aanvraag: { ...base, status: { tag: 'Goedgekeurd', referentie: 'BIG-2026-456789' } } },
};
export const Afgewezen: Story = {
args: {
aanvraag: {
...base,
type: 'herregistratie',
status: {
tag: 'Afgewezen',
referentie: 'BIG-2026-456789',
reden: 'Aanvraag afgewezen: geen gewerkte uren geregistreerd.',
},
},
},
};

View File

@@ -15,14 +15,28 @@ import { detailRows } from '@registratie/domain/aanvraag-view';
handling is future work. The dashboard "Mijn aanvragen" rows link here. */ handling is future work. The dashboard "Mijn aanvragen" rows link here. */
@Component({ @Component({
selector: 'app-aanvraag-detail-page', selector: 'app-aanvraag-detail-page',
imports: [PageShellComponent, SkeletonComponent, AlertComponent, DataBlockComponent, DataRowComponent, ...ASYNC], imports: [
PageShellComponent,
SkeletonComponent,
AlertComponent,
DataBlockComponent,
DataRowComponent,
...ASYNC,
],
template: ` template: `
<app-page-shell i18n-heading="@@aanvraagDetail.heading" heading="Aanvraag" backLink="/dashboard"> <app-page-shell
i18n-heading="@@aanvraagDetail.heading"
heading="Aanvraag"
backLink="/dashboard"
>
<app-async [data]="store.applications()"> <app-async [data]="store.applications()">
<ng-template appAsyncLoaded let-list> <ng-template appAsyncLoaded let-list>
@let a = find($any(list)); @let a = find($any(list));
@if (a) { @if (a) {
<app-data-block i18n-ariaLabel="@@aanvraagDetail.ariaLabel" ariaLabel="Aanvraaggegevens"> <app-data-block
i18n-ariaLabel="@@aanvraagDetail.ariaLabel"
ariaLabel="Aanvraaggegevens"
>
@for (row of rows(a); track row.key) { @for (row of rows(a); track row.key) {
<div app-data-row [key]="row.key" [value]="row.value"></div> <div app-data-row [key]="row.key" [value]="row.value"></div>
} }
@@ -31,7 +45,9 @@ import { detailRows } from '@registratie/domain/aanvraag-view';
De volledige afhandeling van deze aanvraag is nog niet beschikbaar in deze POC. De volledige afhandeling van deze aanvraag is nog niet beschikbaar in deze POC.
</app-alert> </app-alert>
} @else { } @else {
<app-alert type="warning" i18n="@@aanvraagDetail.nietGevonden">Deze aanvraag is niet gevonden.</app-alert> <app-alert type="warning" i18n="@@aanvraagDetail.nietGevonden"
>Deze aanvraag is niet gevonden.</app-alert
>
} }
</ng-template> </ng-template>
<ng-template appAsyncLoading> <ng-template appAsyncLoading>

View File

@@ -19,27 +19,73 @@ export type AdresErrors = Partial<Record<keyof AdresValue, string>>;
@Component({ @Component({
selector: 'app-address-fields', selector: 'app-address-fields',
imports: [FormsModule, FormFieldComponent, TextInputComponent], imports: [FormsModule, FormFieldComponent, TextInputComponent],
styles: [` styles: [
fieldset{border:0;margin:0;padding:0;min-inline-size:0} `
legend{padding:0;font-weight:var(--rhc-text-font-weight-semi-bold);margin-block-end:var(--rhc-space-max-md)} fieldset {
`], border: 0;
margin: 0;
padding: 0;
min-inline-size: 0;
}
legend {
padding: 0;
font-weight: var(--rhc-text-font-weight-semi-bold);
margin-block-end: var(--rhc-space-max-md);
}
`,
],
template: ` template: `
<fieldset> <fieldset>
<legend>{{ legend() }}</legend> <legend>{{ legend() }}</legend>
<app-form-field i18n-label="@@address.straat" label="Straat en huisnummer" [fieldId]="idPrefix() + '-straat'" required [error]="errors().straat"> <app-form-field
<app-text-input [inputId]="idPrefix() + '-straat'" [invalid]="!!errors().straat" i18n-label="@@address.straat"
[ngModel]="value().straat" (ngModelChange)="fieldChange.emit({ key: 'straat', value: $event })" label="Straat en huisnummer"
name="straat" [ngModelOptions]="{ standalone: true }" /> [fieldId]="idPrefix() + '-straat'"
required
[error]="errors().straat"
>
<app-text-input
[inputId]="idPrefix() + '-straat'"
[invalid]="!!errors().straat"
[ngModel]="value().straat"
(ngModelChange)="fieldChange.emit({ key: 'straat', value: $event })"
name="straat"
[ngModelOptions]="{ standalone: true }"
/>
</app-form-field> </app-form-field>
<app-form-field i18n-label="@@address.postcode" label="Postcode" [fieldId]="idPrefix() + '-postcode'" required [error]="errors().postcode"> <app-form-field
<app-text-input [inputId]="idPrefix() + '-postcode'" [invalid]="!!errors().postcode" i18n-label="@@address.postcode"
[ngModel]="value().postcode" (ngModelChange)="fieldChange.emit({ key: 'postcode', value: $event })" label="Postcode"
name="postcode" i18n-placeholder="@@address.postcodePlaceholder" placeholder="1234 AB" [ngModelOptions]="{ standalone: true }" /> [fieldId]="idPrefix() + '-postcode'"
required
[error]="errors().postcode"
>
<app-text-input
[inputId]="idPrefix() + '-postcode'"
[invalid]="!!errors().postcode"
[ngModel]="value().postcode"
(ngModelChange)="fieldChange.emit({ key: 'postcode', value: $event })"
name="postcode"
i18n-placeholder="@@address.postcodePlaceholder"
placeholder="1234 AB"
[ngModelOptions]="{ standalone: true }"
/>
</app-form-field> </app-form-field>
<app-form-field i18n-label="@@address.woonplaats" label="Woonplaats" [fieldId]="idPrefix() + '-woonplaats'" required [error]="errors().woonplaats"> <app-form-field
<app-text-input [inputId]="idPrefix() + '-woonplaats'" [invalid]="!!errors().woonplaats" i18n-label="@@address.woonplaats"
[ngModel]="value().woonplaats" (ngModelChange)="fieldChange.emit({ key: 'woonplaats', value: $event })" label="Woonplaats"
name="woonplaats" [ngModelOptions]="{ standalone: true }" /> [fieldId]="idPrefix() + '-woonplaats'"
required
[error]="errors().woonplaats"
>
<app-text-input
[inputId]="idPrefix() + '-woonplaats'"
[invalid]="!!errors().woonplaats"
[ngModel]="value().woonplaats"
(ngModelChange)="fieldChange.emit({ key: 'woonplaats', value: $event })"
name="woonplaats"
[ngModelOptions]="{ standalone: true }"
/>
</app-form-field> </app-form-field>
</fieldset> </fieldset>
`, `,

View File

@@ -20,12 +20,18 @@ const empty = { straat: '', postcode: '', woonplaats: '' };
export const Default: Story = { args: { value: empty, errors: {} } }; export const Default: Story = { args: { value: empty, errors: {} } };
export const Prefilled: Story = { export const Prefilled: Story = {
args: { value: { straat: 'Stationsplein 1', postcode: '3511 ED', woonplaats: 'Utrecht' }, errors: {} }, args: {
value: { straat: 'Stationsplein 1', postcode: '3511 ED', woonplaats: 'Utrecht' },
errors: {},
},
}; };
export const WithErrors: Story = { export const WithErrors: Story = {
args: { args: {
value: { straat: '', postcode: '12', woonplaats: '' }, value: { straat: '', postcode: '12', woonplaats: '' },
errors: { straat: 'Vul een straat en huisnummer in.', postcode: 'Voer een geldige postcode in, bijv. 1234 AB.' }, errors: {
straat: 'Vul een straat en huisnummer in.',
postcode: 'Voer een geldige postcode in, bijv. 1234 AB.',
},
}, },
}; };

View File

@@ -3,7 +3,11 @@ import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/alert/alert.component';
import { AddressFieldsComponent, AdresValue, AdresErrors } from '@registratie/ui/address-fields/address-fields.component'; import {
AddressFieldsComponent,
AdresValue,
AdresErrors,
} from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp'; import { whenTag } from '@shared/kernel/fp';
import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine'; import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine';
@@ -21,25 +25,37 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang
template: ` template: `
@if (state().tag === 'Submitted') { @if (state().tag === 'Submitted') {
<app-alert type="ok" i18n="@@changeRequest.success"> <app-alert type="ok" i18n="@@changeRequest.success">
Uw adreswijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5 werkdagen bericht. Uw adreswijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5
werkdagen bericht.
</app-alert> </app-alert>
<div class="app-section"> <div class="app-section">
<app-button variant="secondary" (click)="dispatch({ tag: 'Reset' })" i18n="@@changeRequest.nieuwe">Nieuwe wijziging doorgeven</app-button> <app-button
variant="secondary"
(click)="dispatch({ tag: 'Reset' })"
i18n="@@changeRequest.nieuwe"
>Nieuwe wijziging doorgeven</app-button
>
</div> </div>
} @else { } @else {
<app-heading [level]="2" i18n="@@changeRequest.heading">Adreswijziging doorgeven</app-heading> <app-heading [level]="2" i18n="@@changeRequest.heading">Adreswijziging doorgeven</app-heading>
<form (ngSubmit)="onSubmit()" class="form-horizontal app-section"> <form (ngSubmit)="onSubmit()" class="form-horizontal app-section">
<div class="form-header"> <div class="form-header">
<div class="form-action"><span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span></div> <div class="form-action">
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
</div>
</div> </div>
<app-address-fields <app-address-fields
idPrefix="cr" idPrefix="cr"
[value]="adres()" [value]="adres()"
[errors]="errors()" [errors]="errors()"
(fieldChange)="dispatch({ tag: 'SetField', key: $event.key, value: $event.value })" /> (fieldChange)="dispatch({ tag: 'SetField', key: $event.key, value: $event.value })"
/>
@if (failedError()) { @if (failedError()) {
<app-alert type="error"><ng-container i18n="@@changeRequest.failed">Het indienen is niet gelukt:</ng-container> {{ failedError() }}</app-alert> <app-alert type="error"
><ng-container i18n="@@changeRequest.failed">Het indienen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
} }
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'"> <app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">

View File

@@ -5,7 +5,11 @@ import { ChangeRequestFormComponent } from './change-request-form.component';
import { provideApiClient } from '@shared/infrastructure/api-client.provider'; import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { Postcode } from '@registratie/domain/value-objects/postcode'; import { Postcode } from '@registratie/domain/value-objects/postcode';
const validData = { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' }; const validData = {
straat: 'Lange Voorhout 9',
postcode: '2514 EA' as Postcode,
woonplaats: 'Den Haag',
};
const meta: Meta<ChangeRequestFormComponent> = { const meta: Meta<ChangeRequestFormComponent> = {
title: 'Organisms/Change Request Form', title: 'Organisms/Change Request Form',
@@ -17,16 +21,27 @@ export default meta;
type Story = StoryObj<ChangeRequestFormComponent>; type Story = StoryObj<ChangeRequestFormComponent>;
// One render per state of the machine. // One render per state of the machine.
export const Empty: Story = { args: { seed: { tag: 'Editing', draft: { straat: '', postcode: '', woonplaats: '' }, errors: {} } } }; export const Empty: Story = {
args: {
seed: { tag: 'Editing', draft: { straat: '', postcode: '', woonplaats: '' }, errors: {} },
},
};
export const WithErrors: Story = { export const WithErrors: Story = {
args: { args: {
seed: { seed: {
tag: 'Editing', tag: 'Editing',
draft: { straat: '', postcode: 'nope', woonplaats: '' }, draft: { straat: '', postcode: 'nope', woonplaats: '' },
errors: { straat: 'Vul straat en huisnummer in.', postcode: 'Voer een geldige postcode in, bijv. 1234 AB.' }, errors: {
straat: 'Vul straat en huisnummer in.',
postcode: 'Voer een geldige postcode in, bijv. 1234 AB.',
},
}, },
}, },
}; };
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } }; export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData, referentie: 'BIG-2026-123456' } } }; export const Submitted: Story = {
export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } }; args: { seed: { tag: 'Submitted', data: validData, referentie: 'BIG-2026-123456' } },
};
export const Failed: Story = {
args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } },
};

View File

@@ -25,25 +25,55 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
@Component({ @Component({
selector: 'app-dashboard-page', selector: 'app-dashboard-page',
imports: [ imports: [
PageShellComponent, HeadingComponent, AlertComponent, SkeletonComponent, PageShellComponent,
DataRowComponent, DataBlockComponent, TaskListComponent, ApplicationListComponent, ApplicationLinkComponent, HeadingComponent,
AlertComponent,
SkeletonComponent,
DataRowComponent,
DataBlockComponent,
TaskListComponent,
ApplicationListComponent,
ApplicationLinkComponent,
...ASYNC, ...ASYNC,
RegistrationSummaryComponent, RegistrationTableComponent, AanvraagBlockComponent, RegistrationSummaryComponent,
RegistrationTableComponent,
AanvraagBlockComponent,
], ],
template: ` template: `
<app-page-shell i18n-heading="@@dashboard.heading" heading="Mijn overzicht" i18n-intro="@@dashboard.intro" intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken."> <app-page-shell
i18n-heading="@@dashboard.heading"
heading="Mijn overzicht"
i18n-intro="@@dashboard.intro"
intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken."
>
<div class="app-stack"> <div class="app-stack">
@if (aanvragen().length) { @if (aanvragen().length) {
<section> <section>
@for (a of concepten(); track a.id) { @for (a of concepten(); track a.id) {
<app-aanvraag-block animate.enter="app-item-enter" animate.leave="app-item-leave" [aanvraag]="a" (resume)="resume(a)" (cancel)="cancelAanvraag(a)" /> <app-aanvraag-block
animate.enter="app-item-enter"
animate.leave="app-item-leave"
[aanvraag]="a"
(resume)="resume(a)"
(cancel)="cancelAanvraag(a)"
/>
} }
@if (ingediend().length) { @if (ingediend().length) {
<app-heading [level]="2" class="app-section" i18n="@@dashboard.mijnAanvragen">Mijn aanvragen</app-heading> <app-heading [level]="2" class="app-section" i18n="@@dashboard.mijnAanvragen"
>Mijn aanvragen</app-heading
>
<app-application-list> <app-application-list>
@for (a of ingediend(); track a.id) { @for (a of ingediend(); track a.id) {
@let row = submittedRow(a); @let row = submittedRow(a);
<li app-application-link animate.enter="app-item-enter" animate.leave="app-item-leave" [heading]="row.heading" [subtitle]="row.subtitle" [status]="row.status" [to]="'/aanvraag/' + a.id"></li> <li
app-application-link
animate.enter="app-item-enter"
animate.leave="app-item-leave"
[heading]="row.heading"
[subtitle]="row.subtitle"
[status]="row.status"
[to]="'/aanvraag/' + a.id"
></li>
} }
</app-application-list> </app-application-list>
} }
@@ -51,7 +81,9 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
} }
@if (store.pendingHerregistratie()) { @if (store.pendingHerregistratie()) {
<app-alert type="info" i18n="@@dashboard.pendingHerregistratie">Uw herregistratie-aanvraag is in behandeling.</app-alert> <app-alert type="info" i18n="@@dashboard.pendingHerregistratie"
>Uw herregistratie-aanvraag is in behandeling.</app-alert
>
} }
<app-async [data]="store.profile()"> <app-async [data]="store.profile()">
@@ -60,22 +92,52 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
<section> <section>
@if (tasks.length) { @if (tasks.length) {
<app-task-list class="app-section" i18n-listHeading="@@dashboard.watMoetIkRegelen" listHeading="Wat moet ik regelen" [tasks]="tasks" /> <app-task-list
class="app-section"
i18n-listHeading="@@dashboard.watMoetIkRegelen"
listHeading="Wat moet ik regelen"
[tasks]="tasks"
/>
} @else { } @else {
<app-heading [level]="2" i18n="@@dashboard.watMoetIkRegelen">Wat moet ik regelen</app-heading> <app-heading [level]="2" i18n="@@dashboard.watMoetIkRegelen"
<p class="app-text-subtle" i18n="@@dashboard.nietsOpenstaan">U heeft op dit moment niets openstaan.</p> >Wat moet ik regelen</app-heading
>
<p class="app-text-subtle" i18n="@@dashboard.nietsOpenstaan">
U heeft op dit moment niets openstaan.
</p>
} }
</section> </section>
<section> <section>
<app-heading [level]="2" i18n="@@dashboard.mijnRegistratie">Mijn registratie</app-heading> <app-heading [level]="2" i18n="@@dashboard.mijnRegistratie"
>Mijn registratie</app-heading
>
<div class="app-section"> <div class="app-section">
<app-registration-summary [reg]="$any(p).registration" /> <app-registration-summary [reg]="$any(p).registration" />
</div> </div>
<app-data-block class="app-section" i18n-heading="@@dashboard.persoonsgegevens" heading="Persoonsgegevens (BRP)"> <app-data-block
<div app-data-row i18n-key="@@dashboard.straat" key="Straat" [value]="$any(p).person.adres.straat"></div> class="app-section"
<div app-data-row i18n-key="@@dashboard.postcode" key="Postcode" [value]="$any(p).person.adres.postcode"></div> i18n-heading="@@dashboard.persoonsgegevens"
<div app-data-row i18n-key="@@dashboard.woonplaats" key="Woonplaats" [value]="$any(p).person.adres.woonplaats"></div> heading="Persoonsgegevens (BRP)"
>
<div
app-data-row
i18n-key="@@dashboard.straat"
key="Straat"
[value]="$any(p).person.adres.straat"
></div>
<div
app-data-row
i18n-key="@@dashboard.postcode"
key="Postcode"
[value]="$any(p).person.adres.postcode"
></div>
<div
app-data-row
i18n-key="@@dashboard.woonplaats"
key="Woonplaats"
[value]="$any(p).person.adres.woonplaats"
></div>
</app-data-block> </app-data-block>
</section> </section>
</ng-template> </ng-template>
@@ -85,7 +147,9 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
</app-async> </app-async>
<section> <section>
<app-heading [level]="2" i18n="@@dashboard.specialismen">Specialismen en aantekeningen</app-heading> <app-heading [level]="2" i18n="@@dashboard.specialismen"
>Specialismen en aantekeningen</app-heading
>
<div class="app-section"> <div class="app-section">
<app-async [data]="store.aantekeningen()"> <app-async [data]="store.aantekeningen()">
<ng-template appAsyncLoaded let-r> <ng-template appAsyncLoaded let-r>
@@ -95,7 +159,9 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
<app-skeleton height="2.5rem" [count]="3" /> <app-skeleton height="2.5rem" [count]="3" />
</ng-template> </ng-template>
<ng-template appAsyncEmpty> <ng-template appAsyncEmpty>
<p class="app-text-subtle" i18n="@@dashboard.geenSpecialismen">U heeft nog geen specialismen of aantekeningen.</p> <p class="app-text-subtle" i18n="@@dashboard.geenSpecialismen">
U heeft nog geen specialismen of aantekeningen.
</p>
</ng-template> </ng-template>
</app-async> </app-async>
</div> </div>
@@ -105,7 +171,13 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
<app-heading [level]="2" i18n="@@dashboard.watWiltUDoen">Wat wilt u doen?</app-heading> <app-heading [level]="2" i18n="@@dashboard.watWiltUDoen">Wat wilt u doen?</app-heading>
<app-application-list class="app-section"> <app-application-list class="app-section">
@for (a of acties; track a.to) { @for (a of acties; track a.to) {
<li app-application-link [heading]="a.titel" [subtitle]="a.tekst" [cta]="a.actie" [to]="a.to"></li> <li
app-application-link
[heading]="a.titel"
[subtitle]="a.tekst"
[cta]="a.actie"
[to]="a.to"
></li>
} }
</app-application-list> </app-application-list>
</section> </section>
@@ -132,7 +204,12 @@ export class DashboardPage {
protected aanvragen = computed<Aanvraag[]>(() => { protected aanvragen = computed<Aanvraag[]>(() => {
const rd = this.apps.applications(); const rd = this.apps.applications();
if (rd.tag !== 'Success') return []; if (rd.tag !== 'Success') return [];
const order: Record<Aanvraag['status']['tag'], number> = { Concept: 0, InBehandeling: 1, Goedgekeurd: 2, Afgewezen: 2 }; const order: Record<Aanvraag['status']['tag'], number> = {
Concept: 0,
InBehandeling: 1,
Goedgekeurd: 2,
Afgewezen: 2,
};
return rd.value.slice().sort((a, b) => order[a.status.tag] - order[b.status.tag]); return rd.value.slice().sort((a, b) => order[a.status.tag] - order[b.status.tag]);
}); });
/** A Concept ("lopende aanvraag") renders as a melding above the list; the rest /** A Concept ("lopende aanvraag") renders as a melding above the list; the rest
@@ -166,11 +243,41 @@ export class DashboardPage {
componenten/aanvragen). The core portal sections live in the header nav now; componenten/aanvragen). The core portal sections live in the header nav now;
the teaching pages (concepts/brief) are only reachable from here. */ the teaching pages (concepts/brief) are only reachable from here. */
protected readonly acties = [ protected readonly acties = [
{ to: '/registreren', titel: $localize`:@@dashboard.actie.inschrijven.titel:Inschrijven`, tekst: $localize`:@@dashboard.actie.inschrijven.tekst:Schrijf u in in het BIG-register via de registratiewizard.`, actie: $localize`:@@dashboard.actie.inschrijven.actie:Start inschrijving` }, {
{ to: '/herregistratie', titel: $localize`:@@dashboard.actie.herregistratie.titel:Herregistratie aanvragen`, tekst: $localize`:@@dashboard.actie.herregistratie.tekst:Verleng uw registratie voor de komende periode.`, actie: $localize`:@@dashboard.actie.herregistratie.actie:Vraag aan` }, to: '/registreren',
{ to: '/intake', titel: $localize`:@@dashboard.actie.intake.titel:Herregistratie-intake`, tekst: $localize`:@@dashboard.actie.intake.tekst:Vragenlijst met vertakkingen.`, actie: $localize`:@@dashboard.actie.intake.actie:Start intake` }, titel: $localize`:@@dashboard.actie.inschrijven.titel:Inschrijven`,
{ to: '/registratie', titel: $localize`:@@dashboard.actie.wijzigen.titel:Gegevens wijzigen`, tekst: $localize`:@@dashboard.actie.wijzigen.tekst:Bekijk uw gegevens of geef een wijziging door.`, actie: $localize`:@@dashboard.actie.wijzigen.actie:Bekijk gegevens` }, tekst: $localize`:@@dashboard.actie.inschrijven.tekst:Schrijf u in in het BIG-register via de registratiewizard.`,
{ to: '/concepts', titel: $localize`:@@dashboard.actie.concepten.titel:Functionele patronen`, tekst: $localize`:@@dashboard.actie.concepten.tekst:Bekijk de FP/TEA-bouwstenen van deze POC.`, actie: $localize`:@@dashboard.actie.concepten.actie:Bekijk patronen` }, actie: $localize`:@@dashboard.actie.inschrijven.actie:Start inschrijving`,
{ to: '/brief', titel: $localize`:@@dashboard.actie.brief.titel:Brief opstellen`, tekst: $localize`:@@dashboard.actie.brief.tekst:Stel een brief samen uit vaste en vrije onderdelen.`, actie: $localize`:@@dashboard.actie.brief.actie:Start brief` }, },
{
to: '/herregistratie',
titel: $localize`:@@dashboard.actie.herregistratie.titel:Herregistratie aanvragen`,
tekst: $localize`:@@dashboard.actie.herregistratie.tekst:Verleng uw registratie voor de komende periode.`,
actie: $localize`:@@dashboard.actie.herregistratie.actie:Vraag aan`,
},
{
to: '/intake',
titel: $localize`:@@dashboard.actie.intake.titel:Herregistratie-intake`,
tekst: $localize`:@@dashboard.actie.intake.tekst:Vragenlijst met vertakkingen.`,
actie: $localize`:@@dashboard.actie.intake.actie:Start intake`,
},
{
to: '/registratie',
titel: $localize`:@@dashboard.actie.wijzigen.titel:Gegevens wijzigen`,
tekst: $localize`:@@dashboard.actie.wijzigen.tekst:Bekijk uw gegevens of geef een wijziging door.`,
actie: $localize`:@@dashboard.actie.wijzigen.actie:Bekijk gegevens`,
},
{
to: '/concepts',
titel: $localize`:@@dashboard.actie.concepten.titel:Functionele patronen`,
tekst: $localize`:@@dashboard.actie.concepten.tekst:Bekijk de FP/TEA-bouwstenen van deze POC.`,
actie: $localize`:@@dashboard.actie.concepten.actie:Bekijk patronen`,
},
{
to: '/brief',
titel: $localize`:@@dashboard.actie.brief.titel:Brief opstellen`,
tekst: $localize`:@@dashboard.actie.brief.tekst:Stel een brief samen uit vaste en vrije onderdelen.`,
actie: $localize`:@@dashboard.actie.brief.actie:Start brief`,
},
]; ];
} }

View File

@@ -9,7 +9,12 @@ import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component'; import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component'; import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import { WizardShellComponent, WizardError, WizardStatus, naarStapLabel } from '@shared/layout/wizard-shell/wizard-shell.component'; import {
WizardShellComponent,
WizardError,
WizardStatus,
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/async/async.component';
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component'; import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
@@ -54,28 +59,41 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
@Component({ @Component({
selector: 'app-registratie-wizard', selector: 'app-registratie-wizard',
imports: [ imports: [
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, FormsModule,
AlertComponent, SkeletonComponent, DataRowComponent, ReviewSectionComponent, ConfirmationComponent, WizardShellComponent, FormFieldComponent,
AddressFieldsComponent, DocumentUploadComponent, ...ASYNC, TextInputComponent,
RadioGroupComponent,
ButtonComponent,
AlertComponent,
SkeletonComponent,
DataRowComponent,
ReviewSectionComponent,
ConfirmationComponent,
WizardShellComponent,
AddressFieldsComponent,
DocumentUploadComponent,
...ASYNC,
], ],
template: ` template: `
<app-wizard-shell <app-wizard-shell
[steps]="stepLabels" [steps]="stepLabels"
[current]="cursor()" [current]="cursor()"
[stepTitle]="stepTitle()" [stepTitle]="stepTitle()"
i18n-processName="@@regWizard.processName" processName="Inschrijven in het BIG-register" i18n-processName="@@regWizard.processName"
processName="Inschrijven in het BIG-register"
[status]="shellStatus()" [status]="shellStatus()"
[primaryLabel]="primaryLabel()" [primaryLabel]="primaryLabel()"
[canGoBack]="cursor() > 0" [canGoBack]="cursor() > 0"
[errors]="errorList()" [errors]="errorList()"
[errorMessage]="errorMessage()" [errorMessage]="errorMessage()"
i18n-submittingLabel="@@regWizard.submitting" submittingLabel="Uw registratie wordt verwerkt…" i18n-submittingLabel="@@regWizard.submitting"
submittingLabel="Uw registratie wordt verwerkt…"
(primary)="onPrimary()" (primary)="onPrimary()"
(back)="dispatch({ tag: 'Back' })" (back)="dispatch({ tag: 'Back' })"
(cancel)="restart()" (cancel)="restart()"
(retry)="onRetry()" (retry)="onRetry()"
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"> (goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
>
@switch (step()) { @switch (step()) {
@case ('adres') { @case ('adres') {
@if (adresStatus() === 'laden') { @if (adresStatus() === 'laden') {
@@ -83,26 +101,68 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
} @else { } @else {
@switch (adresStatus()) { @switch (adresStatus()) {
@case ('gevonden') { @case ('gevonden') {
<app-alert type="info" i18n="@@regWizard.brpGevonden">Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert> <app-alert type="info" i18n="@@regWizard.brpGevonden"
>Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert
>
} }
@case ('geen') { @case ('geen') {
<app-alert type="warning" i18n="@@regWizard.brpGeen">We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert> <app-alert type="warning" i18n="@@regWizard.brpGeen"
>We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert
>
} }
@case ('fout') { @case ('fout') {
<app-alert type="warning" i18n="@@regWizard.brpFout">We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in.</app-alert> <app-alert type="warning" i18n="@@regWizard.brpFout"
>We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig
in.</app-alert
>
} }
} }
<app-address-fields <app-address-fields
[value]="{ straat: draft().straat ?? '', postcode: draft().postcode ?? '', woonplaats: draft().woonplaats ?? '' }" [value]="{
[errors]="{ straat: err('straat'), postcode: err('postcode'), woonplaats: err('woonplaats') }" straat: draft().straat ?? '',
(fieldChange)="set($event.key, $event.value)" /> postcode: draft().postcode ?? '',
<app-form-field i18n-label="@@regWizard.correspondentieLabel" label="Hoe wilt u correspondentie ontvangen?" fieldId="correspondentie" required [error]="err('correspondentie')"> woonplaats: draft().woonplaats ?? '',
<app-radio-group name="correspondentie" [options]="kanalen" [invalid]="!!err('correspondentie')" }"
[ngModel]="draft().correspondentie ?? ''" (ngModelChange)="setKanaal($event)" /> [errors]="{
straat: err('straat'),
postcode: err('postcode'),
woonplaats: err('woonplaats'),
}"
(fieldChange)="set($event.key, $event.value)"
/>
<app-form-field
i18n-label="@@regWizard.correspondentieLabel"
label="Hoe wilt u correspondentie ontvangen?"
fieldId="correspondentie"
required
[error]="err('correspondentie')"
>
<app-radio-group
name="correspondentie"
[options]="kanalen"
[invalid]="!!err('correspondentie')"
[ngModel]="draft().correspondentie ?? ''"
(ngModelChange)="setKanaal($event)"
/>
</app-form-field> </app-form-field>
@if (draft().correspondentie === 'email') { @if (draft().correspondentie === 'email') {
<app-form-field i18n-label="@@regWizard.emailLabel" label="E-mailadres" fieldId="email" required [error]="err('email')"> <app-form-field
<app-text-input inputId="email" type="email" [invalid]="!!err('email')" [ngModel]="draft().email ?? ''" (ngModelChange)="set('email', $event)" name="email" i18n-placeholder="@@regWizard.emailPlaceholder" placeholder="naam@voorbeeld.nl" /> i18n-label="@@regWizard.emailLabel"
label="E-mailadres"
fieldId="email"
required
[error]="err('email')"
>
<app-text-input
inputId="email"
type="email"
[invalid]="!!err('email')"
[ngModel]="draft().email ?? ''"
(ngModelChange)="set('email', $event)"
name="email"
i18n-placeholder="@@regWizard.emailPlaceholder"
placeholder="naam@voorbeeld.nl"
/>
</app-form-field> </app-form-field>
} }
} }
@@ -110,31 +170,80 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
@case ('beroep') { @case ('beroep') {
<app-async [data]="lookupRd()"> <app-async [data]="lookupRd()">
<ng-template appAsyncLoaded let-data> <ng-template appAsyncLoaded let-data>
<app-form-field i18n-label="@@regWizard.diplomaLabel" label="Kies het diploma waarmee u zich wilt registreren" fieldId="diploma" required [error]="err('diploma')"> <app-form-field
<app-radio-group name="diploma" [options]="diplomaOptions($any(data))" [invalid]="!!err('diploma')" i18n-label="@@regWizard.diplomaLabel"
[ngModel]="diplomaKeuze()" (ngModelChange)="onDiplomaKeuze($any(data), $event)" /> label="Kies het diploma waarmee u zich wilt registreren"
fieldId="diploma"
required
[error]="err('diploma')"
>
<app-radio-group
name="diploma"
[options]="diplomaOptions($any(data))"
[invalid]="!!err('diploma')"
[ngModel]="diplomaKeuze()"
(ngModelChange)="onDiplomaKeuze($any(data), $event)"
/>
</app-form-field> </app-form-field>
@if (handmatigActief()) { @if (handmatigActief()) {
<app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing">Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig beoordeeld.</app-alert> <app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing"
<app-form-field i18n-label="@@regWizard.beroepLabel" label="Voor welk beroep wilt u zich registreren?" fieldId="hm-beroep" [error]="err('diploma')"> >Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw
<app-radio-group name="hm-beroep" [options]="beroepOptions($any(data))" [invalid]="!!err('diploma')" beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig
[ngModel]="draft().beroep ?? ''" (ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })" /> beoordeeld.</app-alert
>
<app-form-field
i18n-label="@@regWizard.beroepLabel"
label="Voor welk beroep wilt u zich registreren?"
fieldId="hm-beroep"
[error]="err('diploma')"
>
<app-radio-group
name="hm-beroep"
[options]="beroepOptions($any(data))"
[invalid]="!!err('diploma')"
[ngModel]="draft().beroep ?? ''"
(ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })"
/>
</app-form-field> </app-form-field>
} @else if (draft().beroep) { } @else if (draft().beroep) {
<dl class="mb-0 app-section"> <dl class="mb-0 app-section">
<div app-data-row i18n-key="@@regWizard.beroepAfgeleid" key="Beroep (afgeleid uit diploma)" [value]="draft().beroep ?? ''"></div> <div
app-data-row
i18n-key="@@regWizard.beroepAfgeleid"
key="Beroep (afgeleid uit diploma)"
[value]="draft().beroep ?? ''"
></div>
</dl> </dl>
} }
@for (q of actieveVragen($any(data)); track q.id) { @for (q of actieveVragen($any(data)); track q.id) {
<app-form-field [label]="q.vraag" [fieldId]="'vraag-' + q.id" [error]="vraagErr(q.id)"> <app-form-field
[label]="q.vraag"
[fieldId]="'vraag-' + q.id"
[error]="vraagErr(q.id)"
>
@if (q.type === 'ja-nee') { @if (q.type === 'ja-nee') {
<app-radio-group [name]="'vraag-' + q.id" [options]="jaNee" [invalid]="!!vraagErr(q.id)" <app-radio-group
[ngModel]="antwoord(q.id)" (ngModelChange)="dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })" [ngModelOptions]="{ standalone: true }" /> [name]="'vraag-' + q.id"
[options]="jaNee"
[invalid]="!!vraagErr(q.id)"
[ngModel]="antwoord(q.id)"
(ngModelChange)="
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
"
[ngModelOptions]="{ standalone: true }"
/>
} @else { } @else {
<app-text-input [inputId]="'vraag-' + q.id" [invalid]="!!vraagErr(q.id)" [ngModel]="antwoord(q.id)" <app-text-input
(ngModelChange)="dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })" [ngModelOptions]="{ standalone: true }" /> [inputId]="'vraag-' + q.id"
[invalid]="!!vraagErr(q.id)"
[ngModel]="antwoord(q.id)"
(ngModelChange)="
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
"
[ngModelOptions]="{ standalone: true }"
/>
} }
</app-form-field> </app-form-field>
} }
@@ -152,28 +261,70 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
(removeUpload)="uploadCtl.onRemove($event)" (removeUpload)="uploadCtl.onRemove($event)"
(retryUpload)="uploadCtl.onRetry($event)" (retryUpload)="uploadCtl.onRetry($event)"
(deleteUpload)="uploadCtl.onDelete($event)" (deleteUpload)="uploadCtl.onDelete($event)"
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)" /> (channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)"
/>
@if (err('documenten')) { @if (err('documenten')) {
<app-alert type="warning">{{ err('documenten') }}</app-alert> <app-alert type="warning">{{ err('documenten') }}</app-alert>
} }
} }
@case ('controle') { @case ('controle') {
<app-alert type="info" i18n="@@regWizard.controleer">Controleer uw gegevens en dien de registratie in.</app-alert> <app-alert type="info" i18n="@@regWizard.controleer"
<app-review-section i18n-heading="@@regWizard.sectie.adres" heading="Adres en correspondentie" >Controleer uw gegevens en dien de registratie in.</app-alert
i18n-editAriaLabel="@@regWizard.adresWijzigenAria" editAriaLabel="Wijzigen adresgegevens" >
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"> <app-review-section
<div app-data-row i18n-key="@@regWizard.summary.adres" key="Adres" [value]="adresSamenvatting()"></div> i18n-heading="@@regWizard.sectie.adres"
<div app-data-row i18n-key="@@regWizard.summary.herkomstAdres" key="Herkomst adres" [value]="adresHerkomstLabel()"></div> heading="Adres en correspondentie"
<div app-data-row i18n-key="@@regWizard.summary.correspondentie" key="Correspondentie" [value]="correspondentieLabel()"></div> i18n-editAriaLabel="@@regWizard.adresWijzigenAria"
editAriaLabel="Wijzigen adresgegevens"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
>
<div
app-data-row
i18n-key="@@regWizard.summary.adres"
key="Adres"
[value]="adresSamenvatting()"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.herkomstAdres"
key="Herkomst adres"
[value]="adresHerkomstLabel()"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.correspondentie"
key="Correspondentie"
[value]="correspondentieLabel()"
></div>
@if (draft().correspondentie === 'email') { @if (draft().correspondentie === 'email') {
<div app-data-row i18n-key="@@regWizard.summary.email" key="E-mailadres" [value]="draft().email ?? ''"></div> <div
app-data-row
i18n-key="@@regWizard.summary.email"
key="E-mailadres"
[value]="draft().email ?? ''"
></div>
} }
</app-review-section> </app-review-section>
<app-review-section class="app-section" i18n-heading="@@regWizard.sectie.beroep" heading="Beroep en diploma" <app-review-section
i18n-editAriaLabel="@@regWizard.diplomaWijzigenAria" editAriaLabel="Wijzigen beroep en diploma" class="app-section"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"> i18n-heading="@@regWizard.sectie.beroep"
<div app-data-row i18n-key="@@regWizard.summary.beroep" key="Beroep" [value]="draft().beroep ?? ''"></div> heading="Beroep en diploma"
<div app-data-row i18n-key="@@regWizard.summary.herkomstDiploma" key="Herkomst diploma" [value]="diplomaHerkomstLabel()"></div> i18n-editAriaLabel="@@regWizard.diplomaWijzigenAria"
editAriaLabel="Wijzigen beroep en diploma"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
>
<div
app-data-row
i18n-key="@@regWizard.summary.beroep"
key="Beroep"
[value]="draft().beroep ?? ''"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.herkomstDiploma"
key="Herkomst diploma"
[value]="diplomaHerkomstLabel()"
></div>
@for (item of samenvattingVragen(); track item.vraag) { @for (item of samenvattingVragen(); track item.vraag) {
<div app-data-row [key]="item.vraag" [value]="item.antwoord"></div> <div app-data-row [key]="item.vraag" [value]="item.antwoord"></div>
} }
@@ -182,10 +333,17 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
} }
<div wizardSuccess> <div wizardSuccess>
<app-confirmation i18n-title="@@regWizard.success.title" title="Uw registratie is ontvangen"> <app-confirmation
<p class="app-section" i18n="@@regWizard.success.referentie">Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.</p> i18n-title="@@regWizard.success.title"
title="Uw registratie is ontvangen"
>
<p class="app-section" i18n="@@regWizard.success.referentie">
Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.
</p>
<div class="app-section"> <div class="app-section">
<app-button variant="secondary" (click)="restart()" i18n="@@regWizard.nieuweRegistratie">Nieuwe registratie starten</app-button> <app-button variant="secondary" (click)="restart()" i18n="@@regWizard.nieuweRegistratie"
>Nieuwe registratie starten</app-button
>
</div> </div>
</app-confirmation> </app-confirmation>
</div> </div>
@@ -206,8 +364,16 @@ export class RegistratieWizardComponent {
seed = input<RegistratieState>(initial); seed = input<RegistratieState>(initial);
readonly kanalen = KANALEN; readonly kanalen = KANALEN;
readonly stepLabels = [$localize`:@@regWizard.step.adres:Adres`, $localize`:@@regWizard.step.beroep:Beroep`, $localize`:@@regWizard.step.controle:Controle`]; // short labels for the stepper readonly stepLabels = [
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`]; $localize`:@@regWizard.step.adres:Adres`,
$localize`:@@regWizard.step.beroep:Beroep`,
$localize`:@@regWizard.step.controle:Controle`,
]; // short labels for the stepper
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`,
];
readonly state = this.store.model; readonly state = this.store.model;
readonly dispatch = this.store.dispatch; readonly dispatch = this.store.dispatch;
@@ -234,14 +400,18 @@ export class RegistratieWizardComponent {
snapshot: () => { snapshot: () => {
const s = this.state(); const s = this.state();
if (s.tag !== 'Invullen' || !hasProgress(s)) return null; if (s.tag !== 'Invullen' || !hasProgress(s)) return null;
const documentIds = deliveryRefs(s.upload).filter((r) => r.channel === 'digital' && r.documentId).map((r) => r.documentId!); const documentIds = deliveryRefs(s.upload)
.filter((r) => r.channel === 'digital' && r.documentId)
.map((r) => r.documentId!);
return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds }; return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds };
}, },
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as RegistratieState }), onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as RegistratieState }),
enabled: () => this.seed() === initial, enabled: () => this.seed() === initial,
}); });
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]); protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]); protected stepTitle = computed(
() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)],
);
protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? ''); protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
protected failedError = computed(() => whenTag(this.state(), 'Mislukt')?.error ?? ''); protected failedError = computed(() => whenTag(this.state(), 'Mislukt')?.error ?? '');
@@ -251,13 +421,21 @@ export class RegistratieWizardComponent {
const next = this.cursor() + 1; const next = this.cursor() + 1;
return naarStapLabel(next + 1, this.stepLabels[next]); return naarStapLabel(next + 1, this.stepLabels[next]);
}); });
protected errorMessage = computed(() => $localize`:@@regWizard.indienenMislukt:Het indienen is niet gelukt:` + ` ${this.failedError()}`); protected errorMessage = computed(
() =>
$localize`:@@regWizard.indienenMislukt:Het indienen is niet gelukt:` +
` ${this.failedError()}`,
);
protected shellStatus = computed<WizardStatus>(() => { protected shellStatus = computed<WizardStatus>(() => {
switch (this.state().tag) { switch (this.state().tag) {
case 'Invullen': return 'editing'; case 'Invullen':
case 'Indienen': return 'submitting'; return 'editing';
case 'Ingediend': return 'submitted'; case 'Indienen':
case 'Mislukt': return 'failed'; return 'submitting';
case 'Ingediend':
return 'submitted';
case 'Mislukt':
return 'failed';
} }
}); });
/** Current step's errors (incl. per-question), flattened for the error summary. */ /** Current step's errors (incl. per-question), flattened for the error summary. */
@@ -274,12 +452,32 @@ export class RegistratieWizardComponent {
}); });
protected adresSamenvatting = computed(() => { protected adresSamenvatting = computed(() => {
const d = this.draft(); const d = this.draft();
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')].filter(Boolean).join(', '); return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')]
.filter(Boolean)
.join(', ');
}); });
// Readable labels for the controle summary (instead of raw enum values). // Readable labels for the controle summary (instead of raw enum values).
protected adresHerkomstLabel = computed(() => ({ brp: $localize`:@@regWizard.herkomst.adresBrp:Automatisch uit de BRP`, handmatig: $localize`:@@regWizard.herkomst.adresHandmatig:Handmatig ingevoerd` }[this.draft().adresHerkomst ?? 'handmatig'])); protected adresHerkomstLabel = computed(
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: $localize`:@@regWizard.herkomst.adresBrp:Automatisch uit de BRP`,
handmatig: $localize`:@@regWizard.herkomst.adresHandmatig:Handmatig ingevoerd`,
})[this.draft().adresHerkomst ?? 'handmatig'],
);
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 (laden/gevonden/geen/fout) and the parsed DUO lookup, both /** 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. */ served by the application facade — the wizard renders, it does not fetch/parse. */
@@ -295,23 +493,35 @@ export class RegistratieWizardComponent {
readonly jaNee = JA_NEE; readonly jaNee = JA_NEE;
protected err = (k: DraftField | 'correspondentie' | 'diploma' | 'documenten') => this.invullen()?.errors[k] ?? ''; protected err = (k: DraftField | 'correspondentie' | 'diploma' | 'documenten') =>
this.invullen()?.errors[k] ?? '';
protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? ''; protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? '';
protected antwoord = (id: string) => this.draft().antwoorden[id] ?? ''; // runtime guard: missing key → undefined protected antwoord = (id: string) => this.draft().antwoorden[id] ?? ''; // runtime guard: missing key → undefined
protected set = (key: DraftField, value: string) => this.dispatch({ tag: 'SetField', key, value }); protected set = (key: DraftField, value: string) =>
protected setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie }); this.dispatch({ tag: 'SetField', key, value });
protected setKanaal = (value: string) =>
this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
/** True while the user is entering a diploma manually (not in the DUO list). */ /** True while the user is entering a diploma manually (not in the DUO list). */
protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig'); protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig');
/** The radio selection: a diploma id, or the"not listed" sentinel in manual mode. */ /** The radio selection: a diploma id, or the"not listed" sentinel in manual mode. */
protected diplomaKeuze = computed(() => (this.handmatigActief() ? HANDMATIG : this.draft().diplomaId ?? '')); protected diplomaKeuze = computed(() =>
this.handmatigActief() ? HANDMATIG : (this.draft().diplomaId ?? ''),
);
protected diplomaOptions = (data: DuoLookupDto) => [ protected diplomaOptions = (data: DuoLookupDto) => [
...data.diplomas.map((d) => ({ value: d.id, label: `${d.naam}${d.instelling} (${d.jaar})` })), ...data.diplomas.map((d) => ({
{ value: HANDMATIG, label: $localize`:@@regWizard.diplomaNietBij:Mijn diploma staat er niet bij` }, value: d.id,
label: `${d.naam}${d.instelling} (${d.jaar})`,
})),
{
value: HANDMATIG,
label: $localize`:@@regWizard.diplomaNietBij:Mijn diploma staat er niet bij`,
},
]; ];
protected beroepOptions = (data: DuoLookupDto) => data.handmatig.beroepen.map((b) => ({ value: b, label: b })); protected beroepOptions = (data: DuoLookupDto) =>
data.handmatig.beroepen.map((b) => ({ value: b, label: b }));
/** The policy questions that apply to the current choice (server-decided). */ /** The policy questions that apply to the current choice (server-decided). */
protected actieveVragen = (data: DuoLookupDto): PolicyQuestionDto[] => { protected actieveVragen = (data: DuoLookupDto): PolicyQuestionDto[] => {
@@ -324,24 +534,41 @@ export class RegistratieWizardComponent {
const data = this.duoData(); const data = this.duoData();
const d = this.draft(); const d = this.draft();
if (!data) return [] as { vraag: string; antwoord: string }[]; if (!data) return [] as { vraag: string; antwoord: string }[];
const alle = [...data.diplomas.flatMap((x) => x.policyQuestions), ...data.handmatig.policyQuestions]; const alle = [
return (d.vraagIds ?? []).map((id) => ({ vraag: alle.find((q) => q.id === id)?.vraag ?? id, antwoord: d.antwoorden[id] ?? '' })); ...data.diplomas.flatMap((x) => x.policyQuestions),
...data.handmatig.policyQuestions,
];
return (d.vraagIds ?? []).map((id) => ({
vraag: alle.find((q) => q.id === id)?.vraag ?? id,
antwoord: d.antwoorden[id] ?? '',
}));
}); });
protected onDiplomaKeuze(data: DuoLookupDto, id: string) { protected onDiplomaKeuze(data: DuoLookupDto, id: string) {
if (id === HANDMATIG) { if (id === HANDMATIG) {
this.dispatch({ tag: 'KiesHandmatig', vraagIds: data.handmatig.policyQuestions.map((q) => q.id) }); this.dispatch({
tag: 'KiesHandmatig',
vraagIds: data.handmatig.policyQuestions.map((q) => q.id),
});
return; return;
} }
const d = data.diplomas.find((x) => x.id === id); const d = data.diplomas.find((x) => x.id === id);
if (d) this.dispatch({ tag: 'KiesDiploma', diplomaId: d.id, beroep: d.beroep, vraagIds: d.policyQuestions.map((q) => q.id) }); if (d)
this.dispatch({
tag: 'KiesDiploma',
diplomaId: d.id,
beroep: d.beroep,
vraagIds: d.policyQuestions.map((q) => q.id),
});
} }
constructor() { constructor() {
// An explicit seed (stories/tests) wins; otherwise resume from the backend draft // An explicit seed (stories/tests) wins; otherwise resume from the backend draft
// (`?aanvraag=<id>`), or start fresh. Persistence is the draftSync controller's job. // (`?aanvraag=<id>`), or start fresh. Persistence is the draftSync controller's job.
const seeded = this.seed(); const seeded = this.seed();
queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume())); 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 facade's // 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 // 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 // would otherwise make this effect loop on its own write). Don't clobber
@@ -352,7 +579,12 @@ export class RegistratieWizardComponent {
untracked(() => { untracked(() => {
const s = this.state(); const s = this.state();
if (s.tag !== 'Invullen' || s.draft.straat) return; if (s.tag !== 'Invullen' || s.draft.straat) return;
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 // A11y: focus management (step heading on step change, error summary on a
@@ -384,7 +616,10 @@ export class RegistratieWizardComponent {
private async runIfIndienen() { private async runIfIndienen() {
const s = this.state(); const s = this.state();
if (s.tag !== 'Indienen') return; if (s.tag !== 'Indienen') return;
const r = await this.draftSync.submit({ diplomaHerkomst: s.data.diplomaHerkomst, documents: s.data.documents }); const r = await this.draftSync.submit({
diplomaHerkomst: s.data.diplomaHerkomst,
documents: s.data.documents,
});
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value.referentie ?? '' }); if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value.referentie ?? '' });
else this.dispatch({ tag: 'SubmitFailed', error: r.error }); else this.dispatch({ tag: 'SubmitFailed', error: r.error });
} }

View File

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

View File

@@ -12,11 +12,11 @@ import { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/r
<app-page-shell <app-page-shell
i18n-heading="@@registratie.heading" i18n-heading="@@registratie.heading"
heading="Inschrijven in het BIG-register" heading="Inschrijven in het BIG-register"
backLink="/dashboard"> backLink="/dashboard"
>
<app-alert type="info" i18n="@@registratie.intro"> <app-alert type="info" i18n="@@registratie.intro">
In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het diploma waarmee
diploma waarmee u zich registreert, en een controle. Uw gegevens blijven bewaard u zich registreert, en een controle. Uw gegevens blijven bewaard als u de pagina herlaadt.
als u de pagina herlaadt.
</app-alert> </app-alert>
<div class="app-section"> <div class="app-section">
<app-registratie-wizard /> <app-registratie-wizard />

View File

@@ -9,11 +9,18 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
@Component({ @Component({
selector: 'app-registration-detail-page', selector: 'app-registration-detail-page',
imports: [ imports: [
PageShellComponent, SkeletonComponent, ...ASYNC, PageShellComponent,
RegistrationSummaryComponent, ChangeRequestFormComponent, SkeletonComponent,
...ASYNC,
RegistrationSummaryComponent,
ChangeRequestFormComponent,
], ],
template: ` template: `
<app-page-shell i18n-heading="@@registratieDetail.heading" heading="Mijn gegevens" backLink="/dashboard"> <app-page-shell
i18n-heading="@@registratieDetail.heading"
heading="Mijn gegevens"
backLink="/dashboard"
>
<app-async [data]="store.profile()"> <app-async [data]="store.profile()">
<ng-template appAsyncLoaded let-p> <ng-template appAsyncLoaded let-p>
<app-registration-summary [reg]="$any(p).registration" /> <app-registration-summary [reg]="$any(p).registration" />

View File

@@ -13,25 +13,60 @@ import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
imports: [DatePipe, DataRowComponent, StatusBadgeComponent, DataBlockComponent], imports: [DatePipe, DataRowComponent, StatusBadgeComponent, DataBlockComponent],
template: ` template: `
<app-data-block i18n-ariaLabel="@@summary.ariaLabel" ariaLabel="Registratiegegevens"> <app-data-block i18n-ariaLabel="@@summary.ariaLabel" ariaLabel="Registratiegegevens">
<div app-data-row i18n-key="@@summary.bigNummer" key="BIG-nummer" [value]="reg().bigNummer"></div> <div
app-data-row
i18n-key="@@summary.bigNummer"
key="BIG-nummer"
[value]="reg().bigNummer"
></div>
<div app-data-row i18n-key="@@summary.naam" key="Naam" [value]="reg().naam"></div> <div app-data-row i18n-key="@@summary.naam" key="Naam" [value]="reg().naam"></div>
<div app-data-row i18n-key="@@summary.beroep" key="Beroep" [value]="reg().beroep"></div> <div app-data-row i18n-key="@@summary.beroep" key="Beroep" [value]="reg().beroep"></div>
<div app-data-row i18n-key="@@summary.status" key="Status"> <div app-data-row i18n-key="@@summary.status" key="Status">
<app-status-badge [label]="label()" [color]="color()" /> <app-status-badge [label]="label()" [color]="color()" />
</div> </div>
<div app-data-row i18n-key="@@summary.registratiedatum" key="Registratiedatum" [value]="reg().registratiedatum | date:'longDate'"></div> <div
app-data-row
i18n-key="@@summary.registratiedatum"
key="Registratiedatum"
[value]="reg().registratiedatum | date: 'longDate'"
></div>
<!-- Each status variant renders only the row its own data supports. --> <!-- Each status variant renders only the row its own data supports. -->
@switch (reg().status.tag) { @switch (reg().status.tag) {
@case ('Geregistreerd') { @case ('Geregistreerd') {
<div app-data-row i18n-key="@@summary.uiterste" key="Uiterste herregistratie" [value]="$any(reg().status).herregistratieDatum | date:'longDate'"></div> <div
app-data-row
i18n-key="@@summary.uiterste"
key="Uiterste herregistratie"
[value]="$any(reg().status).herregistratieDatum | date: 'longDate'"
></div>
} }
@case ('Geschorst') { @case ('Geschorst') {
<div app-data-row i18n-key="@@summary.geschorstTot" key="Geschorst tot" [value]="$any(reg().status).geschorstTot | date:'longDate'"></div> <div
<div app-data-row i18n-key="@@summary.reden" key="Reden" [value]="$any(reg().status).reden"></div> app-data-row
i18n-key="@@summary.geschorstTot"
key="Geschorst tot"
[value]="$any(reg().status).geschorstTot | date: 'longDate'"
></div>
<div
app-data-row
i18n-key="@@summary.reden"
key="Reden"
[value]="$any(reg().status).reden"
></div>
} }
@case ('Doorgehaald') { @case ('Doorgehaald') {
<div app-data-row i18n-key="@@summary.doorgehaaldOp" key="Doorgehaald op" [value]="$any(reg().status).doorgehaaldOp | date:'longDate'"></div> <div
<div app-data-row i18n-key="@@summary.reden" key="Reden" [value]="$any(reg().status).reden"></div> app-data-row
i18n-key="@@summary.doorgehaaldOp"
key="Doorgehaald op"
[value]="$any(reg().status).doorgehaaldOp | date: 'longDate'"
></div>
<div
app-data-row
i18n-key="@@summary.reden"
key="Reden"
[value]="$any(reg().status).reden"
></div>
} }
} }
</app-data-block> </app-data-block>

View File

@@ -23,8 +23,18 @@ export const Geregistreerd: Story = {
args: { reg: { ...base, status: { tag: 'Geregistreerd', herregistratieDatum: '2027-09-01' } } }, args: { reg: { ...base, status: { tag: 'Geregistreerd', herregistratieDatum: '2027-09-01' } } },
}; };
export const Geschorst: Story = { export const Geschorst: Story = {
args: { reg: { ...base, status: { tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'Lopend tuchtonderzoek' } } }, args: {
reg: {
...base,
status: { tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'Lopend tuchtonderzoek' },
},
},
}; };
export const Doorgehaald: Story = { export const Doorgehaald: Story = {
args: { reg: { ...base, status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' } } }, args: {
reg: {
...base,
status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' },
},
},
}; };

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