From e82309786d3453c7daf60089bdcd21f5cf29d6a6 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 3 Jul 2026 13:39:31 +0200 Subject: [PATCH] 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) --- .claude/skills/bff-endpoint/SKILL.md | 1 + .claude/skills/form-machine/SKILL.md | 28 +- .claude/skills/mutation-command/SKILL.md | 4 +- .claude/skills/new-feature/SKILL.md | 1 + .claude/skills/value-object/SKILL.md | 1 + .prettierignore | 18 + .storybook/main.ts | 17 +- .storybook/preview.ts | 4 +- README.md | 59 ++- docker-compose.yml | 4 +- .../0001-bff-lite-decision-dtos.md | 20 +- .../0002-user-groups-and-bounded-contexts.md | 30 +- docs/architecture/0003-cibg-huisstijl.md | 4 +- docs/backlog/README.md | 46 +- docs/backlog/WP-01-axe-ci-gate.md | 2 +- docs/backlog/WP-05-parse-boundaries.md | 4 +- docs/backlog/WP-07-brief-idioms.md | 2 +- docs/backlog/WP-13-cibg-gap-register.md | 3 +- docs/backlog/WP-16-component-a11y.md | 1 + docs/backlog/WP-17-app-a11y.md | 5 +- .../0001-mijn-aanvragen-en-wizardstatus.md | 44 +- .../0002-attribute-based-access-control.md | 36 +- docs/ui-ux-audit.md | 96 ++-- eslint.config.mjs | 67 ++- src/app/app.config.ts | 9 +- src/app/app.routes.ts | 56 +- src/app/auth/infrastructure/digid.adapter.ts | 3 +- .../ui/login-form/login-form.component.ts | 17 +- src/app/auth/ui/login.page.ts | 13 +- src/app/brief/application/brief.store.ts | 33 +- src/app/brief/domain/brief.machine.spec.ts | 96 +++- src/app/brief/domain/brief.machine.ts | 75 ++- src/app/brief/domain/brief.spec.ts | 69 ++- src/app/brief/domain/brief.ts | 11 +- src/app/brief/domain/placeholders.spec.ts | 19 +- src/app/brief/domain/placeholders.ts | 8 +- .../infrastructure/brief.adapter.spec.ts | 62 ++- src/app/brief/infrastructure/brief.adapter.ts | 112 +++- src/app/brief/ui/brief.page.ts | 59 ++- .../diagnostics-panel.component.ts | 39 +- .../ui/letter-block/letter-block.component.ts | 48 +- .../letter-composer.component.ts | 110 +++- .../letter-composer.stories.ts | 115 +++- .../letter-preview.component.ts | 100 +++- .../letter-section.component.ts | 52 +- .../passage-picker.component.ts | 32 +- .../rejection-comments.component.ts | 29 +- .../application/intake-policy.store.ts | 4 +- .../domain/herregistratie.machine.spec.ts | 61 ++- .../domain/herregistratie.machine.ts | 30 +- .../domain/intake.machine.spec.ts | 36 +- .../herregistratie/domain/intake.machine.ts | 29 +- .../herregistratie-wizard.component.ts | 142 ++++- .../herregistratie-wizard.stories.ts | 55 +- .../herregistratie/ui/herregistratie.page.ts | 6 +- .../intake-wizard/intake-wizard.component.ts | 236 +++++++-- .../ui/intake-wizard/intake-wizard.stories.ts | 20 +- src/app/herregistratie/ui/intake.page.ts | 11 +- .../application/applications.store.ts | 11 +- .../application/big-profile.store.ts | 18 +- src/app/registratie/application/draft-sync.ts | 43 +- .../application/registratie-lookup.store.ts | 18 +- .../registratie/domain/aanvraag-view.spec.ts | 44 +- src/app/registratie/domain/aanvraag-view.ts | 54 +- .../registratie/domain/block-actions.spec.ts | 9 +- .../domain/change-request.machine.spec.ts | 16 +- .../domain/change-request.machine.ts | 9 +- .../registratie/domain/has-progress.spec.ts | 20 +- .../domain/registratie-wizard.machine.spec.ts | 76 ++- .../domain/registratie-wizard.machine.ts | 63 ++- .../domain/registration.policy.spec.ts | 22 +- .../registratie/domain/registration.policy.ts | 6 +- src/app/registratie/domain/tasks.spec.ts | 10 +- src/app/registratie/domain/tasks.ts | 5 +- .../domain/value-objects/big-nummer.ts | 4 +- .../registratie/domain/value-objects/email.ts | 4 +- .../domain/value-objects/uren.spec.ts | 6 +- .../applications.adapter.spec.ts | 29 +- .../infrastructure/applications.adapter.ts | 26 +- .../infrastructure/big-register.adapter.ts | 6 +- .../infrastructure/brp.adapter.spec.ts | 5 +- .../registratie/infrastructure/brp.adapter.ts | 7 +- .../dashboard-view.adapter.spec.ts | 10 +- .../infrastructure/dashboard-view.adapter.ts | 23 +- .../infrastructure/duo.adapter.spec.ts | 28 +- .../registratie/infrastructure/duo.adapter.ts | 30 +- .../aanvraag-block.component.ts | 25 +- .../aanvraag-block/aanvraag-block.stories.ts | 37 +- .../registratie/ui/aanvraag-detail.page.ts | 24 +- .../address-fields.component.ts | 78 ++- .../address-fields/address-fields.stories.ts | 10 +- .../change-request-form.component.ts | 28 +- .../change-request-form.stories.ts | 25 +- src/app/registratie/ui/dashboard.page.ts | 273 +++++++--- .../registratie-wizard.component.ts | 499 +++++++++++++----- .../registratie-wizard.stories.ts | 67 ++- src/app/registratie/ui/registratie.page.ts | 8 +- .../ui/registration-detail.page.ts | 13 +- .../registration-summary.component.ts | 49 +- .../registration-summary.stories.ts | 14 +- .../registration-table.component.ts | 2 +- .../registration-table.stories.ts | 6 +- src/app/shared/application/remote-data.ts | 6 +- src/app/shared/application/submit.ts | 5 +- .../shared/infrastructure/api-error.spec.ts | 9 +- src/app/shared/infrastructure/role.ts | 4 +- .../infrastructure/scenario.interceptor.ts | 7 +- src/app/shared/infrastructure/scenario.ts | 10 +- src/app/shared/kernel/rich-text.spec.ts | 31 +- .../layout/breadcrumb/breadcrumb-trail.ts | 10 +- .../layout/breadcrumb/breadcrumb.component.ts | 8 +- .../layout/breadcrumb/breadcrumb.stories.ts | 15 +- .../layout/page-shell/page-shell.component.ts | 28 +- .../shared/layout/shell/shell.component.ts | 43 +- .../site-footer/site-footer.component.ts | 118 ++++- .../site-header/site-header.component.ts | 45 +- .../wizard-shell/wizard-shell.component.ts | 90 +++- .../wizard-shell/wizard-shell.stories.ts | 15 +- src/app/shared/ui/alert/alert.component.ts | 16 +- .../application-link.component.ts | 37 +- .../application-link.stories.ts | 6 +- src/app/shared/ui/async/async.component.ts | 77 ++- src/app/shared/ui/async/async.stories.ts | 8 +- src/app/shared/ui/button/button.component.ts | 3 +- src/app/shared/ui/card/card.component.ts | 35 +- .../shared/ui/checkbox/checkbox.component.ts | 31 +- .../ui/choice-link/choice-link.component.ts | 32 +- .../ui/choice-link/choice-link.stories.ts | 11 +- .../ui/confirmation/confirmation.component.ts | 11 +- .../ui/data-block/data-block.component.ts | 4 +- .../shared/ui/data-row/data-row.component.ts | 12 +- .../ui/debug-state/debug-state.component.ts | 54 +- .../ui/form-field/form-field.component.ts | 8 +- .../ui/form-field/form-field.stories.ts | 7 +- .../shared/ui/heading/heading.component.ts | 20 +- .../placeholder-chip.component.ts | 68 ++- .../placeholder-chip.stories.ts | 16 +- .../ui/radio-group/radio-group.component.ts | 26 +- .../ui/radio-group/radio-group.stories.ts | 8 +- .../review-section.component.ts | 4 +- .../ui/rich-text-editor/rich-text-dom.spec.ts | 22 +- .../ui/rich-text-editor/rich-text-dom.ts | 27 +- .../rich-text-editor.component.ts | 162 ++++-- .../rich-text-editor.stories.ts | 12 +- .../shared/ui/skeleton/skeleton.component.ts | 42 +- .../shared/ui/spinner/spinner.component.ts | 44 +- .../ui/status-badge/status-badge.component.ts | 19 +- .../ui/status-badge/status-badge.stories.ts | 12 +- .../shared/ui/stepper/stepper.component.ts | 22 +- src/app/shared/ui/stepper/stepper.stories.ts | 11 +- .../shared/ui/task-list/task-list.stories.ts | 19 +- .../ui/text-input/text-input.component.ts | 38 +- .../delivery-channel-toggle.component.ts | 28 +- .../delivery-channel-toggle.stories.ts | 4 +- .../document-category.component.ts | 54 +- .../document-category.stories.ts | 7 +- .../document-chip/document-chip.component.ts | 25 +- .../document-chip/document-chip.stories.ts | 19 +- .../document-upload.component.ts | 15 +- .../upload/file-input/file-input.component.ts | 68 ++- .../upload/file-input/file-input.stories.ts | 24 +- .../single-upload/single-upload.component.ts | 49 +- .../upload-progress-bar.component.ts | 29 +- .../upload-status-banner.component.ts | 4 +- .../upload-status-icon.component.ts | 47 +- src/app/shared/upload/upload-controller.ts | 18 +- src/app/shared/upload/upload-shell.service.ts | 20 +- src/app/shared/upload/upload.adapter.ts | 35 +- src/app/shared/upload/upload.machine.spec.ts | 107 +++- src/app/shared/upload/upload.machine.ts | 91 +++- src/app/showcase/concepts.page.ts | 269 ++++++++-- src/docs/atomic-design.mdx | 38 +- src/docs/design-tokens.mdx | 32 +- src/docs/fp-in-ui.mdx | 6 +- src/index.html | 26 +- src/styles.scss | 103 ++-- 176 files changed, 5069 insertions(+), 1471 deletions(-) create mode 100644 .prettierignore diff --git a/.claude/skills/bff-endpoint/SKILL.md b/.claude/skills/bff-endpoint/SKILL.md index e9a4d6e..ca2b6d6 100644 --- a/.claude/skills/bff-endpoint/SKILL.md +++ b/.claude/skills/bff-endpoint/SKILL.md @@ -34,6 +34,7 @@ server re-validates as authority). 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. + 5. **Application store** exposes the resource as `RemoteData` (`fromResource`, combine sources with `map`/`map2`/`andThen` from `@shared/application/remote-data`). UI never imports infrastructure (lint-enforced). diff --git a/.claude/skills/form-machine/SKILL.md b/.claude/skills/form-machine/SKILL.md index cb97ac8..a6845f6 100644 --- a/.claude/skills/form-machine/SKILL.md +++ b/.claude/skills/form-machine/SKILL.md @@ -16,9 +16,15 @@ unrepresentable. ```ts 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>; -export interface Valid { postcode: Postcode; uren: Uren } // branded, proven valid +export interface Valid { + postcode: Postcode; + uren: Uren; +} // branded, proven valid export type State = | { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors } @@ -28,23 +34,31 @@ export type State = export type Msg = | { tag: 'SetField'; key: keyof Draft; value: string } - | { tag: 'Next' } | { tag: 'Back' } | { tag: 'Submit' } | { tag: 'Retry' } - | { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error: string } - | { tag: 'Seed'; state: State }; // mount any state (stories, resume) + | { tag: 'Next' } + | { tag: 'Back' } + | { tag: 'Submit' } + | { tag: 'Retry' } + | { tag: 'SubmitConfirmed' } + | { tag: 'SubmitFailed'; error: string } + | { tag: 'Seed'; state: State }; // mount any state (stories, resume) export const initial: State = { tag: 'Editing', step: 1, draft: emptyDraft, errors: {} }; export function reduce(s: State, m: Msg): State { switch (m.tag) { /* … pure transitions only … */ - default: return assertNever(m); // exhaustiveness enforced + default: + return assertNever(m); // exhaustiveness enforced } } -export function validate(draft: Draft): Result { /* calls value-object parsers */ } +export function validate(draft: Draft): Result { + /* calls value-object parsers */ +} ``` Rules: + - **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. - Server-owned thresholds arrive as config values; keep only an offline fallback constant (see `SCHOLING_THRESHOLD_DEFAULT` in the intake machine). diff --git a/.claude/skills/mutation-command/SKILL.md b/.claude/skills/mutation-command/SKILL.md index b324289..8372bfc 100644 --- a/.claude/skills/mutation-command/SKILL.md +++ b/.claude/skills/mutation-command/SKILL.md @@ -17,7 +17,9 @@ machine's `Valid` type, returns the server's answer, no parse (server is authori @Injectable({ providedIn: 'root' }) export class ChangeRequestAdapter { private client = inject(ApiClient); - async changeRequest(data: Valid): Promise { /* → server reference */ } + async changeRequest(data: Valid): Promise { + /* → server reference */ + } } ``` diff --git a/.claude/skills/new-feature/SKILL.md b/.claude/skills/new-feature/SKILL.md index f443ea0..a12d646 100644 --- a/.claude/skills/new-feature/SKILL.md +++ b/.claude/skills/new-feature/SKILL.md @@ -33,6 +33,7 @@ Every feature is built inward-out. Never start with the component. ## Worked example The intake wizard slice, end to end: + - `src/app/herregistratie/domain/intake.machine.ts` (+ spec) - `src/app/herregistratie/infrastructure/` - `src/app/herregistratie/ui/` (wizard component + page) diff --git a/.claude/skills/value-object/SKILL.md b/.claude/skills/value-object/SKILL.md index e78ec6d..489aa97 100644 --- a/.claude/skills/value-object/SKILL.md +++ b/.claude/skills/value-object/SKILL.md @@ -28,6 +28,7 @@ export function parsePostcode(raw: string): Result { ``` Rules: + - 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.` id. - Trim/normalise before testing; return the cleaned value. diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..067fb25 --- /dev/null +++ b/.prettierignore @@ -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/ diff --git a/.storybook/main.ts b/.storybook/main.ts index 01e8cde..8f7dd16 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,18 +1,11 @@ import type { StorybookConfig } from '@storybook/angular'; const config: StorybookConfig = { - "stories": [ - "../src/**/*.mdx", - "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)" - ], - "addons": [ - "@storybook/addon-a11y", - "@storybook/addon-docs", - "@storybook/addon-onboarding" - ], - "framework": "@storybook/angular", + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: ['@storybook/addon-a11y', '@storybook/addon-docs', '@storybook/addon-onboarding'], + framework: '@storybook/angular', // Serve the vendored CIBG package so preview-head.html can its CSS (and its // relative font/icon/image url()s resolve) — mirrors index.html for the real app. - "staticDirs": ["../public"] + staticDirs: ['../public'], }; -export default config; \ No newline at end of file +export default config; diff --git a/.storybook/preview.ts b/.storybook/preview.ts index f794cbd..88deb0a 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -11,9 +11,7 @@ if (typeof document !== 'undefined') document.body.classList.add('brand--cibg'); const preview: Preview = { // 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). - decorators: [ - componentWrapperDecorator((story) => `
${story}
`), - ], + decorators: [componentWrapperDecorator((story) => `
${story}
`)], parameters: { layout: 'padded', // Accessibility (axe) via @storybook/addon-a11y. Runs WCAG 2.0/2.1 A+AA rule diff --git a/README.md b/README.md index fdfb6a3..5269cfb 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ and demonstrates a robust **async-state pattern** where the UI can never reach a inconsistent state. > 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 > is demonstrable, not hand-waved. A system-font stack stands in for the licensed > Rijksoverheid font and a text wordmark for the logo. @@ -48,28 +48,28 @@ eligibility, thresholds); see **[backend/README.md](backend/README.md)**. Append `?scenario=` to any data page (e.g. `/dashboard`) to force an async state: -| URL | What you see | -|-----|--------------| -| `/dashboard` | real data (fast) | -| `/dashboard?scenario=slow` | skeletons for ~2.5s, then data | -| `/dashboard?scenario=loading` | the loading state, held open | -| `/dashboard?scenario=empty` | "geen gegevens" empty state | -| `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) | +| URL | What you see | +| ----------------------------- | -------------------------------------------- | +| `/dashboard` | real data (fast) | +| `/dashboard?scenario=slow` | skeletons for ~2.5s, then data | +| `/dashboard?scenario=loading` | the loading state, held open | +| `/dashboard?scenario=empty` | "geen gegevens" empty state | +| `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) | --- ## How atomic design works here (folder = layer) 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 | -|-------|-----------|---------------| -| **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) | -| **organisms/** | larger, self-contained sections | `site-header`, `site-footer`, `login-form`, `registration-summary`, `registration-table`, `change-request-form` | -| **templates/** | page skeletons that define layout; content is projected in | `page-layout` (header/content/footer chrome), `page-shell` (back-link + heading + intro + content) | -| **pages/** | a template filled with real data | `login`, `dashboard`, `registration-detail`, `herregistratie` | +| 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` | +| **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` | +| **templates/** | page skeletons that define layout; content is projected in | `page-layout` (header/content/footer chrome), `page-shell` (back-link + heading + intro + content) | +| **pages/** | a template filled with real data | `login`, `dashboard`, `registration-detail`, `herregistratie` | Each atom is a thin Angular standalone component that applies CIBG Huisstijl (Bootstrap 5.2) CSS classes (`btn`, `form-control`, `card`, …) — so the design system @@ -81,14 +81,14 @@ does the visual work and we only own a small, typed component API. **1. Reuse — the same blocks appear everywhere.** -| Component | Appears in | -|-----------|-----------| -| `button` | login, change-request, herregistratie, async retry, Storybook | -| `form-field` + `text-input` | login form *and* change-request *and* herregistratie | -| `status-badge` | dashboard summary, detail summary | -| `page-shell` / `page-layout` | all four pages | -| `site-header` / `site-footer` | every page | -| `async` + `skeleton` | dashboard, detail | +| Component | Appears in | +| ----------------------------- | ------------------------------------------------------------- | +| `button` | login, change-request, herregistratie, async retry, Storybook | +| `form-field` + `text-input` | login form _and_ change-request _and_ herregistratie | +| `status-badge` | dashboard summary, detail summary | +| `page-shell` / `page-layout` | all four pages | +| `site-header` / `site-footer` | every page | +| `async` + `skeleton` | dashboard, detail | Change a component once and every screen that uses it updates. @@ -125,13 +125,13 @@ decisions the FE renders rather than recomputes (see ADR-0001). The molecule **``** turns those signals into UI. It renders **exactly one** of 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. ```html - - + + ``` @@ -139,7 +139,7 @@ time, or show stale content during a hard failure: those states are unrepresenta - **Loaded** — your content, with the value. - **Loading** — your skeleton, or a default **delayed spinner** (only appears after ~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 `isEmpty` predicate). - **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. ### Deliberately out of scope (POC) + 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 -itself *is* implemented.) +itself _is_ implemented.) diff --git a/docker-compose.yml b/docker-compose.yml index a3f09a8..d373adf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: - api-bin:/src/src/BigRegister.Api/bin - api-obj:/src/src/BigRegister.Api/obj ports: - - "5000:5000" + - '5000:5000' web: image: node:24 @@ -24,7 +24,7 @@ services: - ./:/app:z - web-modules:/app/node_modules ports: - - "4200:4200" + - '4200:4200' depends_on: - api diff --git a/docs/architecture/0001-bff-lite-decision-dtos.md b/docs/architecture/0001-bff-lite-decision-dtos.md index 8bd0fb4..65a18e8 100644 --- a/docs/architecture/0001-bff-lite-decision-dtos.md +++ b/docs/architecture/0001-bff-lite-decision-dtos.md @@ -20,13 +20,13 @@ backend team. ## Options considered -| Option | Fewer calls? | Unifies policy? | Cost | -|---|---|---|---| -| 1. Status quo (client calls upstreams, aggregates, owns rules) | No | No | — | -| 2. Unified client API layer (one facade in the FE) | No — still N round-trips | No — rules still on client | Low, but misses the goal | -| **3. Screen-shaped endpoints on our own backend ("BFF-lite")** | **Yes** — 1 call/screen | **Yes** — server computes decisions | Low–medium | -| 4. Separately-deployed BFF service | Yes | Yes | Medium — another deployable | -| 5. GraphQL gateway | Yes (client picks fields) | **No, not by itself** — still need resolvers to own rules | Medium–high; new infra | +| Option | Fewer calls? | Unifies policy? | Cost | +| -------------------------------------------------------------- | ------------------------- | --------------------------------------------------------- | --------------------------- | +| 1. Status quo (client calls upstreams, aggregates, owns rules) | No | No | — | +| 2. Unified client API layer (one facade in the FE) | No — still N round-trips | No — rules still on client | Low, but misses the goal | +| **3. Screen-shaped endpoints on our own backend ("BFF-lite")** | **Yes** — 1 call/screen | **Yes** — server computes decisions | Low–medium | +| 4. Separately-deployed BFF service | Yes | Yes | Medium — another deployable | +| 5. GraphQL gateway | Yes (client picks fields) | **No, not by itself** — still need resolvers to own rules | Medium–high; new infra | GraphQL solves over/under-fetching but does not, on its own, move rules server-side — and our problem is policy unification + drift, not field-selection @@ -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 (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 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 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 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: **A. Dashboard profile → one aggregated, decision-enriched call (decision-flag).** + - Contract: `src/app/registratie/contracts/dashboard-view.dto.ts` (`DashboardViewDto` = registration + person + `decisions`). - Endpoint: `public/mock/dashboard-view.json` (one call replaces three). @@ -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. **B. Intake scholing threshold → config value.** + - Contract: `src/app/herregistratie/contracts/intake-policy.dto.ts`. - Endpoint: `public/mock/intake-policy.json` (`{ "scholingThreshold": 1000 }`). - `intake.machine.ts`: the hardcoded `LAGE_UREN_DREMPEL` constant is gone; diff --git a/docs/architecture/0002-user-groups-and-bounded-contexts.md b/docs/architecture/0002-user-groups-and-bounded-contexts.md index 7f93bae..e70f0ac 100644 --- a/docs/architecture/0002-user-groups-and-bounded-contexts.md +++ b/docs/architecture/0002-user-groups-and-bounded-contexts.md @@ -7,7 +7,7 @@ Status: Proposed · Date: 2026-07-01 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 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 applications) — and want room for others later (admin, auditor, institution rep). The question @@ -27,11 +27,11 @@ Confirmed constraints (with the product owner): ## Options considered -| Option | Ubiquitous language respected? | Coupling | Verdict | -|---|---|---|---| -| 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 | -| 3. **Actors are personas; contexts are capabilities; identity is typed** | Yes | Low | **Adopt** | +| Option | Ubiquitous language respected? | Coupling | Verdict | +| ------------------------------------------------------------------------------ | --------------------------------------------------------------- | -------- | --------- | +| 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 | +| 3. **Actors are personas; contexts are capabilities; identity is typed** | Yes | Low | **Adopt** | ## Decision @@ -42,9 +42,9 @@ Confirmed constraints (with the product owner): 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.** -- **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. 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 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 -aggregate projected into two screen-shaped views. The **aanvraag status lifecycle** is the *published -contract* between the two contexts: +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 +contract_ between the two contexts: ``` Ingediend → In behandeling → (Meer info gevraagd ⇄) → Goedgekeurd / Afgewezen @@ -93,7 +93,7 @@ These are two concerns people habitually conflate; keeping them apart is the cru ```ts type Principal = - | { kind: 'zorgverlener'; bsn: string; naam: string } // DigiD/BSN + | { kind: 'zorgverlener'; bsn: string; naam: string } // DigiD/BSN | { kind: 'medewerker'; medewerkerId: string; naam: string; rollen: Rol[] }; // employee SSO ``` @@ -102,14 +102,14 @@ These are two concerns people habitually conflate; keeping them apart is the cru second actor arrives. - **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 every other server-owned rule. ### 4. "Other users" slot in without inventing contexts 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), 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`). - 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. -- `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) diff --git a/docs/architecture/0003-cibg-huisstijl.md b/docs/architecture/0003-cibg-huisstijl.md index cb725e3..9d81115 100644 --- a/docs/architecture/0003-cibg-huisstijl.md +++ b/docs/architecture/0003-cibg-huisstijl.md @@ -23,7 +23,7 @@ layer — not a palette swap. 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 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 `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 @@ -42,7 +42,7 @@ layer — not a palette swap. (`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. - `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 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 diff --git a/docs/backlog/README.md b/docs/backlog/README.md index dfe4c3c..c878a72 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -38,25 +38,25 @@ only needs re-running if a WP unexpectedly touches `backend/`. Gates land before the work they cover; each lint rule lands in the same WP as the fixes for its existing violations, so every WP ends green. -| WP | Title | Phase | Status | -|----|-------|-------|--------| -| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done | -| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done | -| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done | -| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done | -| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | todo | -| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo | -| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo | -| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | todo | -| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo | -| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | todo | -| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done | -| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done | -| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | todo | -| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | todo | -| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | todo | -| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | todo | -| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | todo | +| WP | Title | Phase | Status | +| --------------------------------------- | ------------------------------------------------------------ | ------------- | ------ | +| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done | +| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done | +| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done | +| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done | +| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | todo | +| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo | +| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo | +| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | todo | +| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo | +| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | todo | +| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done | +| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done | +| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | todo | +| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | todo | +| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | todo | +| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | todo | +| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | todo | Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn); 03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed @@ -72,12 +72,20 @@ Status: todo | in-progress | done () Phase: N — name ## Why + ## Read first + ## Decisions (pre-made, don't relitigate) + ## Files + ## Steps + ## Acceptance criteria + ## Verification + ## Out of scope + ## Risks ``` diff --git a/docs/backlog/WP-01-axe-ci-gate.md b/docs/backlog/WP-01-axe-ci-gate.md index ae02081..5b54bac 100644 --- a/docs/backlog/WP-01-axe-ci-gate.md +++ b/docs/backlog/WP-01-axe-ci-gate.md @@ -6,7 +6,7 @@ Phase: 0 — enforcement & gates ## Why 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 build", so every story added or changed by later WPs is automatically covered. diff --git a/docs/backlog/WP-05-parse-boundaries.md b/docs/backlog/WP-05-parse-boundaries.md index 35a7fee..fd21090 100644 --- a/docs/backlog/WP-05-parse-boundaries.md +++ b/docs/backlog/WP-05-parse-boundaries.md @@ -30,7 +30,7 @@ principle (every response through a hand-written `parse*` returning `Result`). - `src/app/registratie/infrastructure/big-register.adapter.ts` (~line 25) — `n.type as AantekeningType` → validated parse - `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 `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` @@ -57,7 +57,7 @@ GREEN + `npm run test-storybook:ci`. Smoke: intake wizard still loads its policy ## 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). ## Risks diff --git a/docs/backlog/WP-07-brief-idioms.md b/docs/backlog/WP-07-brief-idioms.md index 88d1c68..bfc3b1b 100644 --- a/docs/backlog/WP-07-brief-idioms.md +++ b/docs/backlog/WP-07-brief-idioms.md @@ -26,7 +26,7 @@ bypassing the shared molecule. (`Idle | Busy | Failed{error}`), replacing `busy`+`lastError`. `saveState` keeps its union shape (align tag style). - Load lifecycle → `RemoteData` + ``; 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). - Keep the debounced-save sequencing identical; only re-type the state. diff --git a/docs/backlog/WP-13-cibg-gap-register.md b/docs/backlog/WP-13-cibg-gap-register.md index baf3d03..bfbbc3c 100644 --- a/docs/backlog/WP-13-cibg-gap-register.md +++ b/docs/backlog/WP-13-cibg-gap-register.md @@ -43,6 +43,7 @@ the list-family rationale to document. ## Files Components to mark (closest CIBG concept in parens): + - `skeleton`, `spinner` (Laadindicatie — no vendored class, verified) - `upload/` suite (Bestand-upload) - `rich-text-editor` (Tekstgebied) @@ -52,7 +53,7 @@ Components to mark (closest CIBG concept in parens): - `debug-state` (devtool, no CIBG concept) - `status-badge` (deliberate custom, documented in code), `card` (`.app-card`), `placeholder-chip` -Plus: + Plus: - Delete `upload-status-banner` + its story; inline alert at its consumer - Header comments on `task-list`/`application-list`/`choice-list` - New `src/docs/cibg-gaps.mdx` — title `Foundations/CIBG Gap Register` diff --git a/docs/backlog/WP-16-component-a11y.md b/docs/backlog/WP-16-component-a11y.md index a96726d..7cdc07b 100644 --- a/docs/backlog/WP-16-component-a11y.md +++ b/docs/backlog/WP-16-component-a11y.md @@ -6,6 +6,7 @@ Phase: 4 — a11y ## Why Audit findings axe can't (fully) catch: + - `form-field` renders a description `
` that **no control 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 diff --git a/docs/backlog/WP-17-app-a11y.md b/docs/backlog/WP-17-app-a11y.md index 35941b3..b37b1d5 100644 --- a/docs/backlog/WP-17-app-a11y.md +++ b/docs/backlog/WP-17-app-a11y.md @@ -6,9 +6,10 @@ Phase: 4 — a11y ## Why Three app-level gaps close the WCAG story: + - **No route-change focus/scroll management** — `app.config.ts` has only `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.) - **No template a11y linting** — `@angular-eslint` is entirely absent. - 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; 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. - [ ] `a11y.mdx` renders; links to checklist and WP-01 skip rules. diff --git a/docs/prd/0001-mijn-aanvragen-en-wizardstatus.md b/docs/prd/0001-mijn-aanvragen-en-wizardstatus.md index dd32350..5a28c85 100644 --- a/docs/prd/0001-mijn-aanvragen-en-wizardstatus.md +++ b/docs/prd/0001-mijn-aanvragen-en-wizardstatus.md @@ -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 > 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, > unbuilt context. @@ -24,7 +24,7 @@ uploaded. Concretely, today: and there is **no GET-content endpoint**. - 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 - 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 @@ -54,25 +54,25 @@ uploaded. Concretely, today: ## 4. Personas 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. ## 5. Domain model — the `Aanvraag` aggregate (backend-owned) The backend gains an `Aanvraag` (application) aggregate — the system of record the dashboard reads. -| Field | Type | Notes | -|---|---|---| -| `id` | string (uuid) | client-visible handle; used in the resume deep link | -| `type` | `registratie` \| `herregistratie` \| `intake` | which wizard | -| `status` | discriminated union (below) | computed on read for auto-approval | -| `draft` | opaque JSON | the wizard's persisted machine snapshot (Concept only) | -| `stepIndex`, `stepCount` | int | for the "Stap X van Y" progress on the block | -| `documentIds` | string[] | documents linked to this aanvraag | -| `referentie` | string? | set on submit (e.g. `BIG-2026-456789`) | -| `owner` | string | `DemoOwner` | -| `autoApprovable` | bool | set at submit: `diplomaHerkomst === 'duo'` (registratie); other types auto | -| `createdAt` / `updatedAt` / `submittedAt?` | timestamps | | +| Field | Type | Notes | +| ------------------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------- | +| `id` | string (uuid) | client-visible handle; used in the resume deep link | +| `type` | `registratie` \| `herregistratie` \| `intake` | which wizard | +| `status` | discriminated union (below) | computed on read for auto-approval | +| `draft` | opaque JSON | the wizard's persisted machine snapshot (Concept only) | +| `stepIndex`, `stepCount` | int | for the "Stap X van Y" progress on the block | +| `documentIds` | string[] | documents linked to this aanvraag | +| `referentie` | string? | set on submit (e.g. `BIG-2026-456789`) | +| `owner` | string | `DemoOwner` | +| `autoApprovable` | bool | set at submit: `diplomaHerkomst === 'duo'` (registratie); other types auto | +| `createdAt` / `updatedAt` / `submittedAt?` | timestamps | | ### Status lifecycle @@ -94,7 +94,7 @@ FE-mirrored discriminated union (illegal states unrepresentable, same reflex as ```ts type AanvraagStatus = | { tag: 'Concept'; stepIndex: number; stepCount: number } - | { tag: 'InBehandeling'; referentie: string; manual: boolean } // manual=true → "wordt beoordeeld" + | { tag: 'InBehandeling'; referentie: string; manual: boolean } // manual=true → "wordt beoordeeld" | { tag: 'Goedgekeurd'; referentie: string } | { tag: 'Afgewezen'; referentie: string; reden: string }; ``` @@ -117,12 +117,12 @@ Concept → In behandeling → resolved. Empty list → the section is hidden. Per-status block (new `aanvraag-block` component; **which** actions/badge a block shows comes from a pure `blockActions(status)`): -| Status | Badge | Body | Actions | -|---|---|---|---| -| **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 | -| **Goedgekeurd** | green "Goedgekeurd" | referentie | — | -| **Afgewezen** | red "Afgewezen" | referentie + reden | — | +| Status | Badge | Body | Actions | +| ------------------ | ---------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------ | +| **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 | +| **Goedgekeurd** | green "Goedgekeurd" | referentie | — | +| **Afgewezen** | red "Afgewezen" | referentie + reden | — | - **Verder gaan** deep-links to the wizard with `?aanvraag=`; the wizard loads the draft from the backend and seeds its machine. diff --git a/docs/prd/0002-attribute-based-access-control.md b/docs/prd/0002-attribute-based-access-control.md index aca37f0..92bec75 100644 --- a/docs/prd/0002-attribute-based-access-control.md +++ b/docs/prd/0002-attribute-based-access-control.md @@ -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 > 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 > 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 BSN, or advance a manual application; others may not. -- **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 +- **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 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, 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`, registered only under `isDevMode()` in `src/app/app.config.ts:22`). `X-Admin: true` is the parallel 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 anywhere. - 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 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 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 `DashboardViewDto` (`backend/src/BigRegister.Api/Contracts/Dtos.cs:25-27`), computed by `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 1. Support all four control types above — **capability gating, data-scoping, field/PII-level, and 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. 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. @@ -69,7 +69,7 @@ PRD extends that same seam from *business* decisions to *authorization* decision ## 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 authn middleware. - **A general policy engine (OPA/Cedar/XACML).** We express access as named **capabilities** computed @@ -85,17 +85,17 @@ PRD extends that same seam from *business* decisions to *authorization* decision ## 4. Personas & attributes 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. An access decision is a function of four attribute sets: -| 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 | -| **Resource** | the domain entity | owner id, region, sensitivity class (contains BSN / art. 9 data), status | -| **Action** | the request | `view`, `edit`, `approve`, `reveal-bsn`, `beoordelen` | -| **Environment** | the request context | MFA/assurance level, time-of-day, break-glass flag | +| 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 | +| **Resource** | the domain entity | owner id, region, sensitivity class (contains BSN / art. 9 data), status | +| **Action** | the request | `view`, `edit`, `approve`, `reveal-bsn`, `beoordelen` | +| **Environment** | the request context | MFA/assurance level, time-of-day, break-glass flag | > AD owns only the first column's first row (coarse roles). Everything else is the app's overlay and > the entity's own attributes — the reason a role alone is too blunt. @@ -124,7 +124,7 @@ from `currentRole()` — this PRD moves that authority to the server flag.) ### 5b. Data-scoping (row-level) 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 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. > **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). ## 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 FE never sees the merge. - **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, `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. diff --git a/docs/ui-ux-audit.md b/docs/ui-ux-audit.md index 2b7c256..396cd83 100644 --- a/docs/ui-ux-audit.md +++ b/docs/ui-ux-audit.md @@ -24,82 +24,82 @@ onto: spacing `--rhc-space-max-{sm..5xl}`, type `--rhc-text-font-size-*` / `--rhc-color-{lintblauw,donkerblauw,cool-grey}-*` / `--rhc-color-wit`, radius `--rhc-border-radius-*`. -| # | Pri | Finding | Resolves to | -|---|-----|---------|-------------| -| 1.1 | **H** | `var(--rhc-color-grijs-700)` is referenced in 3 wizards but **`--rhc-color-grijs-*` does not exist** in the package → the "Stap X van Y" text falls back to the default color, not grey. | `--rhc-color-foreground-subtle` | -| 1.2 | **H** | `site-header`/`site-footer` set bg to `var(--rhc-color-lintblauw-700,#154273)` / `…-900,#01689b` — `lintblauw-900` doesn't exist (only 50–700), so the **hex fallback renders**. | header `--rhc-color-lintblauw-700`; footer `--rhc-color-donkerblauw-700`; text `--rhc-color-wit` | -| 1.3 | M | Raw spacing everywhere: `0.25/0.5/0.75/1/1.5/2/3rem` as `gap`/`margin`/`padding` (≈30 occurrences). | `--rhc-space-max-{sm,md,lg,xl,2xl,3xl,5xl}` | -| 1.4 | M | Inconsistent form/content widths: `28rem`, `30rem`, `32rem`, `64rem` as raw `max-width`. | app width tokens in `styles.scss` (`--app-form-max`, `--app-content-max`) mapped once | -| 1.5 | M | `spinner` & `skeleton` hardcode greys/accent (`#cad0d6`, `#e8ebee`, `#f3f5f6`) and the spinner accent via bogus `--rhc-color-lintblauw-700,#154273` fallback. | `--rhc-color-cool-grey-{200,300}`, `--rhc-color-lintblauw-700` | -| 1.6 | L | `site-header` hardcodes `font-weight:700/400`, `font-size:0.9rem`, `opacity:0.85`. | `--rhc-text-font-weight-{bold,regular}`, `--rhc-text-font-size-sm` | +| # | Pri | Finding | Resolves to | +| --- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| 1.1 | **H** | `var(--rhc-color-grijs-700)` is referenced in 3 wizards but **`--rhc-color-grijs-*` does not exist** in the package → the "Stap X van Y" text falls back to the default color, not grey. | `--rhc-color-foreground-subtle` | +| 1.2 | **H** | `site-header`/`site-footer` set bg to `var(--rhc-color-lintblauw-700,#154273)` / `…-900,#01689b` — `lintblauw-900` doesn't exist (only 50–700), so the **hex fallback renders**. | header `--rhc-color-lintblauw-700`; footer `--rhc-color-donkerblauw-700`; text `--rhc-color-wit` | +| 1.3 | M | Raw spacing everywhere: `0.25/0.5/0.75/1/1.5/2/3rem` as `gap`/`margin`/`padding` (≈30 occurrences). | `--rhc-space-max-{sm,md,lg,xl,2xl,3xl,5xl}` | +| 1.4 | M | Inconsistent form/content widths: `28rem`, `30rem`, `32rem`, `64rem` as raw `max-width`. | app width tokens in `styles.scss` (`--app-form-max`, `--app-content-max`) mapped once | +| 1.5 | M | `spinner` & `skeleton` hardcode greys/accent (`#cad0d6`, `#e8ebee`, `#f3f5f6`) and the spinner accent via bogus `--rhc-color-lintblauw-700,#154273` fallback. | `--rhc-color-cool-grey-{200,300}`, `--rhc-color-lintblauw-700` | +| 1.6 | L | `site-header` hardcodes `font-weight:700/400`, `font-size:0.9rem`, `opacity:0.85`. | `--rhc-text-font-weight-{bold,regular}`, `--rhc-text-font-size-sm` | ## 2. Typography & heading hierarchy -| # | Pri | Finding | Resolves to | -|---|-----|---------|-------------| +| # | Pri | Finding | Resolves to | +| --- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | | 2.1 | **H** | Each page has one `

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

`** — step title is a plain `

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

` via `app-heading [level]="2"` | -| 2.2 | M | Type scale not applied to bespoke text (header wordmark uses raw sizes). | `--rhc-text-font-size-*`, `app-heading` | +| 2.2 | M | Type scale not applied to bespoke text (header wordmark uses raw sizes). | `--rhc-text-font-size-*`, `app-heading` | ## 3. Color & contrast -| # | Pri | Finding | Resolves to | -|---|-----|---------|-------------| -| 3.1 | **H** | Because of 1.1/1.2 the actual rendered colors are partly accidental (default text color, hex fallbacks) — contrast is unverified. | map to real tokens, then verify AA | -| 3.2 | M | Palette discipline: confirm a single primary blue (lintblauw) + neutrals + status-only accents (`alert` types ok/info/warning/error already map to Utrecht alert variants). | keep alert variants; route blues to lintblauw/donkerblauw tokens | +| # | Pri | Finding | Resolves to | +| --- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| 3.1 | **H** | Because of 1.1/1.2 the actual rendered colors are partly accidental (default text color, hex fallbacks) — contrast is unverified. | map to real tokens, then verify AA | +| 3.2 | M | Palette discipline: confirm a single primary blue (lintblauw) + neutrals + status-only accents (`alert` types ok/info/warning/error already map to Utrecht alert variants). | keep alert variants; route blues to lintblauw/donkerblauw tokens | ## 4. Spacing & layout -| # | Pri | Finding | Resolves to | -|---|-----|---------|-------------| -| 4.1 | M | Repeated inline idioms: `display:flex;gap:0.5rem;margin-top:1rem` (button rows, 6×), `max-width:30rem` (forms, 3×), `margin:1rem 0` (summaries). | utility classes `.app-button-row`, `.app-form`, `.app-stack` in `styles.scss` | -| 4.2 | L | `shell` uses a custom `--app-content-max:64rem` defined inline. | promote to the `styles.scss` token layer | +| # | Pri | Finding | Resolves to | +| --- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | +| 4.1 | M | Repeated inline idioms: `display:flex;gap:0.5rem;margin-top:1rem` (button rows, 6×), `max-width:30rem` (forms, 3×), `margin:1rem 0` (summaries). | utility classes `.app-button-row`, `.app-form`, `.app-stack` in `styles.scss` | +| 4.2 | L | `shell` uses a custom `--app-content-max:64rem` defined inline. | promote to the `styles.scss` token layer | ## 5. Component reuse -| # | Pri | Finding | Resolves to | -|---|-----|---------|-------------| -| 5.1 | L | `intake-wizard` review step uses bespoke `
` instead of `app-data-row` (out of scope here; note for later). | `app-data-row` | -| 5.2 | M | Button rows / form containers are ad-hoc inline layout rather than a shared idiom. | utility classes (4.1) | +| # | Pri | Finding | Resolves to | +| --- | --- | ----------------------------------------------------------------------------------------------------------------------- | --------------------- | +| 5.1 | L | `intake-wizard` review step uses bespoke `
` instead of `app-data-row` (out of scope here; note for later). | `app-data-row` | +| 5.2 | M | Button rows / form containers are ad-hoc inline layout rather than a shared idiom. | utility classes (4.1) | ## 6. Form, validation & status patterns -| # | Pri | Finding | Resolves to | -|---|-----|---------|-------------| -| 6.1 | **H** | `form-field` renders the error with `role="alert"` but **no `id`**, and the projected input has **no `aria-describedby`** → error not programmatically linked. | error `id="${fieldId}-error"`; input `aria-describedby` | -| 6.2 | **H** | `text-input` never sets `aria-invalid` even when `invalid()` is true. | `[attr.aria-invalid]` | -| 6.3 | **H** | `radio-group` has `role="radiogroup"` but **no accessible name** and no invalid state. | `aria-labelledby="${name}-label"`, add `invalid` input → `aria-invalid`/`aria-describedby` | -| 6.4 | M | Async states (`app-async`) render but aren't announced (no live region) — SR users miss loading→loaded/empty/error. | wrap in `aria-live="polite"` + `aria-busy` | -| 6.5 | L | No error-summary pattern; per-field inline errors only. Acceptable for short steps; revisit if steps grow. | (defer) | +| # | Pri | Finding | Resolves to | +| --- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| 6.1 | **H** | `form-field` renders the error with `role="alert"` but **no `id`**, and the projected input has **no `aria-describedby`** → error not programmatically linked. | error `id="${fieldId}-error"`; input `aria-describedby` | +| 6.2 | **H** | `text-input` never sets `aria-invalid` even when `invalid()` is true. | `[attr.aria-invalid]` | +| 6.3 | **H** | `radio-group` has `role="radiogroup"` but **no accessible name** and no invalid state. | `aria-labelledby="${name}-label"`, add `invalid` input → `aria-invalid`/`aria-describedby` | +| 6.4 | M | Async states (`app-async`) render but aren't announced (no live region) — SR users miss loading→loaded/empty/error. | wrap in `aria-live="polite"` + `aria-busy` | +| 6.5 | L | No error-summary pattern; per-field inline errors only. Acceptable for short steps; revisit if steps grow. | (defer) | ## 7. Page chrome -| # | Pri | Finding | Resolves to | -|---|-----|---------|-------------| -| 7.1 | M | No breadcrumb / location context in the portal chrome. | new `app-breadcrumb` (`.rhc-breadcrumb-nav`), wired via `page-shell` | -| 7.2 | M | Step progress is non-semantic text. | new `app-stepper` (`
    `, `aria-current="step"`) | -| 7.3 | L | Skip-link uses `left:-999px` (works) and landmarks (`header/main/footer`) are correct. | keep; tokenize offset | +| # | Pri | Finding | Resolves to | +| --- | --- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| 7.1 | M | No breadcrumb / location context in the portal chrome. | new `app-breadcrumb` (`.rhc-breadcrumb-nav`), wired via `page-shell` | +| 7.2 | M | Step progress is non-semantic text. | new `app-stepper` (`
      `, `aria-current="step"`) | +| 7.3 | L | Skip-link uses `left:-999px` (works) and landmarks (`header/main/footer`) are correct. | keep; tokenize offset | ## 8. Copy & tone -| # | Pri | Finding | Resolves to | -|---|-----|---------|-------------| -| 8.1 | M | Microcopy is functional but informal/uneven (labels, helper text, button text, the manual-diploma warning, the controle summary). | formal, plain official Dutch; consistent with domain terms (registratie wizard + chrome only) | +| # | Pri | Finding | Resolves to | +| --- | --- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| 8.1 | M | Microcopy is functional but informal/uneven (labels, helper text, button text, the manual-diploma warning, the controle summary). | formal, plain official Dutch; consistent with domain terms (registratie wizard + chrome only) | ## 9. Accessibility (WCAG 2.1 AA) — consolidated -| # | Pri | Finding | Resolves to | -|---|-----|---------|-------------| -| 9.1 | **H** | No focus management: after Next/Back focus stays on the button; SR/keyboard users aren't moved to the new step. | move focus to the step `

      ` (`tabindex="-1"`) on `cursor` change | -| 9.2 | **H** | Error/label/invalid association missing (6.1–6.3). | as above | -| 9.3 | M | Step changes & async states not announced (6.4). | `aria-live` | -| 9.4 | M | `skeleton` placeholders are announced as content. | `aria-hidden="true"` | -| 9.5 | L | No automated a11y in CI; only Storybook `addon-a11y` (axe) exists. | add a11y `parameters` in `preview.ts`; keep manual keyboard/SR pass; no new deps | +| # | Pri | Finding | Resolves to | +| --- | ----- | --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| 9.1 | **H** | No focus management: after Next/Back focus stays on the button; SR/keyboard users aren't moved to the new step. | move focus to the step `

      ` (`tabindex="-1"`) on `cursor` change | +| 9.2 | **H** | Error/label/invalid association missing (6.1–6.3). | as above | +| 9.3 | M | Step changes & async states not announced (6.4). | `aria-live` | +| 9.4 | M | `skeleton` placeholders are announced as content. | `aria-hidden="true"` | +| 9.5 | L | No automated a11y in CI; only Storybook `addon-a11y` (axe) exists. | add a11y `parameters` in `preview.ts`; keep manual keyboard/SR pass; no new deps | ## 10. Responsive -| # | Pri | Finding | Resolves to | -|---|-----|---------|-------------| -| 10.1 | M | Fixed `rem` widths and inline flex rows; verify reflow + ≥24px targets at narrow widths. | token widths + `flex-wrap` on button rows; manual check | +| # | 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 | --- diff --git a/eslint.config.mjs b/eslint.config.mjs index 53f3f38..d7d4c53 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -42,7 +42,14 @@ export default [ rules: { 'no-restricted-imports': [ '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: { 'no-restricted-imports': [ '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: { 'no-restricted-imports': [ '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: { 'no-restricted-imports': [ '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: { 'no-restricted-imports': [ '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: [ { - group: ['@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.', + group: [ + '@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'], 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/*', ], 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`.)', }, ], }, diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 2bd80ef..0f6bfcb 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -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 type { ActivatedRouteSnapshot } from '@angular/router'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; @@ -43,5 +48,5 @@ export const appConfig: ApplicationConfig = { provideApiClient(), { provide: SESSION_PORT, useExisting: SessionStore }, { provide: LOCALE_ID, useValue: 'nl' }, - ] + ], }; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index cced12a..7616548 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -8,15 +8,53 @@ export const routes: Routes = [ component: ShellComponent, // persistent header/footer; only children swap children: [ { 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: '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: 'login', + loadComponent: () => import('@auth/ui/login.page').then((m) => m.LoginPage), + }, + { + path: 'dashboard', + canActivate: [authGuard], + loadComponent: () => import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage), + }, + { + path: 'registratie', + canActivate: [authGuard], + loadComponent: () => + import('@registratie/ui/registration-detail.page').then((m) => m.RegistrationDetailPage), + }, + { + path: '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' }, ], }, diff --git a/src/app/auth/infrastructure/digid.adapter.ts b/src/app/auth/infrastructure/digid.adapter.ts index 6be75a6..489bec3 100644 --- a/src/app/auth/infrastructure/digid.adapter.ts +++ b/src/app/auth/infrastructure/digid.adapter.ts @@ -9,7 +9,8 @@ export class DigidAdapter { // Swap for a real OIDC redirect flow when there's a backend. async authenticate(bsn: string): Promise> { 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' }); } } diff --git a/src/app/auth/ui/login-form/login-form.component.ts b/src/app/auth/ui/login-form/login-form.component.ts index c9599a9..8927007 100644 --- a/src/app/auth/ui/login-form/login-form.component.ts +++ b/src/app/auth/ui/login-form/login-form.component.ts @@ -11,10 +11,19 @@ import { ButtonComponent } from '@shared/ui/button/button.component'; template: `
      -
      * verplichte velden
      +
      + * verplichte velden +
      - + @@ -22,7 +31,9 @@ import { ButtonComponent } from '@shared/ui/button/button.component'; - Inloggen met DigiD + Inloggen met DigiD
      `, }) diff --git a/src/app/auth/ui/login.page.ts b/src/app/auth/ui/login.page.ts index 1bb7b4b..59671f9 100644 --- a/src/app/auth/ui/login.page.ts +++ b/src/app/auth/ui/login.page.ts @@ -9,9 +9,16 @@ import { SessionStore } from '@auth/application/session.store'; selector: 'app-login-page', imports: [PageShellComponent, AlertComponent, LoginFormComponent], template: ` - - @if (error()) { {{ error() }} } + + @if (error()) { + {{ error() }} + } `, diff --git a/src/app/brief/application/brief.store.ts b/src/app/brief/application/brief.store.ts index 703d5f4..424931e 100644 --- a/src/app/brief/application/brief.store.ts +++ b/src/app/brief/application/brief.store.ts @@ -3,7 +3,13 @@ import { Result } from '@shared/kernel/fp'; import { createStore } from '@shared/application/store'; import { Role } from '@shared/domain/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 { 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). */ readonly editable = computed(() => { 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 unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : [])); @@ -46,7 +54,12 @@ export class BriefStore { async 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 }); } @@ -84,7 +97,12 @@ export class BriefStore { const r = await this.adapter.reset(); this.busy.set(false); 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); } @@ -119,7 +137,12 @@ export class BriefStore { this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt }); break; 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; case 'sent': this.store.dispatch({ tag: 'Sent', at: s.sentAt }); diff --git a/src/app/brief/domain/brief.machine.spec.ts b/src/app/brief/domain/brief.machine.spec.ts index 7b35b78..531828b 100644 --- a/src/app/brief/domain/brief.machine.spec.ts +++ b/src/app/brief/domain/brief.machine.spec.ts @@ -9,7 +9,9 @@ const placeholders: PlaceholderDef[] = [ { 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 => ({ 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', brief: briefWith(status, sections), availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')], @@ -46,14 +51,27 @@ const sectionBlocks = (s: BriefState, key: string) => describe('brief.machine reduce', () => { it('BriefLoaded / BriefLoadFailed / Seed set state directly', () => { - expect(reduce(initialLoading(), { tag: 'BriefLoaded', brief: briefWith({ tag: 'draft' }), availablePassages: [] }).tag).toBe('loaded'); - expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({ tag: 'failed', reason: 'x' }); + expect( + 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(); expect(reduce(initialLoading(), { tag: 'Seed', state: seeded })).toBe(seeded); }); 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'); expect(blocks.map((b) => b.blockId)).toEqual(['local-1', 'local-2']); 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', () => { 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. (passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED'; const block = sectionBlocks(s, 'aanhef')[0]; @@ -77,7 +99,11 @@ describe('brief.machine reduce', () => { }); 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') }); const block = sectionBlocks(s, 'aanhef')[0]; expect(block.type === 'passage' && block.edited).toBe(true); @@ -85,7 +111,11 @@ describe('brief.machine reduce', () => { }); 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 }); expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-2', 'local-1']); 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)', () => { 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: [] }, ]; const s = loaded({ tag: 'draft' }, lockedSections); // 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. - 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: '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: '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 const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' }); expect(sectionBlocks(edited, 'kern')).toHaveLength(1); @@ -116,7 +162,12 @@ describe('brief.machine reduce', () => { }); 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' }); expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft'); expect(sectionBlocks(next, 'slot')).toHaveLength(1); @@ -128,7 +179,11 @@ describe('brief.machine reduce', () => { // fill the required section, then submit const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' }); 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', () => { @@ -136,10 +191,19 @@ describe('brief.machine reduce', () => { // approve from draft is a no-op expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't' })).toEqual(loaded()); 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 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 expect(reduce(submitted, { tag: 'Sent', at: 't' })).toBe(submitted); const sent = reduce(approved, { tag: 'Sent', at: 't3' }); diff --git a/src/app/brief/domain/brief.machine.ts b/src/app/brief/domain/brief.machine.ts index f5163da..7d93763 100644 --- a/src/app/brief/domain/brief.machine.ts +++ b/src/app/brief/domain/brief.machine.ts @@ -1,5 +1,13 @@ 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'; /** @@ -59,8 +67,15 @@ function nextLocalIndex(brief: Brief): number { return max + 1; } -function mapSection(brief: Brief, sectionKey: string, f: (s: LetterSection) => LetterSection): Brief { - return { ...brief, sections: brief.sections.map((s) => (s.sectionKey === sectionKey ? f(s) : s)) }; +function mapSection( + 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. */ @@ -86,7 +101,11 @@ function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState { 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); // 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). @@ -102,7 +121,11 @@ function insertPassages(brief: Brief, sectionKey: string, passages: readonly Lib } 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] })); } @@ -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); if (from === -1) return [...blocks]; 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 // Msg reaches the reducer. The UI already hides controls for locked sections. 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': - 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': 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': return withEdit(s, (b) => @@ -157,18 +190,34 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState { return withEdit(s, (b) => isSectionEditable(b, sectionKeyOfBlock(b, m.blockId)) ? 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, ); case 'Submitted': // 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': - 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': - 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': return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at })); diff --git a/src/app/brief/domain/brief.spec.ts b/src/app/brief/domain/brief.spec.ts index 2802e09..d3b6a34 100644 --- a/src/app/brief/domain/brief.spec.ts +++ b/src/app/brief/domain/brief.spec.ts @@ -1,5 +1,12 @@ 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 { RichTextBlock } from '@shared/kernel/rich-text'; @@ -19,21 +26,47 @@ const passage = (blockId: string, ...keys: string[]): LetterBlock => ({ }); 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', () => { it('unresolvedPlaceholders returns deduped manual keys only (auto excluded)', () => { 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 }); it('allDiagnostics flattens across sections and blocks', () => { 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); 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', () => { - expect(canSubmit(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); + expect( + canSubmit( + 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); }); }); diff --git a/src/app/brief/domain/brief.ts b/src/app/brief/domain/brief.ts index 77dcde3..757e5ca 100644 --- a/src/app/brief/domain/brief.ts +++ b/src/app/brief/domain/brief.ts @@ -59,7 +59,12 @@ export type BriefStatus = | { readonly tag: 'draft' } | { readonly tag: 'submitted'; readonly submittedBy: string; readonly submittedAt: 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 }; 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 diagnostics panel renders and what the send gate checks. */ 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 { diff --git a/src/app/brief/domain/placeholders.spec.ts b/src/app/brief/domain/placeholders.spec.ts index 1b17e92..1c7e1ac 100644 --- a/src/app/brief/domain/placeholders.spec.ts +++ b/src/app/brief/domain/placeholders.spec.ts @@ -9,8 +9,12 @@ const valid: PlaceholderDef[] = [ { key: 'niet_invulbaar', label: 'Niet invulbaar', autoResolvable: true, fillable: false }, ]; -const withPlaceholder = (key: string): RichTextBlock => ({ paragraphs: [{ nodes: [{ type: 'placeholder', key }] }] }); -const withText = (text: string): RichTextBlock => ({ paragraphs: [{ nodes: [{ type: 'text', text }] }] }); +const withPlaceholder = (key: string): RichTextBlock => ({ + paragraphs: [{ nodes: [{ type: 'placeholder', key }] }], +}); +const withText = (text: string): RichTextBlock => ({ + paragraphs: [{ nodes: [{ type: 'text', text }] }], +}); describe('lintPlaceholders', () => { it('clean content (auto-resolvable, fillable, current key) yields no diagnostics', () => { @@ -55,10 +59,17 @@ describe('lintPlaceholders', () => { const content: RichTextBlock = { paragraphs: [ { 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']); }); diff --git a/src/app/brief/domain/placeholders.ts b/src/app/brief/domain/placeholders.ts index 0c15188..d3fb385 100644 --- a/src/app/brief/domain/placeholders.ts +++ b/src/app/brief/domain/placeholders.ts @@ -77,7 +77,13 @@ function messageFor(code: DiagnosticCode, key?: string): string { } 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), + }; } /** diff --git a/src/app/brief/infrastructure/brief.adapter.spec.ts b/src/app/brief/infrastructure/brief.adapter.spec.ts index ff49852..1ed5304 100644 --- a/src/app/brief/infrastructure/brief.adapter.spec.ts +++ b/src/app/brief/infrastructure/brief.adapter.spec.ts @@ -19,14 +19,32 @@ const view: BriefViewDto = { title: 'Aanhef', required: true, 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: [ - { 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); expect(r.ok).toBe(true); 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; expect(passage.type === 'passage' && passage.edited).toBe(true); 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', () => { - expect(parseNode({ type: 'text', text: 'x' })).toEqual({ ok: true, value: { type: 'text', text: 'x' } }); - expect(parseNode({ type: 'placeholder', key: 'k' })).toEqual({ ok: true, value: { type: 'placeholder', key: 'k' } }); + expect(parseNode({ type: 'text', text: 'x' })).toEqual({ + 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: 'text' }).ok).toBe(false); // missing text expect(parseNode({ type: 'bogus' } as never).ok).toBe(false); @@ -87,13 +120,24 @@ describe('brief.adapter parse boundary', () => { const [aanhef, kern] = r.value.sections; expect(aanhef.locked).toBe(true); 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', () => { const r = parseBrief({ ...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); }); diff --git a/src/app/brief/infrastructure/brief.adapter.ts b/src/app/brief/infrastructure/brief.adapter.ts index 44a15ae..a3b0c46 100644 --- a/src/app/brief/infrastructure/brief.adapter.ts +++ b/src/app/brief/infrastructure/brief.adapter.ts @@ -13,7 +13,14 @@ import { RichTextBlockDto, RichTextNodeDto, } 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 { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text'; @@ -43,7 +50,10 @@ export class BriefAdapter { } async save(sections: readonly LetterSection[]): Promise> { - 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; } @@ -83,10 +93,16 @@ export function parseNode(dto: RichTextNodeDto): Result { case 'text': { if (typeof dto.text !== 'string') return err('node: text missing text'); 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': - 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': return ok({ type: 'lineBreak' }); default: @@ -94,7 +110,9 @@ export function parseNode(dto: RichTextNodeDto): Result { } } -export function parseBlockContent(dto: RichTextBlockDto | undefined): Result { +export function parseBlockContent( + dto: RichTextBlockDto | undefined, +): Result { if (!dto || !Array.isArray(dto.paragraphs)) return err('content: paragraphs not an array'); const paragraphs: Paragraph[] = []; for (const p of dto.paragraphs) { @@ -116,7 +134,8 @@ function parseBlock(dto: LetterBlockDto): Result { if (!content.ok) return content; switch (dto.type) { 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({ type: 'passage', blockId: dto.blockId, @@ -133,7 +152,11 @@ function parseBlock(dto: LetterBlockDto): Result { } function parseSection(dto: LetterSectionDto): Result { - 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'); } const blocks: LetterBlock[] = []; @@ -142,11 +165,21 @@ function parseSection(dto: LetterSectionDto): Result { if (!parsed.ok) return parsed; 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 { - 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 ok({ @@ -163,14 +196,26 @@ export function parseStatus(dto: BriefStatusDto | undefined): Result { - if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep')) return err('passage: bad shape'); - if (typeof dto.sectionKey !== 'string' || typeof dto.label !== 'string' || typeof dto.version !== 'number') return err('passage: bad shape'); + if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep')) + 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); if (!content.ok) return content; return ok({ @@ -196,7 +247,12 @@ function parsePassage(dto: LibraryPassageDto): Result { } export function parseBrief(dto: BriefDto): Result { - 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'); } const status = parseStatus(dto.status); @@ -252,15 +308,33 @@ function nodeToDto(n: RichTextNode): RichTextNodeDto { } 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 { 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) }; } 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), + }; } diff --git a/src/app/brief/ui/brief.page.ts b/src/app/brief/ui/brief.page.ts index 776c638..e2667ac 100644 --- a/src/app/brief/ui/brief.page.ts +++ b/src/app/brief/ui/brief.page.ts @@ -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. */ @Component({ selector: 'app-brief-page', - imports: [PageShellComponent, SpinnerComponent, 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} - `], + imports: [ + PageShellComponent, + SpinnerComponent, + 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: ` - - @if (lastError(); as err) { {{ err }} } + + @if (lastError(); as err) { + {{ err }} + } @switch (model().tag) { - @case ('loading') { } + @case ('loading') { + + } @case ('failed') { {{ failedText }} {{ retryText }} @@ -32,7 +50,9 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos @case ('loaded') {
      {{ saveText() }} - {{ resetLabel }} + {{ + resetLabel + }}
      + (send)="store.send()" + /> } }
      @@ -70,10 +91,14 @@ export class BriefPage { /** Debounced-save state, surfaced in a polite live region. */ protected saveText = computed(() => { switch (this.store.saveState()) { - case 'saving': return this.savingText; - case 'saved': return this.savedText; - case 'error': return this.saveErrorText; - default: return ''; + case 'saving': + return this.savingText; + case 'saved': + return this.savedText; + case 'error': + return this.saveErrorText; + default: + return ''; } }); diff --git a/src/app/brief/ui/diagnostics-panel/diagnostics-panel.component.ts b/src/app/brief/ui/diagnostics-panel/diagnostics-panel.component.ts index 4cf9b67..27391c9 100644 --- a/src/app/brief/ui/diagnostics-panel/diagnostics-panel.component.ts +++ b/src/app/brief/ui/diagnostics-panel/diagnostics-panel.component.ts @@ -8,17 +8,38 @@ import { Diagnostic } from '@brief/domain/placeholders'; @Component({ selector: 'app-diagnostics-panel', imports: [AlertComponent], - styles: [` - :host{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} - `], + styles: [ + ` + :host { + 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: ` @if (errors().length) { {{ errorsTitle() }}
        - @for (d of errors(); track $index) {
      • } + @for (d of errors(); track $index) { +
      • + +
      • + }
      } @@ -26,7 +47,11 @@ import { Diagnostic } from '@brief/domain/placeholders'; {{ warningsTitle() }}
        - @for (d of warnings(); track $index) {
      • } + @for (d of warnings(); track $index) { +
      • + +
      • + }
      } diff --git a/src/app/brief/ui/letter-block/letter-block.component.ts b/src/app/brief/ui/letter-block/letter-block.component.ts index 479c8df..937079f 100644 --- a/src/app/brief/ui/letter-block/letter-block.component.ts +++ b/src/app/brief/ui/letter-block/letter-block.component.ts @@ -1,6 +1,9 @@ import { Component, computed, input, output } from '@angular/core'; 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 { LetterBlock } from '@brief/domain/brief'; @@ -9,21 +12,43 @@ import { LetterBlock } from '@brief/domain/brief'; @Component({ selector: 'app-letter-block', imports: [RichTextEditorComponent, ButtonComponent], - 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)} - .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)} - `], + 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); + } + .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: `
      {{ provenance() }} @if (editable()) { - Omhoog - Omlaag - Verwijderen + Omhoog + Omlaag + Verwijderen }
      @@ -31,7 +56,8 @@ import { LetterBlock } from '@brief/domain/brief'; [content]="block().content" [placeholders]="placeholders()" [editable]="editable()" - (contentChanged)="contentChanged.emit($event)" /> + (contentChanged)="contentChanged.emit($event)" + />
      `, }) diff --git a/src/app/brief/ui/letter-composer/letter-composer.component.ts b/src/app/brief/ui/letter-composer/letter-composer.component.ts index b4c3614..4ba573c 100644 --- a/src/app/brief/ui/letter-composer/letter-composer.component.ts +++ b/src/app/brief/ui/letter-composer/letter-composer.component.ts @@ -19,16 +19,44 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti @Component({ selector: 'app-letter-composer', imports: [ - HeadingComponent, StatusBadgeComponent, ButtonComponent, AlertComponent, - LetterSectionComponent, LetterPreviewComponent, DiagnosticsPanelComponent, RejectionCommentsComponent, + HeadingComponent, + 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: `
      {{ title() }} @@ -47,7 +75,8 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti [availablePassages]="availablePassages()" [placeholders]="menu()" [editable]="!section.locked" - (edit)="edit.emit($event)" /> + (edit)="edit.emit($event)" + /> }
      } @else { @@ -62,25 +91,41 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti @switch (status()) { @case ('draft') { @if (editable()) { - {{ submitLabel() }} - @if (!canSubmit()) { {{ submitHint() }} } + {{ submitLabel() }} + @if (!canSubmit()) { + {{ submitHint() }} + } } } @case ('rejected') { @if (editable()) { - {{ resubmitLabel() }} + {{ resubmitLabel() }} } } @case ('submitted') { @if (role() === 'approver') { - {{ approveLabel() }} + {{ + approveLabel() + }} } @else { {{ awaitingText() }} } } @case ('approved') { - {{ sendLabel() }} + {{ + sendLabel() + }} } @case ('sent') { {{ sentText() }} @@ -108,10 +153,14 @@ export class LetterComposerComponent { title = input($localize`:@@brief.title:Brief aan de zorgverlener`); submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`); 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`); 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.`); protected status = computed(() => this.brief().status.tag); @@ -130,21 +179,30 @@ export class LetterComposerComponent { protected statusLabel = computed(() => { switch (this.status()) { - case 'draft': return $localize`:@@brief.status.draft:Concept`; - case 'submitted': return $localize`:@@brief.status.submitted:Ter beoordeling`; - case 'approved': return $localize`:@@brief.status.approved:Goedgekeurd`; - case 'rejected': return $localize`:@@brief.status.rejected:Afgewezen`; - case 'sent': return $localize`:@@brief.status.sent:Verzonden`; + case 'draft': + return $localize`:@@brief.status.draft:Concept`; + case 'submitted': + return $localize`:@@brief.status.submitted:Ter beoordeling`; + 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(() => { switch (this.status()) { - case 'draft': return 'var(--rhc-color-border-strong)'; - case 'submitted': return 'var(--rhc-color-oranje-500)'; + case 'draft': + return 'var(--rhc-color-border-strong)'; + case 'submitted': + return 'var(--rhc-color-oranje-500)'; case 'approved': - case 'sent': return 'var(--rhc-color-groen-500)'; - case 'rejected': return 'var(--rhc-color-rood-500)'; + case 'sent': + return 'var(--rhc-color-groen-500)'; + case 'rejected': + return 'var(--rhc-color-rood-500)'; } }); } diff --git a/src/app/brief/ui/letter-composer/letter-composer.stories.ts b/src/app/brief/ui/letter-composer/letter-composer.stories.ts index 423433a..949eb30 100644 --- a/src/app/brief/ui/letter-composer/letter-composer.stories.ts +++ b/src/app/brief/ui/letter-composer/letter-composer.stories.ts @@ -4,8 +4,33 @@ import { Brief, BriefStatus, LibraryPassage } from '@brief/domain/brief'; import { allDiagnostics } from '@brief/domain/brief'; 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 { @@ -25,22 +50,69 @@ function brief(status: BriefStatus): Brief { title: 'Aanhef', required: 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', title: 'Kern van het besluit', required: true, 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') => ({ - 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: ``, }); @@ -52,7 +124,30 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const DraftDrafter: Story = { render: () => render(brief({ tag: 'draft' }), true, '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') }; +export const DraftDrafter: Story = { + render: () => render(brief({ tag: 'draft' }), true, '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'), +}; diff --git a/src/app/brief/ui/letter-preview/letter-preview.component.ts b/src/app/brief/ui/letter-preview/letter-preview.component.ts index ce6bbed..a5b7953 100644 --- a/src/app/brief/ui/letter-preview/letter-preview.component.ts +++ b/src/app/brief/ui/letter-preview/letter-preview.component.ts @@ -8,7 +8,10 @@ import { Brief, LetterBlock } from '@brief/domain/brief'; import { Diagnostic } from '@brief/domain/placeholders'; /** 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[] { const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = []; @@ -34,25 +37,54 @@ const SAMPLE_VALUES: Record = { @Component({ selector: 'app-letter-preview', imports: [NgTemplateOutlet, HeadingComponent, ButtonComponent, PlaceholderChipComponent], - styles: [` - :host{display:block} - .toolbar{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} - `], + styles: [ + ` + :host { + display: block; + } + .toolbar { + 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: ` @for (node of nodes; track $index) { @switch (node.type) { - @case ('text') { {{ node.text }} } - @case ('lineBreak') {
      } + @case ('text') { + {{ node.text }} + } + @case ('lineBreak') { +
      + } @case ('placeholder') { @if (showSample() && autoFor(node.key)) { {{ sampleFor(node.key) }} } @else { - + } } } @@ -60,7 +92,11 @@ const SAMPLE_VALUES: Record = {
      - + {{ showSample() ? hideSampleLabel() : showSampleLabel() }}
      @@ -72,11 +108,34 @@ const SAMPLE_VALUES: Record = { @for (block of section.blocks; track block.blockId) { @for (seg of segmentsOf(block); track $index) { @if (seg.list === 'bullet') { -
        @for (para of seg.items; track $index) {
      • }
      +
        + @for (para of seg.items; track $index) { +
      • + +
      • + } +
      } @else if (seg.list === 'number') { -
        @for (para of seg.items; track $index) {
      1. }
      +
        + @for (para of seg.items; track $index) { +
      1. + +
      2. + } +
      } @else { -

      +

      + +

      } } } @@ -93,7 +152,11 @@ export class LetterPreviewComponent { hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`); 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 worst = computed(() => { @@ -110,5 +173,6 @@ export class LetterPreviewComponent { protected labelFor = (key: string) => this.defs().get(key)?.label ?? key; protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false; 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)); } diff --git a/src/app/brief/ui/letter-section/letter-section.component.ts b/src/app/brief/ui/letter-section/letter-section.component.ts index 0b73f88..0540355 100644 --- a/src/app/brief/ui/letter-section/letter-section.component.ts +++ b/src/app/brief/ui/letter-section/letter-section.component.ts @@ -14,17 +14,36 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker. @Component({ selector: 'app-letter-section', imports: [ButtonComponent, HeadingComponent, LetterBlockComponent, PassagePickerComponent], - styles: [` - :host{display:block} - .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} - `], + styles: [ + ` + :host { + display: block; + } + .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: ` {{ section().title }} - @if (section().required) { · {{ requiredLabel() }} } + @if (section().required) { + · {{ requiredLabel() }} + }
      @@ -35,7 +54,8 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker. [editable]="editable()" (contentChanged)="onContent(block.blockId, $event)" (removed)="edit.emit({ tag: 'BlockRemoved', blockId: block.blockId })" - (moved)="onMove(block.blockId, $event)" /> + (moved)="onMove(block.blockId, $event)" + /> } @empty {

      {{ emptyLabel() }}

      } @@ -43,8 +63,14 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker. @if (editable()) {
      - {{ addPassageLabel() }} - {{ addFreeLabel() }} + {{ + addPassageLabel() + }} + {{ addFreeLabel() }}
      @if (pickerOpen()) { @@ -65,7 +91,9 @@ export class LetterSectionComponent { addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`); 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) { this.edit.emit({ tag: 'BlockContentEdited', blockId, content }); diff --git a/src/app/brief/ui/passage-picker/passage-picker.component.ts b/src/app/brief/ui/passage-picker/passage-picker.component.ts index 4cfc35a..59b8378 100644 --- a/src/app/brief/ui/passage-picker/passage-picker.component.ts +++ b/src/app/brief/ui/passage-picker/passage-picker.component.ts @@ -10,11 +10,26 @@ import { LibraryPassage } from '@brief/domain/brief'; @Component({ selector: 'app-passage-picker', imports: [FormsModule, CheckboxComponent, ButtonComponent], - 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)} - .scope{color:var(--rhc-color-foreground-subtle)} - `], + 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); + } + .scope { + color: var(--rhc-color-foreground-subtle); + } + `, + ], template: `
        @for (p of passages(); track p.passageId) { @@ -22,12 +37,15 @@ import { LibraryPassage } from '@brief/domain/brief'; + (ngModelChange)="set(p.passageId, $event)" + /> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }} }
      - {{ addLabel() }} ({{ count() }}) + {{ addLabel() }} ({{ count() }}) `, }) export class PassagePickerComponent { diff --git a/src/app/brief/ui/rejection-comments/rejection-comments.component.ts b/src/app/brief/ui/rejection-comments/rejection-comments.component.ts index 101bf0b..40de5f0 100644 --- a/src/app/brief/ui/rejection-comments/rejection-comments.component.ts +++ b/src/app/brief/ui/rejection-comments/rejection-comments.component.ts @@ -8,18 +8,33 @@ import { ButtonComponent } from '@shared/ui/button/button.component'; @Component({ selector: 'app-rejection-comments', imports: [FormsModule, AlertComponent, ButtonComponent], - styles: [` - :host{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} - `], + styles: [ + ` + :host { + 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: ` @if (mode() === 'show') { - {{ rejectedTitle() }} {{ comments() }} + {{ rejectedTitle() }} {{ comments() }} } @else { - {{ rejectLabel() }} + {{ + rejectLabel() + }} } `, }) diff --git a/src/app/herregistratie/application/intake-policy.store.ts b/src/app/herregistratie/application/intake-policy.store.ts index 432af0f..b0fc2b6 100644 --- a/src/app/herregistratie/application/intake-policy.store.ts +++ b/src/app/herregistratie/application/intake-policy.store.ts @@ -15,5 +15,7 @@ export class IntakePolicyStore { private policy = inject(IntakePolicyAdapter); 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, + ); } diff --git a/src/app/herregistratie/domain/herregistratie.machine.spec.ts b/src/app/herregistratie/domain/herregistratie.machine.spec.ts index ea68058..0dd1d9a 100644 --- a/src/app/herregistratie/domain/herregistratie.machine.spec.ts +++ b/src/app/herregistratie/domain/herregistratie.machine.spec.ts @@ -1,11 +1,38 @@ import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; 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 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 }); +const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({ + tag: 'Editing', + 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', () => { it('next advances only when step 1 parses', () => { @@ -76,19 +103,37 @@ describe('reduce (message-driven)', () => { }); it('blocks submit until required documents are satisfied', () => { - const 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] } }); + const 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' }); expect(s.tag).toBe('Editing'); 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' }); expect(s.tag).toBe('Submitting'); expect((s as any).data.documents).toEqual([{ categoryId: 'bewijs', channel: 'post' }]); }); 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'); s = reduce(s, { tag: 'Retry' }); expect(s.tag).toBe('Submitting'); diff --git a/src/app/herregistratie/domain/herregistratie.machine.ts b/src/app/herregistratie/domain/herregistratie.machine.ts index c33d37e..2c457a7 100644 --- a/src/app/herregistratie/domain/herregistratie.machine.ts +++ b/src/app/herregistratie/domain/herregistratie.machine.ts @@ -38,11 +38,23 @@ export type WizardState = | { tag: 'Submitted'; data: Valid } | { 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? */ export function hasProgress(s: Extract): 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. */ @@ -58,7 +70,15 @@ function validate(draft: Draft, upload: UploadState): Result 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) { - 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 }; } @@ -110,7 +130,9 @@ export function upload(s: WizardState, msg: UploadMsg): WizardState { /** Resolve the async submit. Only meaningful while Submitting. */ export function resolve(s: WizardState, r: Result): WizardState { 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. */ diff --git a/src/app/herregistratie/domain/intake.machine.spec.ts b/src/app/herregistratie/domain/intake.machine.spec.ts index 3007c32..ded4ea8 100644 --- a/src/app/herregistratie/domain/intake.machine.spec.ts +++ b/src/app/herregistratie/domain/intake.machine.spec.ts @@ -15,7 +15,13 @@ import { IntakeState, } 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', () => { 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' }, 2000)).toBe(true); // 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 as any).errors.scholingGevolgd).toBeTruthy(); }); @@ -61,7 +69,11 @@ describe('navigation', () => { }); 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 }); @@ -89,7 +101,11 @@ describe('submit', () => { it('reaches Submitting ONLY with valid answers', () => { // 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)); expect(good.tag).toBe('Submitting'); expect((good as any).data.uren).toBe(4160); @@ -98,17 +114,23 @@ describe('submit', () => { it('punten is required only when aanvullende scholing was gevolgd', () => { // scholing = ja but punten missing -> blocked on punten. - const missing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' })); + const missing = submit( + answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' }), + ); expect(missing.tag).toBe('Answering'); expect((missing as any).errors.punten).toBeTruthy(); // scholing = nee -> punten not required, submits without it. - expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag).toBe('Submitting'); + expect( + submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag, + ).toBe('Submitting'); }); it('low hours requires the scholing answer before submit', () => { const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' })); 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 as any).data.aanvullendeScholing).toBe(true); expect((withScholing as any).data.punten).toBe(200); diff --git a/src/app/herregistratie/domain/intake.machine.ts b/src/app/herregistratie/domain/intake.machine.ts index 6c5dedc..ca26d0d 100644 --- a/src/app/herregistratie/domain/intake.machine.ts +++ b/src/app/herregistratie/domain/intake.machine.ts @@ -56,12 +56,24 @@ export const STEPS: StepId[] = ['buitenland', 'werk', 'review']; type Errors = Partial>; 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: 'Submitted'; data: ValidIntake } | { 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). */ export function currentStep(s: Extract): StepId { @@ -79,9 +91,11 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu const errors: Errors = {}; switch (step) { 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') { - 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 ?? ''); if (!u.ok) errors.buitenlandseUren = u.error; } @@ -89,7 +103,8 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu case 'werk': { const u = parseUren(a.uren ?? ''); 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. if (a.scholingGevolgd === 'ja') { const p = parseUren(a.punten ?? ''); @@ -171,7 +186,9 @@ export function submit(s: IntakeState): IntakeState { export function resolve(s: IntakeState, r: Result): IntakeState { 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 = diff --git a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts index 5833250..5ad99fe 100644 --- a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts +++ b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts @@ -3,12 +3,24 @@ import { FormsModule } from '@angular/forms'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { 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 { createStore } from '@shared/application/store'; import { whenTag } from '@shared/kernel/fp'; 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 { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component'; 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. */ @Component({ selector: 'app-herregistratie-wizard', - imports: [FormsModule, FormFieldComponent, TextInputComponent, AlertComponent, ConfirmationComponent, WizardShellComponent, DocumentUploadComponent], + imports: [ + FormsModule, + FormFieldComponent, + TextInputComponent, + AlertComponent, + ConfirmationComponent, + WizardShellComponent, + DocumentUploadComponent, + ], template: ` - + (goToStep)="goToStep($event)" + > @switch (step()) { @case (1) { - - + + - - + + } @case (2) { - - + + } @case (3) { @@ -66,7 +126,8 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload. (removeUpload)="uploadCtl.onRemove($event)" (retryUpload)="uploadCtl.onRetry($event)" (deleteUpload)="uploadCtl.onDelete($event)" - (channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)" /> + (channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)" + /> @if (errDocumenten()) { {{ errDocumenten() }} } @@ -74,7 +135,10 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload. }
      - +
      `, @@ -102,7 +166,9 @@ export class HerregistratieWizardComponent { snapshot: () => { const s = this.state(); 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 }; }, 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). - readonly stepLabels = [$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`]; + readonly stepLabels = [ + $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')); protected step = computed(() => this.editing()?.step ?? 1); - protected draft = computed(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' }); + protected draft = computed( + () => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' }, + ); protected upload = computed(() => this.editing()?.upload ?? initialUpload); protected errUren = computed(() => this.editing()?.errors.uren ?? ''); protected errJaren = computed(() => this.editing()?.errors.jaren ?? ''); @@ -132,20 +208,28 @@ export class HerregistratieWizardComponent { protected stepTitle = computed(() => this.stepTitles[this.step() - 1]); protected primaryLabel = computed(() => { 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. */ protected goToStep(index: number) { 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(() => { switch (this.state().tag) { - case 'Editing': return 'editing'; - case 'Submitting': return 'submitting'; - case 'Submitted': return 'submitted'; - case 'Failed': return 'failed'; + case 'Editing': + return 'editing'; + case 'Submitting': + return 'submitting'; + case 'Submitted': + return 'submitted'; + case 'Failed': + return 'failed'; } }); /** 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 // (`?aanvraag=`) or start fresh. Persistence is the draftSync controller's job. const seeded = this.seed(); - queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume())); + queueMicrotask(() => + seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume(), + ); } onPrimary() { diff --git a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts index 9c521fa..549ed2a 100644 --- a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts +++ b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts @@ -20,12 +20,55 @@ export default meta; type Story = StoryObj; // 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 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 Step1: Story = { + args: { + 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 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' } }, +}; diff --git a/src/app/herregistratie/ui/herregistratie.page.ts b/src/app/herregistratie/ui/herregistratie.page.ts index f943784..9770f97 100644 --- a/src/app/herregistratie/ui/herregistratie.page.ts +++ b/src/app/herregistratie/ui/herregistratie.page.ts @@ -13,7 +13,11 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie selector: 'app-herregistratie-page', imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent], template: ` - + @if (eligible) { diff --git a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts index 5c9843a..744ee52 100644 --- a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts +++ b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts @@ -8,7 +8,12 @@ import { AlertComponent } from '@shared/ui/alert/alert.component'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.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 { whenTag } from '@shared/kernel/fp'; 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). */ @Component({ 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: ` - + (goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })" + > @switch (step()) { @case ('buitenland') { - - + + @if (answers().buitenlandGewerkt === 'ja') { - - + + - - + + } } @case ('werk') { - - + + @if (scholingZichtbaar()) { - - + + } @if (answers().scholingGevolgd === 'ja') { - - + + } } @case ('review') { - Controleer uw antwoorden en dien de aanvraag in. - -
      + Controleer uw antwoorden en dien de aanvraag in. + +
      @if (answers().buitenlandGewerkt === 'ja') { -
      -
      +
      +
      }
      - -
      + +
      @if (scholingZichtbaar()) { -
      +
      } @if (answers().scholingGevolgd === 'ja') { -
      +
      }
      } }
      - +
      - Opnieuw beginnen + Opnieuw beginnen
      @@ -151,13 +286,19 @@ export class IntakeWizardComponent { protected answers = computed(() => this.answering()?.answers ?? {}); protected step = computed(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]); /** Server-owned threshold from the policy endpoint (mirrored into machine state). */ - protected scholingThreshold = computed(() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT); + protected scholingThreshold = computed( + () => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT, + ); /** Whether the inline scholing question is shown (and required) in the 'werk' step. */ protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold())); protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); // --- 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 = { buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`, werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`, @@ -169,13 +310,19 @@ export class IntakeWizardComponent { const next = this.cursor() + 1; 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(() => { switch (this.state().tag) { - case 'Answering': return 'editing'; - case 'Submitting': return 'submitting'; - case 'Submitted': return 'submitted'; - case 'Failed': return 'failed'; + case 'Answering': + return 'editing'; + case 'Submitting': + return 'submitting'; + case 'Submitted': + return 'submitted'; + case 'Failed': + return 'failed'; } }); /** 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 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() { // An explicit seed (stories/tests) wins; otherwise resume the backend draft // (`?aanvraag=`) or start fresh. Persistence is the draftSync controller's job. const seeded = this.seed(); - queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume())); + queueMicrotask(() => + seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume(), + ); // Apply the server-owned threshold into machine state as it arrives. Track // only the policy value; untrack the dispatch (it reads the state signal // internally, which would otherwise make this effect loop on its own write). diff --git a/src/app/herregistratie/ui/intake-wizard/intake-wizard.stories.ts b/src/app/herregistratie/ui/intake-wizard/intake-wizard.stories.ts index 550f6ad..d779edb 100644 --- a/src/app/herregistratie/ui/intake-wizard/intake-wizard.stories.ts +++ b/src/app/herregistratie/ui/intake-wizard/intake-wizard.stories.ts @@ -17,14 +17,26 @@ const meta: Meta = { export default meta; type Story = StoryObj; -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({}) } }; // Inline reveal: country/hours appear within the buitenland step (cursor 0). export const AbroadBranch: Story = { args: { seed: answering({ buitenlandGewerkt: 'ja' }, 0) } }; // Inline reveal: the scholing question appears within the werk step (cursor 1). -export const LowHoursScholing: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '500' }, 1) } }; -export const Review: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: '200' }, 2) } }; +export const LowHoursScholing: Story = { + args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '500' }, 1) }, +}; +export const Review: Story = { + args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: '200' }, 2) }, +}; export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } }; export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } }; -export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } }; +export const Failed: Story = { + args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } }, +}; diff --git a/src/app/herregistratie/ui/intake.page.ts b/src/app/herregistratie/ui/intake.page.ts index 6349c8b..b2c4ba8 100644 --- a/src/app/herregistratie/ui/intake.page.ts +++ b/src/app/herregistratie/ui/intake.page.ts @@ -9,11 +9,14 @@ import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-w selector: 'app-intake-page', imports: [PageShellComponent, AlertComponent, IntakeWizardComponent], template: ` - + - Een paar vragen bepalen welke gegevens we nodig hebben. Afhankelijk van uw - antwoorden verschijnen er extra vragen. Uw antwoorden blijven bewaard als u - de pagina herlaadt. + Een paar vragen bepalen welke gegevens we nodig hebben. Afhankelijk van uw antwoorden + verschijnen er extra vragen. Uw antwoorden blijven bewaard als u de pagina herlaadt.
      diff --git a/src/app/registratie/application/applications.store.ts b/src/app/registratie/application/applications.store.ts index 7f6516a..db96201 100644 --- a/src/app/registratie/application/applications.store.ts +++ b/src/app/registratie/application/applications.store.ts @@ -1,7 +1,10 @@ import { Injectable, inject, signal } from '@angular/core'; import { RemoteData } from '@shared/application/remote-data'; 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; @@ -31,7 +34,11 @@ export class ApplicationsStore { if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' }); try { 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) { this.state.set({ tag: 'Failure', error: e as Error }); } diff --git a/src/app/registratie/application/big-profile.store.ts b/src/app/registratie/application/big-profile.store.ts index a79cbba..f869c9b 100644 --- a/src/app/registratie/application/big-profile.store.ts +++ b/src/app/registratie/application/big-profile.store.ts @@ -4,7 +4,11 @@ import { Aantekening } from '../domain/registration'; import { BigProfile } from '../domain/big-profile'; import { HerregistratieDecisions } from '../contracts/dashboard-view.dto'; 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; @@ -32,14 +36,20 @@ export class BigProfileStore { const rd = fromResource(this.viewRes); if (rd.tag !== 'Success') return rd; const parsed = parseDashboardView(rd.value); - return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) }; + return parsed.ok + ? { tag: 'Success', value: parsed.value } + : { tag: 'Failure', error: new Error(parsed.error) }; }); /** Registration + person, from the single aggregated call. */ - readonly profile = computed>(() => map(this.view(), (v) => v.profile)); + readonly profile = computed>(() => + map(this.view(), (v) => v.profile), + ); /** Server-computed decisions (e.g. herregistratie eligibility) — rendered, not recomputed. */ - readonly decisions = computed>(() => map(this.view(), (v) => v.decisions)); + readonly decisions = computed>(() => + map(this.view(), (v) => v.decisions), + ); /** Specialisms/notes stay a separate stream (they have their own empty state). */ readonly aantekeningen = computed>(() => { diff --git a/src/app/registratie/application/draft-sync.ts b/src/app/registratie/application/draft-sync.ts index 2ee4c08..aea2e47 100644 --- a/src/app/registratie/application/draft-sync.ts +++ b/src/app/registratie/application/draft-sync.ts @@ -2,9 +2,15 @@ import { DestroyRef, effect, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { Result } from '@shared/kernel/fp'; 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 { 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. */ export interface DraftSnapshot { @@ -59,7 +65,12 @@ export function createDraftSync(deps: DraftSyncDeps) { ensuring ??= adapter.create(deps.type).then((newId) => { id = newId; // 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 ensuring; @@ -78,7 +89,12 @@ export function createDraftSync(deps: DraftSyncDeps) { const snap = deps.snapshot(); if (!snap) return; 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 @@ -117,7 +133,9 @@ export function createDraftSync(deps: DraftSyncDeps) { const findConcept = async (): Promise => { try { 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 { return undefined; } @@ -143,7 +161,12 @@ export function createDraftSync(deps: DraftSyncDeps) { if (existing) { await load(existing); // 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; } applyResume(null); @@ -169,7 +192,13 @@ export function createDraftSync(deps: DraftSyncDeps) { id = 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, + }); }, }; } diff --git a/src/app/registratie/application/registratie-lookup.store.ts b/src/app/registratie/application/registratie-lookup.store.ts index 8931dd3..3b93480 100644 --- a/src/app/registratie/application/registratie-lookup.store.ts +++ b/src/app/registratie/application/registratie-lookup.store.ts @@ -36,19 +36,23 @@ export class RegistratieLookupStore { /** The address to prefill the draft with, once BRP resolves with a found address; null otherwise (loading, error, no address, malformed). */ - readonly prefillAdres = computed<{ straat: string; postcode: string; woonplaats: string } | null>(() => { - const json = this.adresRes.value(); - if (json === undefined) return null; - const parsed = parseBrpAddress(json); - return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null; - }); + readonly prefillAdres = computed<{ straat: string; postcode: string; woonplaats: string } | null>( + () => { + const json = this.adresRes.value(); + if (json === undefined) return null; + const parsed = parseBrpAddress(json); + return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null; + }, + ); /** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */ readonly duoLookup = computed>(() => { const rd = fromResource(this.diplomasRes); if (rd.tag !== 'Success') return rd; const parsed = parseDuoLookup(rd.value); - return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) }; + 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. */ diff --git a/src/app/registratie/domain/aanvraag-view.spec.ts b/src/app/registratie/domain/aanvraag-view.spec.ts index b2048bd..8899f1c 100644 --- a/src/app/registratie/domain/aanvraag-view.spec.ts +++ b/src/app/registratie/domain/aanvraag-view.spec.ts @@ -2,33 +2,57 @@ import { describe, it, expect } from 'vitest'; import { submittedRow, detailRows, purposeLabel, statusLabel, TYPE_LABELS } from './aanvraag-view'; 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', () => { 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.subtitle).toBe(purposeLabel('herregistratie')); }); it('status line carries the status label, reference and submit date', () => { - const row = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: false } } as Aanvraag); - expect(row.status).toContain(statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false })); + const row = submittedRow({ + ...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('12 mei 2024'); }); 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'); - 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'); }); }); describe('detailRows', () => { 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); expect(values).toContain(TYPE_LABELS.herregistratie); expect(values).toContain('R2'); @@ -37,7 +61,11 @@ describe('detailRows', () => { }); 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 === '—'); expect(ref).toBeTruthy(); expect(rows.length).toBe(5); diff --git a/src/app/registratie/domain/aanvraag-view.ts b/src/app/registratie/domain/aanvraag-view.ts index 94abf0b..24ec77a 100644 --- a/src/app/registratie/domain/aanvraag-view.ts +++ b/src/app/registratie/domain/aanvraag-view.ts @@ -13,19 +13,26 @@ export const TYPE_LABELS: Record = { /** What the aanvraag is for (shown under the title). */ export function purposeLabel(type: AanvraagType): string { switch (type) { - case 'registratie': return $localize`:@@aanvraag.purpose.registratie:Inschrijving in het BIG-register`; - case 'herregistratie': return $localize`:@@aanvraag.purpose.herregistratie:Verlenging van uw BIG-registratie`; - case 'intake': return $localize`:@@aanvraag.purpose.intake:Intake-vragenlijst voor uw herregistratie`; + case 'registratie': + return $localize`:@@aanvraag.purpose.registratie:Inschrijving in het BIG-register`; + 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). */ export function statusLabel(status: AanvraagStatus): string { switch (status.tag) { - case 'Concept': return $localize`:@@aanvraag.status.concept:Concept (nog niet ingediend)`; - case 'InBehandeling': return $localize`:@@aanvraag.status.inBehandeling:In behandeling`; - case 'Goedgekeurd': return $localize`:@@aanvraag.status.goedgekeurd:Goedgekeurd`; - case 'Afgewezen': return $localize`:@@aanvraag.status.afgewezen:Afgewezen`; + case 'Concept': + return $localize`:@@aanvraag.status.concept:Concept (nog niet ingediend)`; + case 'InBehandeling': + 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 { - 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 @@ -53,10 +62,18 @@ export function submittedRow(a: Aanvraag): AanvraagRow { const parts = [statusLabel(s)]; const ref = referentie(s); 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 (s.tag === 'InBehandeling' && s.manual) parts.push($localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`); + if (a.submittedAt) + 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); - 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). */ @@ -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.waarvoor:Waarvoor`, value: purposeLabel(a.type) }, { 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') { - 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; } diff --git a/src/app/registratie/domain/block-actions.spec.ts b/src/app/registratie/domain/block-actions.spec.ts index 8fc51ae..f312ad8 100644 --- a/src/app/registratie/domain/block-actions.spec.ts +++ b/src/app/registratie/domain/block-actions.spec.ts @@ -3,11 +3,16 @@ import { blockActions } from './block-actions'; describe('blockActions', () => { 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', () => { - 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', () => { diff --git a/src/app/registratie/domain/change-request.machine.spec.ts b/src/app/registratie/domain/change-request.machine.spec.ts index 5e6423d..09db9b4 100644 --- a/src/app/registratie/domain/change-request.machine.spec.ts +++ b/src/app/registratie/domain/change-request.machine.spec.ts @@ -1,7 +1,9 @@ import { describe, it, expect } from 'vitest'; 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', draft: { straat: '', postcode: '', woonplaats: '', ...draft }, errors: {}, @@ -23,13 +25,17 @@ describe('change-request reduce', () => { }); 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 as Extract).data.postcode).toBe('2514 EA'); }); 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' }); 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', () => { - 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); }); }); diff --git a/src/app/registratie/domain/change-request.machine.ts b/src/app/registratie/domain/change-request.machine.ts index 64fb85c..c33d0c9 100644 --- a/src/app/registratie/domain/change-request.machine.ts +++ b/src/app/registratie/domain/change-request.machine.ts @@ -43,7 +43,10 @@ function validate(draft: Draft): Result { if (!straat) errors.straat = $localize`:@@validation.straat:Vul straat en huisnummer in.`; if (!postcode.ok) errors.postcode = postcode.error; 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 }; } @@ -69,7 +72,9 @@ export function reduce(s: State, m: Msg): State { case 'Retry': return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s; 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': return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s; case 'Reset': diff --git a/src/app/registratie/domain/has-progress.spec.ts b/src/app/registratie/domain/has-progress.spec.ts index e82cace..ba51809 100644 --- a/src/app/registratie/domain/has-progress.spec.ts +++ b/src/app/registratie/domain/has-progress.spec.ts @@ -1,8 +1,10 @@ import { describe, it, expect } from 'vitest'; import { hasProgress, initial, RegistratieState } from './registratie-wizard.machine'; -const invullen = (over: Partial>) => - ({ ...(initial as Extract), ...over }); +const invullen = (over: Partial>) => ({ + ...(initial as Extract), + ...over, +}); describe('hasProgress', () => { it('is false for a fresh wizard', () => { @@ -10,13 +12,23 @@ describe('hasProgress', () => { }); 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); }); 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({ draft: { correspondentie: 'post', antwoorden: {} } }))).toBe(true); + expect(hasProgress(invullen({ draft: { correspondentie: 'post', antwoorden: {} } }))).toBe( + true, + ); expect(hasProgress(invullen({ draft: { diplomaId: 'd1', antwoorden: {} } }))).toBe(true); }); }); diff --git a/src/app/registratie/domain/registratie-wizard.machine.spec.ts b/src/app/registratie/domain/registratie-wizard.machine.spec.ts index a2ccc74..d2cf6f6 100644 --- a/src/app/registratie/domain/registratie-wizard.machine.spec.ts +++ b/src/app/registratie/domain/registratie-wizard.machine.spec.ts @@ -29,8 +29,19 @@ const invullen = (draft: Partial, cursor = 0): RegistratieState => ({ upload: initialUpload, }); -const validAdres = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', correspondentie: 'post' as const, adresHerkomst: 'brp' as const }; -const validDraft: Partial = { ...validAdres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo' }; +const validAdres = { + straat: 'Lange Voorhout 9', + postcode: '2514 EA', + woonplaats: 'Den Haag', + correspondentie: 'post' as const, + adresHerkomst: 'brp' as const, +}; +const validDraft: Partial = { + ...validAdres, + diplomaId: 'd1', + beroep: 'Arts', + diplomaHerkomst: 'duo', +}; describe('STEPS (fixed)', () => { it('always has the same three steps', () => { @@ -106,7 +117,18 @@ describe('adres origin (BRP vs handmatig)', () => { }); 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 as any).data.adresHerkomst).toBe('handmatig'); }); @@ -185,7 +207,12 @@ describe('submit', () => { describe('reduce (message-driven happy path)', () => { it('drives the full flow via messages', () => { let s: RegistratieState = initial; - s = reduce(s, { tag: 'PrefillAdres', straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' }); + s = reduce(s, { + tag: 'PrefillAdres', + straat: 'Lange Voorhout 9', + postcode: '2514 EA', + woonplaats: 'Den Haag', + }); s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' }); s = reduce(s, { tag: 'Next' }); expect(currentStep(s as any)).toBe('beroep'); @@ -199,7 +226,10 @@ describe('reduce (message-driven happy path)', () => { }); 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'); s = reduce(s, { tag: 'Retry' }); expect(s.tag).toBe('Indienen'); @@ -208,27 +238,51 @@ describe('reduce (message-driven happy path)', () => { }); 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', () => { - 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); }); 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 expect(currentStep(s as any)).toBe('beroep'); expect((s as any).errors.documenten).toBeTruthy(); // 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' }); expect(currentStep(s as any)).toBe('controle'); }); it('includes delivery refs in the submitted data', () => { - let s = reduce(invullen(validDraft), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } }); - s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' } }); + let s = reduce(invullen(validDraft), { + 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); expect(done.tag).toBe('Indienen'); expect((done as any).data.documents).toEqual([{ categoryId: 'diploma', channel: 'post' }]); diff --git a/src/app/registratie/domain/registratie-wizard.machine.ts b/src/app/registratie/domain/registratie-wizard.machine.ts index a4d6245..73b96d4 100644 --- a/src/app/registratie/domain/registratie-wizard.machine.ts +++ b/src/app/registratie/domain/registratie-wizard.machine.ts @@ -84,7 +84,13 @@ export type RegistratieState = | { tag: 'Mislukt'; data: ValidRegistratie; error: string }; 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). */ export function currentStep(s: Extract): StepId { @@ -112,11 +118,14 @@ function validateStep(step: StepId, d: Draft, upload: UploadState): Result = {}; 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; // 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; const draft: Draft = { ...s.draft, [key]: value }; // Editing an address field means the user owns it now — not the BRP copy. - if (key === 'straat' || key === 'postcode' || key === 'woonplaats') draft.adresHerkomst = 'handmatig'; + if (key === 'straat' || key === 'postcode' || key === 'woonplaats') + draft.adresHerkomst = 'handmatig'; 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). */ -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; return { ...s, draft: { ...s.draft, straat, postcode, woonplaats, adresHerkomst: 'brp' } }; } /** Pick a DUO diploma; the beroep is derived from it and the applicable policy questions (`vraagIds`) come with it (both server-computed, passed in). */ -export function kiesDiploma(s: RegistratieState, diplomaId: string, beroep: string, vraagIds: string[]): RegistratieState { +export function kiesDiploma( + s: RegistratieState, + diplomaId: string, + beroep: string, + vraagIds: string[], +): RegistratieState { if (s.tag !== 'Invullen') return s; - return { ...s, draft: { ...s.draft, diplomaId, beroep, vraagIds, diplomaHerkomst: 'duo' }, errors: {} }; + 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 @@ -213,7 +238,17 @@ export function kiesDiploma(s: RegistratieState, diplomaId: string, beroep: stri beroep is declared separately (declareerBeroep). */ export function kiesHandmatig(s: RegistratieState, vraagIds: string[]): RegistratieState { if (s.tag !== 'Invullen') return s; - return { ...s, draft: { ...s.draft, diplomaId: 'handmatig', beroep: undefined, vraagIds, diplomaHerkomst: 'handmatig' }, errors: {} }; + 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). */ @@ -260,7 +295,9 @@ export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState { export function resolve(s: RegistratieState, r: Result): RegistratieState { if (s.tag !== 'Indienen') return s; - return r.ok ? { tag: 'Ingediend', data: s.data, referentie: r.value } : { tag: 'Mislukt', data: s.data, error: r.error }; + return r.ok + ? { tag: 'Ingediend', data: s.data, referentie: r.value } + : { tag: 'Mislukt', data: s.data, error: r.error }; } export type RegistratieMsg = @@ -308,7 +345,9 @@ export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState case 'Retry': return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s; case 'SubmitConfirmed': - return s.tag === 'Indienen' ? { tag: 'Ingediend', data: s.data, referentie: m.referentie } : s; + return s.tag === 'Indienen' + ? { tag: 'Ingediend', data: s.data, referentie: m.referentie } + : s; case 'SubmitFailed': return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s; case 'Upload': diff --git a/src/app/registratie/domain/registration.policy.spec.ts b/src/app/registratie/domain/registration.policy.spec.ts index 0ac84de..bdab5b3 100644 --- a/src/app/registratie/domain/registration.policy.spec.ts +++ b/src/app/registratie/domain/registration.policy.spec.ts @@ -3,8 +3,12 @@ import { Registration } from './registration'; import { isHerregistratieEligible, statusColor } from './registration.policy'; const reg = (status: Registration['status']): Registration => ({ - bigNummer: '19012345601', naam: 'Test', beroep: 'Arts', - registratiedatum: '2012-09-01', geboortedatum: '1985-03-14', status, + bigNummer: '19012345601', + naam: 'Test', + beroep: 'Arts', + registratiedatum: '2012-09-01', + geboortedatum: '1985-03-14', + status, }); describe('registration.policy', () => { @@ -15,8 +19,18 @@ describe('registration.policy', () => { }); 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(isHerregistratieEligible(reg({ tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'x' }), new Date('2027-01-01'))).toBe(false); + expect( + 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', () => { diff --git a/src/app/registratie/domain/registration.policy.ts b/src/app/registratie/domain/registration.policy.ts index 8b1579a..5388e9a 100644 --- a/src/app/registratie/domain/registration.policy.ts +++ b/src/app/registratie/domain/registration.policy.ts @@ -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 as `decisions.eligibleForHerregistratie` in the dashboard view. Kept here as the reference implementation + unit test; the frontend no longer calls it. */ -export function isHerregistratieEligible(reg: Registration, today: Date, windowMonths = 12): boolean { +export function isHerregistratieEligible( + reg: Registration, + today: Date, + windowMonths = 12, +): boolean { const deadline = herregistratieDeadline(reg); if (!deadline) return false; const windowStart = new Date(deadline); diff --git a/src/app/registratie/domain/tasks.spec.ts b/src/app/registratie/domain/tasks.spec.ts index 316d13a..5390dfd 100644 --- a/src/app/registratie/domain/tasks.spec.ts +++ b/src/app/registratie/domain/tasks.spec.ts @@ -24,7 +24,10 @@ describe('tasksFromProfile', () => { }); 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); expect(tasks).toHaveLength(1); expect(tasks[0].title).toContain('geschorst'); @@ -32,7 +35,10 @@ describe('tasksFromProfile', () => { }); 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); expect(tasks).toHaveLength(1); expect(tasks[0].title).toContain('doorgehaald'); diff --git a/src/app/registratie/domain/tasks.ts b/src/app/registratie/domain/tasks.ts index 3a320fd..4b30d17 100644 --- a/src/app/registratie/domain/tasks.ts +++ b/src/app/registratie/domain/tasks.ts @@ -22,7 +22,10 @@ function formatNL(d: Date): string { * it does not recompute the rule (ADR-0001). The deadline is still formatted * 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[] = []; if (eligibleForHerregistratie) { diff --git a/src/app/registratie/domain/value-objects/big-nummer.ts b/src/app/registratie/domain/value-objects/big-nummer.ts index fab3a0e..2da5cb3 100644 --- a/src/app/registratie/domain/value-objects/big-nummer.ts +++ b/src/app/registratie/domain/value-objects/big-nummer.ts @@ -5,5 +5,7 @@ export type BigNummer = Brand; export function parseBigNummer(raw: string): Result { 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.`); } diff --git a/src/app/registratie/domain/value-objects/email.ts b/src/app/registratie/domain/value-objects/email.ts index 4fb9c56..c5f4fa5 100644 --- a/src/app/registratie/domain/value-objects/email.ts +++ b/src/app/registratie/domain/value-objects/email.ts @@ -13,7 +13,9 @@ export function parseEmail(raw: string): Result { // Deliberately lax: a single @ with non-empty, dot-bearing parts. Good enough // for instant feedback; the server re-validates. if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) { - return err($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); } diff --git a/src/app/registratie/domain/value-objects/uren.spec.ts b/src/app/registratie/domain/value-objects/uren.spec.ts index b0069b0..ba46f2e 100644 --- a/src/app/registratie/domain/value-objects/uren.spec.ts +++ b/src/app/registratie/domain/value-objects/uren.spec.ts @@ -3,7 +3,11 @@ import { parseUren } from './uren'; describe('parseUren', () => { 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); expect(r.ok).toBe(true); if (r.ok) expect(r.value).toBe(n); diff --git a/src/app/registratie/infrastructure/applications.adapter.spec.ts b/src/app/registratie/infrastructure/applications.adapter.spec.ts index fc07a1c..4fe25fb 100644 --- a/src/app/registratie/infrastructure/applications.adapter.spec.ts +++ b/src/app/registratie/infrastructure/applications.adapter.spec.ts @@ -1,14 +1,33 @@ 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', () => { 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: 'InBehandeling', referentie: 'BIG-1', manual: true }).ok).toBe(true); + expect(parseAanvraagStatus({ tag: 'Concept', stepIndex: 2, stepCount: 4 })).toEqual({ + 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: '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', () => { diff --git a/src/app/registratie/infrastructure/applications.adapter.ts b/src/app/registratie/infrastructure/applications.adapter.ts index 4c65be0..5dc339a 100644 --- a/src/app/registratie/infrastructure/applications.adapter.ts +++ b/src/app/registratie/infrastructure/applications.adapter.ts @@ -9,7 +9,12 @@ import { SubmitApplicationRequest, SubmitApplicationResponse, } 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 @@ -54,20 +59,25 @@ export class ApplicationsAdapter { const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake']; /** Trust-boundary parse of the status union — the tag drives which fields must exist. */ -export function parseAanvraagStatus(s: AanvraagStatusDto | undefined): Result { +export function parseAanvraagStatus( + s: AanvraagStatusDto | undefined, +): Result { if (!s || typeof s.tag !== 'string') return err('aanvraag: missing status'); switch (s.tag) { 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 }); 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 }); case 'Goedgekeurd': if (typeof s.referentie !== 'string') return err('aanvraag: bad Goedgekeurd status'); return ok({ tag: 'Goedgekeurd', referentie: s.referentie }); 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 }); default: return err(`aanvraag: unknown status tag ${s.tag}`); @@ -76,8 +86,10 @@ export function parseAanvraagStatus(s: AanvraagStatusDto | undefined): Result { 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.createdAt !== 'string' || typeof dto.updatedAt !== 'string') return err('aanvraag: missing timestamps'); + if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type)) + 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); if (!status.ok) return status; return ok({ diff --git a/src/app/registratie/infrastructure/big-register.adapter.ts b/src/app/registratie/infrastructure/big-register.adapter.ts index c4aa828..9f2e7c9 100644 --- a/src/app/registratie/infrastructure/big-register.adapter.ts +++ b/src/app/registratie/infrastructure/big-register.adapter.ts @@ -22,5 +22,9 @@ export class BigRegisterAdapter { /** Map the wire DTO (all fields optional) onto our domain type. */ 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 ?? '', + }; } diff --git a/src/app/registratie/infrastructure/brp.adapter.spec.ts b/src/app/registratie/infrastructure/brp.adapter.spec.ts index bb89215..7de0b2d 100644 --- a/src/app/registratie/infrastructure/brp.adapter.spec.ts +++ b/src/app/registratie/infrastructure/brp.adapter.spec.ts @@ -3,7 +3,10 @@ import { parseBrpAddress } from './brp.adapter'; describe('parseBrpAddress (trust boundary)', () => { it('accepts a found address', () => { - const r = parseBrpAddress({ gevonden: true, adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' } }); + const r = parseBrpAddress({ + gevonden: true, + adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' }, + }); expect(r.ok).toBe(true); if (r.ok) expect(r.value.adres?.postcode).toBe('2514 EA'); }); diff --git a/src/app/registratie/infrastructure/brp.adapter.ts b/src/app/registratie/infrastructure/brp.adapter.ts index 5211a71..c343ba1 100644 --- a/src/app/registratie/infrastructure/brp.adapter.ts +++ b/src/app/registratie/infrastructure/brp.adapter.ts @@ -27,7 +27,12 @@ export function parseBrpAddress(json: unknown): Result { if (typeof dto.gevonden !== 'boolean') return err('brp-address: missing/invalid gevonden'); if (dto.gevonden) { const a = dto.adres; - if (!a || typeof a.straat !== 'string' || typeof a.postcode !== 'string' || typeof a.woonplaats !== 'string') { + if ( + !a || + typeof a.straat !== 'string' || + typeof a.postcode !== 'string' || + typeof a.woonplaats !== 'string' + ) { return err('brp-address: missing/invalid adres'); } } diff --git a/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts b/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts index 39b3b5f..e2fe78d 100644 --- a/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts +++ b/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts @@ -10,7 +10,11 @@ const valid = { geboortedatum: '1985-03-14', status: { tag: 'Geregistreerd', herregistratieDatum: '2027-03-01' }, }, - person: { naam: 'Dr. A. de Vries', geboortedatum: '1985-03-14', adres: { straat: 'X 1', postcode: '2514 EA', woonplaats: 'Den Haag' } }, + 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' }, }; @@ -27,6 +31,8 @@ describe('parseDashboardView (trust boundary)', () => { it('rejects malformed responses instead of trusting them', () => { expect(parseDashboardView(null).ok).toBe(false); expect(parseDashboardView({ ...valid, registration: undefined }).ok).toBe(false); - expect(parseDashboardView({ ...valid, decisions: { eligibleForHerregistratie: 'yes' } }).ok).toBe(false); + expect( + parseDashboardView({ ...valid, decisions: { eligibleForHerregistratie: 'yes' } }).ok, + ).toBe(false); }); }); diff --git a/src/app/registratie/infrastructure/dashboard-view.adapter.ts b/src/app/registratie/infrastructure/dashboard-view.adapter.ts index d510763..1a55742 100644 --- a/src/app/registratie/infrastructure/dashboard-view.adapter.ts +++ b/src/app/registratie/infrastructure/dashboard-view.adapter.ts @@ -1,6 +1,9 @@ import { Injectable, inject, resource } from '@angular/core'; 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 { Person } from '@registratie/domain/person'; import { BigProfile } from '@registratie/domain/big-profile'; @@ -49,7 +52,12 @@ export function parseDashboardView(json: unknown): Result const dto = json as Partial; 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'); } const person = dto.person; @@ -72,10 +80,17 @@ export function parseDashboardView(json: unknown): Result geboortedatum: reg.geboortedatum, 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({ profile: { registration, person: persoon }, - decisions: { eligibleForHerregistratie: d.eligibleForHerregistratie, herregistratieReason: d.herregistratieReason }, + decisions: { + eligibleForHerregistratie: d.eligibleForHerregistratie, + herregistratieReason: d.herregistratieReason, + }, }); } diff --git a/src/app/registratie/infrastructure/duo.adapter.spec.ts b/src/app/registratie/infrastructure/duo.adapter.spec.ts index 24e8276..abfd9c6 100644 --- a/src/app/registratie/infrastructure/duo.adapter.spec.ts +++ b/src/app/registratie/infrastructure/duo.adapter.spec.ts @@ -3,8 +3,22 @@ import { parseDuoLookup } from './duo.adapter'; const valid = { diplomas: [ - { id: 'd1', naam: 'Geneeskunde', instelling: 'Universiteit Leiden', jaar: 2011, beroep: 'Arts', policyQuestions: [] }, - { id: 'd2', naam: 'Medicine', instelling: 'University of Edinburgh', jaar: 2013, beroep: 'Arts', policyQuestions: [{ id: 'nl-taal', vraag: 'Toon taalvaardigheid', type: 'ja-nee' }] }, + { + id: 'd1', + naam: 'Geneeskunde', + instelling: 'Universiteit Leiden', + jaar: 2011, + beroep: 'Arts', + policyQuestions: [], + }, + { + id: 'd2', + naam: 'Medicine', + instelling: 'University of Edinburgh', + jaar: 2013, + beroep: 'Arts', + policyQuestions: [{ id: 'nl-taal', vraag: 'Toon taalvaardigheid', type: 'ja-nee' }], + }, ], handmatig: { beroepen: ['Arts', 'Verpleegkundige'], @@ -34,6 +48,14 @@ describe('parseDuoLookup (trust boundary)', () => { expect(parseDuoLookup({}).ok).toBe(false); // no diplomas expect(parseDuoLookup({ diplomas: [] }).ok).toBe(false); // no handmatig expect(parseDuoLookup({ diplomas: [{ id: 'd1' }], handmatig: valid.handmatig }).ok).toBe(false); // bad diploma - expect(parseDuoLookup({ diplomas: [], handmatig: { beroepen: ['Arts'], policyQuestions: [{ id: 'x', vraag: 'y', type: 'bogus' }] } }).ok).toBe(false); // bad question type + expect( + parseDuoLookup({ + diplomas: [], + handmatig: { + beroepen: ['Arts'], + policyQuestions: [{ id: 'x', vraag: 'y', type: 'bogus' }], + }, + }).ok, + ).toBe(false); // bad question type }); }); diff --git a/src/app/registratie/infrastructure/duo.adapter.ts b/src/app/registratie/infrastructure/duo.adapter.ts index 570473c..60d9848 100644 --- a/src/app/registratie/infrastructure/duo.adapter.ts +++ b/src/app/registratie/infrastructure/duo.adapter.ts @@ -1,6 +1,11 @@ import { Injectable, inject, resource } from '@angular/core'; 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'; /** @@ -25,7 +30,12 @@ function parseQuestions(json: unknown): PolicyQuestionDto[] | null { for (const q of json) { if (typeof q !== 'object' || q === null) return null; const p = q as Partial; - if (typeof p.id !== 'string' || typeof p.vraag !== 'string' || (p.type !== 'ja-nee' && p.type !== 'tekst')) return null; + if ( + typeof p.id !== 'string' || + typeof p.vraag !== 'string' || + (p.type !== 'ja-nee' && p.type !== 'tekst') + ) + return null; out.push({ id: p.id, vraag: p.vraag, type: p.type }); } return out; @@ -43,10 +53,22 @@ export function parseDuoLookup(json: unknown): Result { if (typeof item !== 'object' || item === null) return err('duo-lookup: invalid diploma'); const d = item as Partial; const vragen = parseQuestions(d.policyQuestions); - if (typeof d.id !== 'string' || typeof d.naam !== 'string' || typeof d.beroep !== 'string' || vragen === null) { + if ( + typeof d.id !== 'string' || + typeof d.naam !== 'string' || + typeof d.beroep !== 'string' || + vragen === null + ) { return err('duo-lookup: missing/invalid diploma fields'); } - diplomas.push({ id: d.id, naam: d.naam, instelling: d.instelling ?? '', jaar: typeof d.jaar === 'number' ? d.jaar : 0, beroep: d.beroep, policyQuestions: vragen }); + 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; diff --git a/src/app/registratie/ui/aanvraag-block/aanvraag-block.component.ts b/src/app/registratie/ui/aanvraag-block/aanvraag-block.component.ts index 4b7cd6c..5a51ed9 100644 --- a/src/app/registratie/ui/aanvraag-block/aanvraag-block.component.ts +++ b/src/app/registratie/ui/aanvraag-block/aanvraag-block.component.ts @@ -13,9 +13,16 @@ import { blockActions } from '@registratie/domain/block-actions'; @Component({ selector: 'app-aanvraag-block', imports: [ButtonComponent, AlertComponent], - styles: [` - .actions{display:flex;align-items:center;gap:var(--rhc-space-max-md);margin-block-start:var(--rhc-space-max-sm)} - `], + styles: [ + ` + .actions { + display: flex; + align-items: center; + gap: var(--rhc-space-max-md); + margin-block-start: var(--rhc-space-max-sm); + } + `, + ], template: ` @if (aanvraag().status.tag === 'Concept') { @@ -23,10 +30,14 @@ import { blockActions } from '@registratie/domain/block-actions';

      {{ conceptText() }}

      @if (actions().includes('cancel')) { - Verwijderen + Verwijderen } @if (actions().includes('resume')) { - Aanvraag openen + Aanvraag openen }
      @@ -59,5 +70,7 @@ export class AanvraagBlockComponent { } 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' }) + : ''; } diff --git a/src/app/registratie/ui/aanvraag-block/aanvraag-block.stories.ts b/src/app/registratie/ui/aanvraag-block/aanvraag-block.stories.ts index 1c76893..55bf95c 100644 --- a/src/app/registratie/ui/aanvraag-block/aanvraag-block.stories.ts +++ b/src/app/registratie/ui/aanvraag-block/aanvraag-block.stories.ts @@ -38,7 +38,36 @@ export const Concept: Story = { args: { aanvraag: { ...base, status: { tag: 'Concept', stepIndex: 1, stepCount: 3 } } }, render: (args) => ({ props: args, template: `` }), }; -export const InBehandelingAuto: Story = { args: { aanvraag: { ...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.' } } } }; +export const InBehandelingAuto: Story = { + args: { + aanvraag: { + ...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.', + }, + }, + }, +}; diff --git a/src/app/registratie/ui/aanvraag-detail.page.ts b/src/app/registratie/ui/aanvraag-detail.page.ts index 0c59f4c..8433ec9 100644 --- a/src/app/registratie/ui/aanvraag-detail.page.ts +++ b/src/app/registratie/ui/aanvraag-detail.page.ts @@ -15,14 +15,28 @@ import { detailRows } from '@registratie/domain/aanvraag-view'; handling is future work. The dashboard "Mijn aanvragen" rows link here. */ @Component({ selector: 'app-aanvraag-detail-page', - imports: [PageShellComponent, SkeletonComponent, AlertComponent, DataBlockComponent, DataRowComponent, ...ASYNC], + imports: [ + PageShellComponent, + SkeletonComponent, + AlertComponent, + DataBlockComponent, + DataRowComponent, + ...ASYNC, + ], template: ` - + @let a = find($any(list)); @if (a) { - + @for (row of rows(a); track row.key) {
      } @@ -31,7 +45,9 @@ import { detailRows } from '@registratie/domain/aanvraag-view'; De volledige afhandeling van deze aanvraag is nog niet beschikbaar in deze POC. } @else { - Deze aanvraag is niet gevonden. + Deze aanvraag is niet gevonden. }
      diff --git a/src/app/registratie/ui/address-fields/address-fields.component.ts b/src/app/registratie/ui/address-fields/address-fields.component.ts index 6d98137..c4bbc64 100644 --- a/src/app/registratie/ui/address-fields/address-fields.component.ts +++ b/src/app/registratie/ui/address-fields/address-fields.component.ts @@ -19,27 +19,73 @@ export type AdresErrors = Partial>; @Component({ selector: 'app-address-fields', imports: [FormsModule, FormFieldComponent, TextInputComponent], - 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)} - `], + 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); + } + `, + ], template: `
      {{ legend() }} - - + + - - + + - - + +
      `, diff --git a/src/app/registratie/ui/address-fields/address-fields.stories.ts b/src/app/registratie/ui/address-fields/address-fields.stories.ts index e59e6be..a17966f 100644 --- a/src/app/registratie/ui/address-fields/address-fields.stories.ts +++ b/src/app/registratie/ui/address-fields/address-fields.stories.ts @@ -20,12 +20,18 @@ const empty = { straat: '', postcode: '', woonplaats: '' }; export const Default: Story = { args: { value: empty, errors: {} } }; 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 = { args: { 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.', + }, }, }; diff --git a/src/app/registratie/ui/change-request-form/change-request-form.component.ts b/src/app/registratie/ui/change-request-form/change-request-form.component.ts index bd6faa3..9dba895 100644 --- a/src/app/registratie/ui/change-request-form/change-request-form.component.ts +++ b/src/app/registratie/ui/change-request-form/change-request-form.component.ts @@ -3,7 +3,11 @@ import { FormsModule } from '@angular/forms'; import { ButtonComponent } from '@shared/ui/button/button.component'; import { HeadingComponent } from '@shared/ui/heading/heading.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 { whenTag } from '@shared/kernel/fp'; import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine'; @@ -21,25 +25,37 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang template: ` @if (state().tag === 'Submitted') { - Uw adreswijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5 werkdagen bericht. + Uw adreswijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5 + werkdagen bericht.
      - Nieuwe wijziging doorgeven + Nieuwe wijziging doorgeven
      } @else { Adreswijziging doorgeven
      -
      * verplichte velden
      +
      + * verplichte velden +
      + (fieldChange)="dispatch({ tag: 'SetField', key: $event.key, value: $event.value })" + /> @if (failedError()) { - Het indienen is niet gelukt: {{ failedError() }} + Het indienen is niet gelukt: + {{ failedError() }} } diff --git a/src/app/registratie/ui/change-request-form/change-request-form.stories.ts b/src/app/registratie/ui/change-request-form/change-request-form.stories.ts index 0b68e5a..2aba86a 100644 --- a/src/app/registratie/ui/change-request-form/change-request-form.stories.ts +++ b/src/app/registratie/ui/change-request-form/change-request-form.stories.ts @@ -5,7 +5,11 @@ import { ChangeRequestFormComponent } from './change-request-form.component'; import { provideApiClient } from '@shared/infrastructure/api-client.provider'; 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 = { title: 'Organisms/Change Request Form', @@ -17,16 +21,27 @@ export default meta; type Story = StoryObj; // 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 = { args: { seed: { tag: 'Editing', 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 Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData, referentie: 'BIG-2026-123456' } } }; -export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } }; +export const Submitted: Story = { + args: { seed: { tag: 'Submitted', data: validData, referentie: 'BIG-2026-123456' } }, +}; +export const Failed: Story = { + args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } }, +}; diff --git a/src/app/registratie/ui/dashboard.page.ts b/src/app/registratie/ui/dashboard.page.ts index 2365f29..63a4e8b 100644 --- a/src/app/registratie/ui/dashboard.page.ts +++ b/src/app/registratie/ui/dashboard.page.ts @@ -25,91 +25,163 @@ import { tasksFromProfile } from '@registratie/domain/tasks'; @Component({ selector: 'app-dashboard-page', imports: [ - PageShellComponent, HeadingComponent, AlertComponent, SkeletonComponent, - DataRowComponent, DataBlockComponent, TaskListComponent, ApplicationListComponent, ApplicationLinkComponent, + PageShellComponent, + HeadingComponent, + AlertComponent, + SkeletonComponent, + DataRowComponent, + DataBlockComponent, + TaskListComponent, + ApplicationListComponent, + ApplicationLinkComponent, ...ASYNC, - RegistrationSummaryComponent, RegistrationTableComponent, AanvraagBlockComponent, + RegistrationSummaryComponent, + RegistrationTableComponent, + AanvraagBlockComponent, ], template: ` - -
      - @if (aanvragen().length) { + +
      + @if (aanvragen().length) { +
      + @for (a of concepten(); track a.id) { + + } + @if (ingediend().length) { + Mijn aanvragen + + @for (a of ingediend(); track a.id) { + @let row = submittedRow(a); +
    1. + } +
      + } +
      + } + + @if (store.pendingHerregistratie()) { + Uw herregistratie-aanvraag is in behandeling. + } + + + + @let tasks = tasksFor($any(p).registration); +
      - @for (a of concepten(); track a.id) { - - } - @if (ingediend().length) { - Mijn aanvragen - - @for (a of ingediend(); track a.id) { - @let row = submittedRow(a); -
    2. - } -
      + @if (tasks.length) { + + } @else { + Wat moet ik regelen +

      + U heeft op dit moment niets openstaan. +

      }
      - } - @if (store.pendingHerregistratie()) { - Uw herregistratie-aanvraag is in behandeling. - } +
      + Mijn registratie +
      + +
      + +
      +
      +
      +
      +
      +
      + + + +
      - - - @let tasks = tasksFor($any(p).registration); +
      + Specialismen en aantekeningen +
      + + + + + + + + +

      + U heeft nog geen specialismen of aantekeningen. +

      +
      +
      +
      +
      -
      - @if (tasks.length) { - - } @else { - Wat moet ik regelen -

      U heeft op dit moment niets openstaan.

      - } -
      - -
      - Mijn registratie -
      - -
      - -
      -
      -
      -
      -
      -
      - - - -
      - -
      - Specialismen en aantekeningen -
      - - - - - - - - -

      U heeft nog geen specialismen of aantekeningen.

      -
      -
      -
      -
      - -
      - Wat wilt u doen? - - @for (a of acties; track a.to) { -
    3. - } -
      -
      -
      +
      + Wat wilt u doen? + + @for (a of acties; track a.to) { +
    4. + } +
      +
      +
      `, }) @@ -132,7 +204,12 @@ export class DashboardPage { protected aanvragen = computed(() => { const rd = this.apps.applications(); if (rd.tag !== 'Success') return []; - const order: Record = { Concept: 0, InBehandeling: 1, Goedgekeurd: 2, Afgewezen: 2 }; + const order: Record = { + Concept: 0, + InBehandeling: 1, + Goedgekeurd: 2, + Afgewezen: 2, + }; 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 @@ -166,11 +243,41 @@ export class DashboardPage { componenten/aanvragen). The core portal sections live in the header nav now; the teaching pages (concepts/brief) are only reachable from here. */ 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: '/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` }, + { + 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: '/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`, + }, ]; } diff --git a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts index f9496aa..7fcc350 100644 --- a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -9,7 +9,12 @@ import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.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 { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component'; import { createStore } from '@shared/application/store'; @@ -54,138 +59,291 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid'; @Component({ selector: 'app-registratie-wizard', imports: [ - FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, - AlertComponent, SkeletonComponent, DataRowComponent, ReviewSectionComponent, ConfirmationComponent, WizardShellComponent, - AddressFieldsComponent, DocumentUploadComponent, ...ASYNC, + FormsModule, + FormFieldComponent, + TextInputComponent, + RadioGroupComponent, + ButtonComponent, + AlertComponent, + SkeletonComponent, + DataRowComponent, + ReviewSectionComponent, + ConfirmationComponent, + WizardShellComponent, + AddressFieldsComponent, + DocumentUploadComponent, + ...ASYNC, ], template: ` - + (goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })" + > @switch (step()) { @case ('adres') { - @if (adresStatus() === 'laden') { - - } @else { - @switch (adresStatus()) { - @case ('gevonden') { - Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan. - } - @case ('geen') { - We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in. - } - @case ('fout') { - We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in. - } - } - - - + @if (adresStatus() === 'laden') { + + } @else { + @switch (adresStatus()) { + @case ('gevonden') { + Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan. + } + @case ('geen') { + We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in. + } + @case ('fout') { + We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig + in. + } + } + + + + + @if (draft().correspondentie === 'email') { + + + + } + } + } + @case ('beroep') { + + + + + + + @if (handmatigActief()) { + Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw + beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig + beoordeeld. + + - @if (draft().correspondentie === 'email') { - - - - } + } @else if (draft().beroep) { +
      +
      +
      } - } - @case ('beroep') { - - - - - - @if (handmatigActief()) { - Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig beoordeeld. - - - - } @else if (draft().beroep) { -
      -
      -
      + @for (q of actieveVragen($any(data)); track q.id) { + + @if (q.type === 'ja-nee') { + + } @else { + } - - @for (q of actieveVragen($any(data)); track q.id) { - - @if (q.type === 'ja-nee') { - - } @else { - - } - - } -
      - - - -
      - - - @if (err('documenten')) { - {{ err('documenten') }} +
      } + + + + + + + + @if (err('documenten')) { + {{ err('documenten') }} + } + } + @case ('controle') { + Controleer uw gegevens en dien de registratie in. + +
      +
      +
      + @if (draft().correspondentie === 'email') { +
      } - @case ('controle') { - Controleer uw gegevens en dien de registratie in. - -
      -
      -
      - @if (draft().correspondentie === 'email') { -
      - } -
      - -
      -
      - @for (item of samenvattingVragen(); track item.vraag) { -
      - } -
      +
      + +
      +
      + @for (item of samenvattingVragen(); track item.vraag) { +
      + } +
      } }
      - -

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

      + +

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

      - Nieuwe registratie starten + Nieuwe registratie starten
      @@ -206,8 +364,16 @@ export class RegistratieWizardComponent { seed = input(initial); 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 - 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 stepLabels = [ + $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 dispatch = this.store.dispatch; @@ -234,14 +400,18 @@ export class RegistratieWizardComponent { snapshot: () => { const s = this.state(); 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 }; }, onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as RegistratieState }), enabled: () => this.seed() === initial, }); protected step = computed(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]); - protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]); + protected stepTitle = computed( + () => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)], + ); protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? ''); protected failedError = computed(() => whenTag(this.state(), 'Mislukt')?.error ?? ''); @@ -251,13 +421,21 @@ export class RegistratieWizardComponent { const next = this.cursor() + 1; 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(() => { switch (this.state().tag) { - case 'Invullen': return 'editing'; - case 'Indienen': return 'submitting'; - case 'Ingediend': return 'submitted'; - case 'Mislukt': return 'failed'; + case 'Invullen': + return 'editing'; + case 'Indienen': + return 'submitting'; + case 'Ingediend': + return 'submitted'; + case 'Mislukt': + return 'failed'; } }); /** Current step's errors (incl. per-question), flattened for the error summary. */ @@ -274,12 +452,32 @@ export class RegistratieWizardComponent { }); protected adresSamenvatting = computed(() => { 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). - protected adresHerkomstLabel = computed(() => ({ 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'])); + protected adresHerkomstLabel = computed( + () => + ({ + 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 served by the application facade — the wizard renders, it does not fetch/parse. */ @@ -295,23 +493,35 @@ export class RegistratieWizardComponent { 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 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 setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie }); + protected set = (key: DraftField, value: string) => + this.dispatch({ tag: 'SetField', key, value }); + protected setKanaal = (value: string) => + this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie }); /** True while the user is entering a diploma manually (not in the DUO list). */ protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig'); /** The radio selection: a diploma id, or the"not listed" sentinel in manual mode. */ - protected diplomaKeuze = computed(() => (this.handmatigActief() ? HANDMATIG : this.draft().diplomaId ?? '')); + protected diplomaKeuze = computed(() => + this.handmatigActief() ? HANDMATIG : (this.draft().diplomaId ?? ''), + ); protected diplomaOptions = (data: DuoLookupDto) => [ - ...data.diplomas.map((d) => ({ value: d.id, label: `${d.naam} — ${d.instelling} (${d.jaar})` })), - { value: HANDMATIG, label: $localize`:@@regWizard.diplomaNietBij:Mijn diploma staat er niet bij` }, + ...data.diplomas.map((d) => ({ + 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). */ protected actieveVragen = (data: DuoLookupDto): PolicyQuestionDto[] => { @@ -324,24 +534,41 @@ export class RegistratieWizardComponent { const data = this.duoData(); const d = this.draft(); if (!data) return [] as { vraag: string; antwoord: string }[]; - const alle = [...data.diplomas.flatMap((x) => x.policyQuestions), ...data.handmatig.policyQuestions]; - return (d.vraagIds ?? []).map((id) => ({ vraag: alle.find((q) => q.id === id)?.vraag ?? id, antwoord: d.antwoorden[id] ?? '' })); + const alle = [ + ...data.diplomas.flatMap((x) => x.policyQuestions), + ...data.handmatig.policyQuestions, + ]; + return (d.vraagIds ?? []).map((id) => ({ + vraag: alle.find((q) => q.id === id)?.vraag ?? id, + antwoord: d.antwoorden[id] ?? '', + })); }); protected onDiplomaKeuze(data: DuoLookupDto, id: string) { if (id === HANDMATIG) { - this.dispatch({ tag: 'KiesHandmatig', vraagIds: data.handmatig.policyQuestions.map((q) => q.id) }); + this.dispatch({ + tag: 'KiesHandmatig', + vraagIds: data.handmatig.policyQuestions.map((q) => q.id), + }); return; } const d = data.diplomas.find((x) => x.id === id); - if (d) this.dispatch({ tag: 'KiesDiploma', diplomaId: d.id, beroep: d.beroep, vraagIds: d.policyQuestions.map((q) => q.id) }); + if (d) + this.dispatch({ + tag: 'KiesDiploma', + diplomaId: d.id, + beroep: d.beroep, + vraagIds: d.policyQuestions.map((q) => q.id), + }); } constructor() { // An explicit seed (stories/tests) wins; otherwise resume from the backend draft // (`?aanvraag=`), or start fresh. Persistence is the draftSync controller's job. const seeded = this.seed(); - queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume())); + 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 // 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 @@ -352,7 +579,12 @@ export class RegistratieWizardComponent { untracked(() => { const s = this.state(); 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 @@ -384,7 +616,10 @@ export class RegistratieWizardComponent { private async runIfIndienen() { const s = this.state(); 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 ?? '' }); else this.dispatch({ tag: 'SubmitFailed', error: r.error }); } diff --git a/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts b/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts index 9e1d8d2..94361f7 100644 --- a/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts +++ b/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts @@ -3,14 +3,36 @@ import { applicationConfig } from '@storybook/angular'; import { provideHttpClient } from '@angular/common/http'; import { provideApiClient } from '@shared/infrastructure/api-client.provider'; 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 { Postcode } from '@registratie/domain/value-objects/postcode'; -const adres: Partial = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', adresHerkomst: 'brp', correspondentie: 'post' }; -const filled: Partial = { ...adres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo', vraagIds: [] }; +const adres: Partial = { + straat: 'Lange Voorhout 9', + postcode: '2514 EA', + woonplaats: 'Den Haag', + adresHerkomst: 'brp', + correspondentie: 'post', +}; +const filled: Partial = { + ...adres, + diplomaId: 'd1', + beroep: 'Arts', + diplomaHerkomst: 'duo', + vraagIds: [], +}; -const invullen = (draft: Partial, cursor = 0): RegistratieState => ({ tag: 'Invullen', draft: { antwoorden: {}, ...draft }, cursor, errors: {}, upload: initialUpload }); +const invullen = (draft: Partial, cursor = 0): RegistratieState => ({ + tag: 'Invullen', + draft: { antwoorden: {}, ...draft }, + cursor, + errors: {}, + upload: initialUpload, +}); const validData: ValidRegistratie = { adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' }, @@ -34,10 +56,39 @@ type Story = StoryObj; export const Adres: Story = { args: { seed: invullen(adres, 0) } }; export const Beroep: Story = { args: { seed: invullen(filled, 1) } }; /** English-language diploma → the Dutch-proficiency policy question appears. */ -export const BeroepEngelstalig: Story = { args: { seed: invullen({ ...adres, diplomaId: 'd2', beroep: 'Arts', diplomaHerkomst: 'duo', vraagIds: ['nl-taalvaardigheid'] }, 1) } }; +export const BeroepEngelstalig: Story = { + args: { + seed: invullen( + { + ...adres, + diplomaId: 'd2', + beroep: 'Arts', + diplomaHerkomst: 'duo', + vraagIds: ['nl-taalvaardigheid'], + }, + 1, + ), + }, +}; /** Diploma not in DUO → declare beroep + the maximal policy-question set. */ -export const BeroepHandmatig: Story = { args: { seed: invullen({ ...adres, diplomaId: 'handmatig', diplomaHerkomst: 'handmatig', vraagIds: ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'] }, 1) } }; +export const BeroepHandmatig: Story = { + args: { + seed: invullen( + { + ...adres, + diplomaId: 'handmatig', + diplomaHerkomst: 'handmatig', + vraagIds: ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'], + }, + 1, + ), + }, +}; export const Controle: Story = { args: { seed: invullen(filled, 2) } }; export const Indienen: Story = { args: { seed: { tag: 'Indienen', data: validData } } }; -export const Ingediend: Story = { args: { seed: { tag: 'Ingediend', data: validData, referentie: 'BIG-2026-123456' } } }; -export const Mislukt: Story = { args: { seed: { tag: 'Mislukt', data: validData, error: 'Netwerkfout' } } }; +export const Ingediend: Story = { + args: { seed: { tag: 'Ingediend', data: validData, referentie: 'BIG-2026-123456' } }, +}; +export const Mislukt: Story = { + args: { seed: { tag: 'Mislukt', data: validData, error: 'Netwerkfout' } }, +}; diff --git a/src/app/registratie/ui/registratie.page.ts b/src/app/registratie/ui/registratie.page.ts index 206b4c7..1600d8a 100644 --- a/src/app/registratie/ui/registratie.page.ts +++ b/src/app/registratie/ui/registratie.page.ts @@ -12,11 +12,11 @@ import { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/r + backLink="/dashboard" + > - In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het - diploma waarmee u zich registreert, en een controle. Uw gegevens blijven bewaard - als u de pagina herlaadt. + In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het diploma waarmee + u zich registreert, en een controle. Uw gegevens blijven bewaard als u de pagina herlaadt.
      diff --git a/src/app/registratie/ui/registration-detail.page.ts b/src/app/registratie/ui/registration-detail.page.ts index eb84513..0875b94 100644 --- a/src/app/registratie/ui/registration-detail.page.ts +++ b/src/app/registratie/ui/registration-detail.page.ts @@ -9,11 +9,18 @@ import { BigProfileStore } from '@registratie/application/big-profile.store'; @Component({ selector: 'app-registration-detail-page', imports: [ - PageShellComponent, SkeletonComponent, ...ASYNC, - RegistrationSummaryComponent, ChangeRequestFormComponent, + PageShellComponent, + SkeletonComponent, + ...ASYNC, + RegistrationSummaryComponent, + ChangeRequestFormComponent, ], template: ` - + diff --git a/src/app/registratie/ui/registration-summary/registration-summary.component.ts b/src/app/registratie/ui/registration-summary/registration-summary.component.ts index 616cccf..d8a431c 100644 --- a/src/app/registratie/ui/registration-summary/registration-summary.component.ts +++ b/src/app/registratie/ui/registration-summary/registration-summary.component.ts @@ -13,25 +13,60 @@ import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; imports: [DatePipe, DataRowComponent, StatusBadgeComponent, DataBlockComponent], template: ` -
      +
      -
      +
      @switch (reg().status.tag) { @case ('Geregistreerd') { -
      +
      } @case ('Geschorst') { -
      -
      +
      +
      } @case ('Doorgehaald') { -
      -
      +
      +
      } }
      diff --git a/src/app/registratie/ui/registration-summary/registration-summary.stories.ts b/src/app/registratie/ui/registration-summary/registration-summary.stories.ts index 2cdb887..ab099b1 100644 --- a/src/app/registratie/ui/registration-summary/registration-summary.stories.ts +++ b/src/app/registratie/ui/registration-summary/registration-summary.stories.ts @@ -23,8 +23,18 @@ export const Geregistreerd: Story = { args: { reg: { ...base, status: { tag: 'Geregistreerd', herregistratieDatum: '2027-09-01' } } }, }; 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 = { - 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' }, + }, + }, }; diff --git a/src/app/registratie/ui/registration-table/registration-table.component.ts b/src/app/registratie/ui/registration-table/registration-table.component.ts index 6580c48..632d4d9 100644 --- a/src/app/registratie/ui/registration-table/registration-table.component.ts +++ b/src/app/registratie/ui/registration-table/registration-table.component.ts @@ -21,7 +21,7 @@ import { Aantekening } from '@registratie/domain/registration'; {{ row.type }} {{ row.omschrijving }} - {{ row.datum | date:'mediumDate' }} + {{ row.datum | date: 'mediumDate' }} } diff --git a/src/app/registratie/ui/registration-table/registration-table.stories.ts b/src/app/registratie/ui/registration-table/registration-table.stories.ts index e720fec..36bc08c 100644 --- a/src/app/registratie/ui/registration-table/registration-table.stories.ts +++ b/src/app/registratie/ui/registration-table/registration-table.stories.ts @@ -12,7 +12,11 @@ export const Default: Story = { args: { rows: [ { type: 'Specialisme', omschrijving: 'Huisartsgeneeskunde', datum: '2016-04-12' }, - { type: 'Aantekening', omschrijving: 'Erkend opleider huisartsgeneeskunde', datum: '2019-01-08' }, + { + type: 'Aantekening', + omschrijving: 'Erkend opleider huisartsgeneeskunde', + datum: '2019-01-08', + }, ], }, }; diff --git a/src/app/shared/application/remote-data.ts b/src/app/shared/application/remote-data.ts index eeb51ee..15cb863 100644 --- a/src/app/shared/application/remote-data.ts +++ b/src/app/shared/application/remote-data.ts @@ -78,7 +78,11 @@ export function map3( c: RemoteData, f: (a: A, b: B, c: C) => R, ): RemoteData { - return map2(map2(a, b, (x, y) => [x, y] as const), c, ([x, y], z) => f(x, y, z)); + return map2( + map2(a, b, (x, y) => [x, y] as const), + c, + ([x, y], z) => f(x, y, z), + ); } /** Chain a second source that depends on the first one's value. */ diff --git a/src/app/shared/application/submit.ts b/src/app/shared/application/submit.ts index bd4b28f..0703d6e 100644 --- a/src/app/shared/application/submit.ts +++ b/src/app/shared/application/submit.ts @@ -7,7 +7,10 @@ import { problemDetail } from '@shared/infrastructure/api-error'; * its own payload mapping. The backend re-validates and returns a 422 * ProblemDetails on rejection, surfaced here as the error string. */ -export async function runSubmit(fn: () => Promise, fallback: string): Promise> { +export async function runSubmit( + fn: () => Promise, + fallback: string, +): Promise> { try { return ok(await fn()); } catch (e) { diff --git a/src/app/shared/infrastructure/api-error.spec.ts b/src/app/shared/infrastructure/api-error.spec.ts index 4193360..1faa689 100644 --- a/src/app/shared/infrastructure/api-error.spec.ts +++ b/src/app/shared/infrastructure/api-error.spec.ts @@ -3,7 +3,9 @@ import { problemDetail, problemFieldErrors } from './api-error'; describe('problemDetail', () => { it('extracts the detail from an RFC-7807 ProblemDetails', () => { - expect(problemDetail({ detail: 'Afgewezen: 0 uren.', status: 422 }, 'fallback')).toBe('Afgewezen: 0 uren.'); + expect(problemDetail({ detail: 'Afgewezen: 0 uren.', status: 422 }, 'fallback')).toBe( + 'Afgewezen: 0 uren.', + ); }); it('falls back when there is no detail', () => { @@ -15,8 +17,9 @@ describe('problemDetail', () => { describe('problemFieldErrors (G4 seam)', () => { it('maps a ValidationProblemDetails errors dict to first-message-per-field', () => { - expect(problemFieldErrors({ errors: { straat: ['Verplicht.'], postcode: ['Ongeldig.', 'x'] } })) - .toEqual({ straat: 'Verplicht.', postcode: 'Ongeldig.' }); + expect( + problemFieldErrors({ errors: { straat: ['Verplicht.'], postcode: ['Ongeldig.', 'x'] } }), + ).toEqual({ straat: 'Verplicht.', postcode: 'Ongeldig.' }); }); it('returns {} when there is no errors envelope (the current backend shape)', () => { diff --git a/src/app/shared/infrastructure/role.ts b/src/app/shared/infrastructure/role.ts index cf1708b..a0989e6 100644 --- a/src/app/shared/infrastructure/role.ts +++ b/src/app/shared/infrastructure/role.ts @@ -9,5 +9,7 @@ import { Role } from '@shared/domain/role'; * the FE derives `editable` from it. */ export function currentRole(): Role { - return new URLSearchParams(window.location.search).get('role') === 'approver' ? 'approver' : 'drafter'; + return new URLSearchParams(window.location.search).get('role') === 'approver' + ? 'approver' + : 'drafter'; } diff --git a/src/app/shared/infrastructure/scenario.interceptor.ts b/src/app/shared/infrastructure/scenario.interceptor.ts index 86fc607..6694226 100644 --- a/src/app/shared/infrastructure/scenario.interceptor.ts +++ b/src/app/shared/infrastructure/scenario.interceptor.ts @@ -21,8 +21,11 @@ export const scenarioInterceptor: HttpInterceptorFn = (req, next) => { return of(new HttpResponse({ status: 200, body: '[]' })).pipe(delay(400)); case 'error': return timer(400).pipe( - switchMap(() => throwError(() => - new HttpErrorResponse({ status: 500, statusText: 'Demo-fout', url: req.url }))), + switchMap(() => + throwError( + () => new HttpErrorResponse({ status: 500, statusText: 'Demo-fout', url: req.url }), + ), + ), ); default: return next(req); diff --git a/src/app/shared/infrastructure/scenario.ts b/src/app/shared/infrastructure/scenario.ts index 33e78dc..698957e 100644 --- a/src/app/shared/infrastructure/scenario.ts +++ b/src/app/shared/infrastructure/scenario.ts @@ -9,7 +9,15 @@ export type Scenario = | 'upload-slow' | 'upload-fail'; -const VALID: Scenario[] = ['default', 'slow', 'loading', 'empty', 'error', 'upload-slow', 'upload-fail']; +const VALID: Scenario[] = [ + 'default', + 'slow', + 'loading', + 'empty', + 'error', + 'upload-slow', + 'upload-fail', +]; /** Reads ?scenario= from the URL so a demo can force each async state. */ export function currentScenario(): Scenario { diff --git a/src/app/shared/kernel/rich-text.spec.ts b/src/app/shared/kernel/rich-text.spec.ts index 0be6a92..e3df310 100644 --- a/src/app/shared/kernel/rich-text.spec.ts +++ b/src/app/shared/kernel/rich-text.spec.ts @@ -1,10 +1,26 @@ import { describe, it, expect } from 'vitest'; -import { RichTextBlock, deepCopyBlock, emptyBlock, isBlockEmpty, placeholderKeysIn } from './rich-text'; +import { + RichTextBlock, + deepCopyBlock, + emptyBlock, + isBlockEmpty, + placeholderKeysIn, +} from './rich-text'; const block = (): RichTextBlock => ({ paragraphs: [ - { nodes: [{ type: 'text', text: 'Beste ' }, { type: 'placeholder', key: 'naam' }] }, - { nodes: [{ type: 'placeholder', key: 'datum' }, { type: 'placeholder', key: 'naam' }] }, + { + nodes: [ + { type: 'text', text: 'Beste ' }, + { type: 'placeholder', key: 'naam' }, + ], + }, + { + nodes: [ + { type: 'placeholder', key: 'datum' }, + { type: 'placeholder', key: 'naam' }, + ], + }, ], }); @@ -16,7 +32,9 @@ describe('rich-text', () => { it('isBlockEmpty is false when any placeholder or non-blank text exists', () => { expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'text', text: ' ' }] }] })).toBe(true); - expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'placeholder', key: 'x' }] }] })).toBe(false); + expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'placeholder', key: 'x' }] }] })).toBe( + false, + ); expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'text', text: 'hoi' }] }] })).toBe(false); }); @@ -31,7 +49,10 @@ describe('rich-text', () => { expect(copy).not.toBe(original); expect(copy.paragraphs[0]).not.toBe(original.paragraphs[0]); // Mutating the copy must not touch the original — proves no shared reference. - (copy.paragraphs[0].nodes as { type: 'text'; text: string }[])[0] = { type: 'text', text: 'CHANGED' }; + (copy.paragraphs[0].nodes as { type: 'text'; text: string }[])[0] = { + type: 'text', + text: 'CHANGED', + }; expect(placeholderKeysIn(original)).toEqual(['naam', 'datum', 'naam']); expect((original.paragraphs[0].nodes[0] as { text: string }).text).toBe('Beste '); }); diff --git a/src/app/shared/layout/breadcrumb/breadcrumb-trail.ts b/src/app/shared/layout/breadcrumb/breadcrumb-trail.ts index 87776d3..ce3f483 100644 --- a/src/app/shared/layout/breadcrumb/breadcrumb-trail.ts +++ b/src/app/shared/layout/breadcrumb/breadcrumb-trail.ts @@ -3,13 +3,19 @@ import { BreadcrumbItem } from './breadcrumb.component'; /** Route → breadcrumb label + parent. The app has a small fixed route set (see app.routes.ts), so a static map is enough — no per-page wiring. ponytail: static map, not a breadcrumb service; revisit if routes go dynamic. */ -interface Crumb { label: string; parent?: string } +interface Crumb { + label: string; + parent?: string; +} const ROUTES: Record = { '/dashboard': { label: $localize`:@@crumb.dashboard:Mijn overzicht` }, '/registratie': { label: $localize`:@@crumb.registratie:Mijn gegevens`, parent: '/dashboard' }, '/registreren': { label: $localize`:@@crumb.registreren:Inschrijven`, parent: '/dashboard' }, - '/herregistratie': { label: $localize`:@@crumb.herregistratie:Herregistratie`, parent: '/dashboard' }, + '/herregistratie': { + label: $localize`:@@crumb.herregistratie:Herregistratie`, + parent: '/dashboard', + }, '/intake': { label: $localize`:@@crumb.intake:Herregistratie-intake`, parent: '/dashboard' }, '/concepts': { label: $localize`:@@crumb.concepts:Functionele patronen`, parent: '/dashboard' }, }; diff --git a/src/app/shared/layout/breadcrumb/breadcrumb.component.ts b/src/app/shared/layout/breadcrumb/breadcrumb.component.ts index 350d13b..36c78d8 100644 --- a/src/app/shared/layout/breadcrumb/breadcrumb.component.ts +++ b/src/app/shared/layout/breadcrumb/breadcrumb.component.ts @@ -16,7 +16,13 @@ export interface BreadcrumbItem { // — including this one, wherever it's mounted. Override it so the breadcrumb // never carries its own background (it should show whatever's behind it, e.g. // the titlebar's robijn fill). - styles: [`nav{background:none}`], + styles: [ + ` + nav { + background: none; + } + `, + ], template: `

      - @if (instructions()) {

      {{ instructions() }}

      } + @if (instructions()) { +

      {{ instructions() }}

      + }
diff --git a/src/app/shared/ui/choice-link/choice-link.stories.ts b/src/app/shared/ui/choice-link/choice-link.stories.ts index 2fdaeea..92243a5 100644 --- a/src/app/shared/ui/choice-link/choice-link.stories.ts +++ b/src/app/shared/ui/choice-link/choice-link.stories.ts @@ -23,11 +23,18 @@ export default meta; type Story = StoryObj; export const Navigatie: Story = { - args: { heading: 'Ik heb een Nederlands diploma', instructions: 'U kunt direct uw registratie aanvragen.', to: '/registreren' }, + args: { + heading: 'Ik heb een Nederlands diploma', + instructions: 'U kunt direct uw registratie aanvragen.', + to: '/registreren', + }, }; export const Actie: Story = { args: { heading: 'Inschrijving', instructions: 'Stap 2 van 3', clickable: true }, }; export const NietInteractief: Story = { - args: { heading: 'Herregistratie', instructions: 'Referentie 2024-00123 · ingediend op 12 mei 2024' }, + args: { + heading: 'Herregistratie', + instructions: 'Referentie 2024-00123 · ingediend op 12 mei 2024', + }, }; diff --git a/src/app/shared/ui/confirmation/confirmation.component.ts b/src/app/shared/ui/confirmation/confirmation.component.ts index 47346ce..810da27 100644 --- a/src/app/shared/ui/confirmation/confirmation.component.ts +++ b/src/app/shared/ui/confirmation/confirmation.component.ts @@ -8,12 +8,19 @@ import { Component, input } from '@angular/core'; selector: 'app-confirmation', template: `
- +
- {{ successPrefix() }}{{ title() }} + {{ successPrefix() }}{{ title() }}
diff --git a/src/app/shared/ui/data-block/data-block.component.ts b/src/app/shared/ui/data-block/data-block.component.ts index dfe9400..92796ab 100644 --- a/src/app/shared/ui/data-block/data-block.component.ts +++ b/src/app/shared/ui/data-block/data-block.component.ts @@ -12,7 +12,9 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component'; selector: 'app-data-block', imports: [HeadingComponent], template: ` - @if (heading()) { {{ heading() }} } + @if (heading()) { + {{ heading() }} + }
diff --git a/src/app/shared/ui/data-row/data-row.component.ts b/src/app/shared/ui/data-row/data-row.component.ts index 8067b49..50f2722 100644 --- a/src/app/shared/ui/data-row/data-row.component.ts +++ b/src/app/shared/ui/data-row/data-row.component.ts @@ -15,10 +15,18 @@ import { Component, input } from '@angular/core'; // The CIBG datablock draws a separator between entries. It ships that border on // dt/dd with a `:last-of-type` reset, but our one-row-per-
grouping (for axe) // makes every dt/dd a last-of-type — so we carry the separator on the row instead. - styles: [`:host:not(:last-of-type){border-block-end:var(--rhc-border-width-sm) solid var(--rhc-color-cool-grey-200)}`], + styles: [ + ` + :host:not(:last-of-type) { + border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-cool-grey-200); + } + `, + ], template: `
{{ key() }}
-
{{ value() }}
+
+ {{ value() }} +
`, }) export class DataRowComponent { diff --git a/src/app/shared/ui/debug-state/debug-state.component.ts b/src/app/shared/ui/debug-state/debug-state.component.ts index a2ca2af..57886d2 100644 --- a/src/app/shared/ui/debug-state/debug-state.component.ts +++ b/src/app/shared/ui/debug-state/debug-state.component.ts @@ -19,25 +19,53 @@ import { maskBsn, redactProfile } from './mask'; @Component({ selector: 'app-debug-state', imports: [JsonPipe], - styles: [` - .fab{position:fixed;bottom:1rem;right:1rem;z-index:9999;font:600 12px/1 monospace; - background:var(--app-devpanel-bg);color:var(--app-devpanel-accent); - border:var(--rhc-border-width-sm) solid var(--app-devpanel-border);border-radius:4px; - padding:.5rem .75rem;cursor:pointer} - .panel{position:fixed;bottom:3.25rem;right:1rem;z-index:9999;width:min(90vw,28rem); - max-height:70vh;overflow:auto;background:var(--app-devpanel-bg);color:var(--app-devpanel-fg); - border:var(--rhc-border-width-sm) solid var(--app-devpanel-border); - border-radius:6px;box-shadow:0 6px 24px var(--app-devpanel-shadow)} - .panel pre{margin:0;padding:.75rem;font:12px/1.5 monospace;white-space:pre-wrap; - word-break:break-word} - `], + styles: [ + ` + .fab { + position: fixed; + bottom: 1rem; + right: 1rem; + z-index: 9999; + font: 600 12px/1 monospace; + background: var(--app-devpanel-bg); + color: var(--app-devpanel-accent); + border: var(--rhc-border-width-sm) solid var(--app-devpanel-border); + border-radius: 4px; + padding: 0.5rem 0.75rem; + cursor: pointer; + } + .panel { + position: fixed; + bottom: 3.25rem; + right: 1rem; + z-index: 9999; + width: min(90vw, 28rem); + max-height: 70vh; + overflow: auto; + background: var(--app-devpanel-bg); + color: var(--app-devpanel-fg); + border: var(--rhc-border-width-sm) solid var(--app-devpanel-border); + border-radius: 6px; + box-shadow: 0 6px 24px var(--app-devpanel-shadow); + } + .panel pre { + margin: 0; + padding: 0.75rem; + font: 12px/1.5 monospace; + white-space: pre-wrap; + word-break: break-word; + } + `, + ], template: ` @if (isDev) { @if (visible()) { -
{{ snapshot() | json }}
+
+
{{ snapshot() | json }}
+
} } `, diff --git a/src/app/shared/ui/form-field/form-field.component.ts b/src/app/shared/ui/form-field/form-field.component.ts index e103896..4d344af 100644 --- a/src/app/shared/ui/form-field/form-field.component.ts +++ b/src/app/shared/ui/form-field/form-field.component.ts @@ -9,14 +9,18 @@ import { Component, booleanAttribute, input } from '@angular/core'; selector: 'app-form-field', template: `
- +
@if (description()) {
{{ description() }}
} @if (error()) { -
{{ error() }}
+
+ {{ error() }} +
}
diff --git a/src/app/shared/ui/form-field/form-field.stories.ts b/src/app/shared/ui/form-field/form-field.stories.ts index 3495aa5..c536308 100644 --- a/src/app/shared/ui/form-field/form-field.stories.ts +++ b/src/app/shared/ui/form-field/form-field.stories.ts @@ -25,5 +25,10 @@ export const Default: Story = { args: { label: 'BSN', fieldId: 'bsn', description: '9 cijfers', required: true }, }; export const WithError: Story = { - args: { label: 'Straat en huisnummer', fieldId: 'street', error: 'Dit veld is verplicht.', required: true }, + args: { + label: 'Straat en huisnummer', + fieldId: 'street', + error: 'Dit veld is verplicht.', + required: true, + }, }; diff --git a/src/app/shared/ui/heading/heading.component.ts b/src/app/shared/ui/heading/heading.component.ts index 31ba486..07df2e6 100644 --- a/src/app/shared/ui/heading/heading.component.ts +++ b/src/app/shared/ui/heading/heading.component.ts @@ -10,11 +10,21 @@ import { NgTemplateOutlet } from '@angular/common'; template: ` @switch (level()) { - @case (1) {

} - @case (2) {

} - @case (3) {

} - @case (4) {

} - @default {
} + @case (1) { +

+ } + @case (2) { +

+ } + @case (3) { +

+ } + @case (4) { +

+ } + @default { +
+ } } `, }) diff --git a/src/app/shared/ui/placeholder-chip/placeholder-chip.component.ts b/src/app/shared/ui/placeholder-chip/placeholder-chip.component.ts index c47302b..9eab05d 100644 --- a/src/app/shared/ui/placeholder-chip/placeholder-chip.component.ts +++ b/src/app/shared/ui/placeholder-chip/placeholder-chip.component.ts @@ -8,21 +8,52 @@ import { Component, computed, input } from '@angular/core'; everywhere the letter is shown, not edited.) */ @Component({ selector: 'app-placeholder-chip', - styles: [` - .chip{ - display:inline-flex;align-items:center;gap:0.2em; - border-radius:var(--rhc-border-radius-sm); - padding:0 0.35em;line-height:1.6;border:1px solid transparent;white-space:nowrap; - } - /* Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */ - .chip::before{content:'\\7B';opacity:0.6;font-weight:700} - .chip::after{content:'\\7D';opacity:0.6;font-weight:700} - .chip--auto{background:var(--rhc-color-cool-grey-100);color:var(--rhc-color-foreground-default)} - .chip--manual{background:var(--rhc-color-geel-100);color:var(--rhc-color-foreground-default)} - .chip--warning{background:var(--rhc-color-geel-100);border-color:var(--rhc-color-border-default);color:var(--rhc-color-foreground-default)} - .chip--error{background:var(--rhc-color-rood-100);border-color:var(--rhc-color-border-default);color:var(--rhc-color-foreground-default)} - `], - template: `{{ label() }}`, + styles: [ + ` + .chip { + display: inline-flex; + align-items: center; + gap: 0.2em; + border-radius: var(--rhc-border-radius-sm); + padding: 0 0.35em; + line-height: 1.6; + border: 1px solid transparent; + white-space: nowrap; + } + /* Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */ + .chip::before { + content: '\\7B'; + opacity: 0.6; + font-weight: 700; + } + .chip::after { + content: '\\7D'; + opacity: 0.6; + font-weight: 700; + } + .chip--auto { + background: var(--rhc-color-cool-grey-100); + color: var(--rhc-color-foreground-default); + } + .chip--manual { + background: var(--rhc-color-geel-100); + color: var(--rhc-color-foreground-default); + } + .chip--warning { + background: var(--rhc-color-geel-100); + border-color: var(--rhc-color-border-default); + color: var(--rhc-color-foreground-default); + } + .chip--error { + background: var(--rhc-color-rood-100); + border-color: var(--rhc-color-border-default); + color: var(--rhc-color-foreground-default); + } + `, + ], + template: `{{ + label() + }}`, }) export class PlaceholderChipComponent { label = input.required(); @@ -41,7 +72,12 @@ export class PlaceholderChipComponent { }); protected ariaLabel = computed(() => { - const status = { auto: this.autoText(), manual: this.manualText(), warning: this.warningText(), error: this.errorText() }[this.variant()]; + const status = { + auto: this.autoText(), + manual: this.manualText(), + warning: this.warningText(), + error: this.errorText(), + }[this.variant()]; return $localize`:@@placeholderChip.aria:Veld ${this.label()}:label:, ${status}:status:`; }); } diff --git a/src/app/shared/ui/placeholder-chip/placeholder-chip.stories.ts b/src/app/shared/ui/placeholder-chip/placeholder-chip.stories.ts index 9dc4148..d836e71 100644 --- a/src/app/shared/ui/placeholder-chip/placeholder-chip.stories.ts +++ b/src/app/shared/ui/placeholder-chip/placeholder-chip.stories.ts @@ -12,7 +12,15 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const AutoResolvable: Story = { args: { label: 'Naam zorgverlener', autoResolvable: true, state: 'ok' } }; -export const Manual: Story = { args: { label: 'Reden besluit', autoResolvable: false, state: 'ok' } }; -export const Warning: Story = { args: { label: 'Oud kenmerk', autoResolvable: true, state: 'warning' } }; -export const Error: Story = { args: { label: 'Onbekend veld', autoResolvable: false, state: 'error' } }; +export const AutoResolvable: Story = { + args: { label: 'Naam zorgverlener', autoResolvable: true, state: 'ok' }, +}; +export const Manual: Story = { + args: { label: 'Reden besluit', autoResolvable: false, state: 'ok' }, +}; +export const Warning: Story = { + args: { label: 'Oud kenmerk', autoResolvable: true, state: 'warning' }, +}; +export const Error: Story = { + args: { label: 'Onbekend veld', autoResolvable: false, state: 'error' }, +}; diff --git a/src/app/shared/ui/radio-group/radio-group.component.ts b/src/app/shared/ui/radio-group/radio-group.component.ts index 30148eb..4864dec 100644 --- a/src/app/shared/ui/radio-group/radio-group.component.ts +++ b/src/app/shared/ui/radio-group/radio-group.component.ts @@ -23,7 +23,8 @@ export const JA_NEE: RadioOption[] = [ role="radiogroup" [attr.aria-labelledby]="name() + '-label'" [attr.aria-invalid]="invalid() ? 'true' : null" - [attr.aria-describedby]="invalid() ? name() + '-error' : null"> + [attr.aria-describedby]="invalid() ? name() + '-error' : null" + > @for (opt of options(); track opt.value) {
+ (blur)="onTouched()" + />
}
`, - providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RadioGroupComponent), multi: true }], + providers: [ + { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RadioGroupComponent), multi: true }, + ], }) export class RadioGroupComponent implements ControlValueAccessor { options = input.required(); @@ -58,8 +62,16 @@ export class RadioGroupComponent implements ControlValueAccessor { this.value = v; this.onChange(v); } - writeValue(v: string) { this.value = v ?? ''; } - registerOnChange(fn: (v: string) => void) { this.onChange = fn; } - registerOnTouched(fn: () => void) { this.onTouched = fn; } - setDisabledState(d: boolean) { this.disabled = d; } + writeValue(v: string) { + this.value = v ?? ''; + } + registerOnChange(fn: (v: string) => void) { + this.onChange = fn; + } + registerOnTouched(fn: () => void) { + this.onTouched = fn; + } + setDisabledState(d: boolean) { + this.disabled = d; + } } diff --git a/src/app/shared/ui/radio-group/radio-group.stories.ts b/src/app/shared/ui/radio-group/radio-group.stories.ts index bf895e4..07957eb 100644 --- a/src/app/shared/ui/radio-group/radio-group.stories.ts +++ b/src/app/shared/ui/radio-group/radio-group.stories.ts @@ -13,5 +13,11 @@ export default meta; type Story = StoryObj; export const JaNee: Story = { - args: { name: 'voorbeeld', options: [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }] }, + args: { + name: 'voorbeeld', + options: [ + { value: 'ja', label: 'Ja' }, + { value: 'nee', label: 'Nee' }, + ], + }, }; diff --git a/src/app/shared/ui/review-section/review-section.component.ts b/src/app/shared/ui/review-section/review-section.component.ts index 7be0647..6c31063 100644 --- a/src/app/shared/ui/review-section/review-section.component.ts +++ b/src/app/shared/ui/review-section/review-section.component.ts @@ -13,7 +13,9 @@ import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';

{{ heading() }}

@if (showEdit()) { }
diff --git a/src/app/shared/ui/rich-text-editor/rich-text-dom.spec.ts b/src/app/shared/ui/rich-text-editor/rich-text-dom.spec.ts index 29f2fd9..aeca58c 100644 --- a/src/app/shared/ui/rich-text-editor/rich-text-dom.spec.ts +++ b/src/app/shared/ui/rich-text-editor/rich-text-dom.spec.ts @@ -23,7 +23,12 @@ describe('rich-text DOM boundary', () => { { type: 'text', text: 'nieuwe regel' }, ], }, - { nodes: [{ type: 'text', text: 'Op ' }, { type: 'placeholder', key: 'datum' }] }, + { + nodes: [ + { type: 'text', text: 'Op ' }, + { type: 'placeholder', key: 'datum' }, + ], + }, ], }; expect(roundTrip(block)).toEqual(block); @@ -46,7 +51,9 @@ describe('rich-text DOM boundary', () => { it('reads combined marks in canonical order regardless of nesting', () => { const root = document.createElement('div'); root.innerHTML = '

x

'; // italic wrapping bold - expect(readBlock(root)).toEqual({ paragraphs: [{ nodes: [{ type: 'text', text: 'x', marks: ['bold', 'italic'] }] }] }); + expect(readBlock(root)).toEqual({ + paragraphs: [{ nodes: [{ type: 'text', text: 'x', marks: ['bold', 'italic'] }] }], + }); }); it('round-trips bullet and numbered lists mixed with paragraphs', () => { @@ -106,7 +113,16 @@ describe('rich-text DOM boundary', () => { const root = document.createElement('div'); renderInto( root, - { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }, { type: 'placeholder', key: 'reden' }] }] }, + { + paragraphs: [ + { + nodes: [ + { type: 'placeholder', key: 'naam' }, + { type: 'placeholder', key: 'reden' }, + ], + }, + ], + }, labelFor, (key) => key === 'naam', ); diff --git a/src/app/shared/ui/rich-text-editor/rich-text-dom.ts b/src/app/shared/ui/rich-text-editor/rich-text-dom.ts index e3d9b31..55faa95 100644 --- a/src/app/shared/ui/rich-text-editor/rich-text-dom.ts +++ b/src/app/shared/ui/rich-text-editor/rich-text-dom.ts @@ -76,9 +76,15 @@ export function createChip(doc: Document, key: string, label: string, auto = fal return span; } -function renderNode(node: RichTextNode, labelFor: (key: string) => string, doc: Document, autoFor?: (key: string) => boolean): Node { +function renderNode( + node: RichTextNode, + labelFor: (key: string) => string, + doc: Document, + autoFor?: (key: string) => boolean, +): Node { if (node.type === 'lineBreak') return doc.createElement('br'); - if (node.type === 'placeholder') return createChip(doc, node.key, labelFor(node.key), autoFor?.(node.key) ?? false); + if (node.type === 'placeholder') + return createChip(doc, node.key, labelFor(node.key), autoFor?.(node.key) ?? false); let el: Node = doc.createTextNode(node.text); // Nest marks in a canonical order so read-back is deterministic. for (const m of ORDER.filter((x) => node.marks?.includes(x))) { @@ -124,7 +130,10 @@ function readLine(el: HTMLElement): { nodes: RichTextNode[] } { function collect(node: Node, marks: Mark[], out: RichTextNode[]): void { if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent ?? ''; - if (text !== '') out.push(marks.length ? { type: 'text', text, marks: canonical(marks) } : { type: 'text', text }); + if (text !== '') + out.push( + marks.length ? { type: 'text', text, marks: canonical(marks) } : { type: 'text', text }, + ); return; } if (node.nodeType !== Node.ELEMENT_NODE) return; @@ -144,7 +153,11 @@ function collect(node: Node, marks: Mark[], out: RichTextNode[]): void { } function isChip(node: Node | null | undefined): node is HTMLElement { - return !!node && node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset?.['phKey'] != null; + return ( + !!node && + node.nodeType === Node.ELEMENT_NODE && + (node as HTMLElement).dataset?.['phKey'] != null + ); } /** @@ -152,7 +165,11 @@ function isChip(node: Node | null | undefined): node is HTMLElement { * (-1 = before / Backspace, +1 = after / Delete), or null. Chips are contenteditable=false, * which some browsers won't delete on Backspace; the editor uses this to remove them itself. */ -export function adjacentChip(container: Node, offset: number, direction: -1 | 1): HTMLElement | null { +export function adjacentChip( + container: Node, + offset: number, + direction: -1 | 1, +): HTMLElement | null { let sibling: Node | null | undefined; if (container.nodeType === Node.TEXT_NODE) { // Only adjacent when the caret sits at the text edge (otherwise there are chars to delete first). diff --git a/src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts b/src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts index 8aaad56..0321940 100644 --- a/src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts +++ b/src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts @@ -23,41 +23,122 @@ export interface PlaceholderOption { */ @Component({ selector: 'app-rich-text-editor', - styles: [` - :host{display:block} - .rte-toolbar{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-sm);align-items:center;margin-block-end:var(--rhc-space-max-sm)} - .rte-toolbar button{min-inline-size:2.2rem} - .rte-sep{inline-size:1px;align-self:stretch;background:var(--rhc-color-border-default)} - .rte-editable{ - border:1px solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-sm); - padding:var(--rhc-space-max-md);min-block-size:4rem; - } - .rte-editable[contenteditable='false']{background:var(--rhc-color-cool-grey-100)} - .rte-editable :is(p){margin:0 0 var(--rhc-space-max-sm)} - .rte-editable :is(ul,ol){margin:0 0 var(--rhc-space-max-sm);padding-inline-start:1.4em} - /* Placeholder chips read as fill-in fields: auto-resolvable (grey, filled server-side) + styles: [ + ` + :host { + display: block; + } + .rte-toolbar { + display: flex; + flex-wrap: wrap; + gap: var(--rhc-space-max-sm); + align-items: center; + margin-block-end: var(--rhc-space-max-sm); + } + .rte-toolbar button { + min-inline-size: 2.2rem; + } + .rte-sep { + inline-size: 1px; + align-self: stretch; + background: var(--rhc-color-border-default); + } + .rte-editable { + border: 1px solid var(--rhc-color-border-default); + border-radius: var(--rhc-border-radius-sm); + padding: var(--rhc-space-max-md); + min-block-size: 4rem; + } + .rte-editable[contenteditable='false'] { + background: var(--rhc-color-cool-grey-100); + } + .rte-editable :is(p) { + margin: 0 0 var(--rhc-space-max-sm); + } + .rte-editable :is(ul, ol) { + margin: 0 0 var(--rhc-space-max-sm); + padding-inline-start: 1.4em; + } + /* Placeholder chips read as fill-in fields: auto-resolvable (grey, filled server-side) vs manual (yellow, still needs a value). The read-only preview adds error/warning states. Chips are created imperatively (createChip) inside contenteditable, so they never receive Angular's _ngcontent scoping attribute — ::ng-deep is required or the rules won't match them. Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */ - :host ::ng-deep .rte-chip{ - border:1px dashed var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-sm); - padding:0 0.3em;white-space:nowrap; - } - :host ::ng-deep .rte-chip[data-auto='true']{background:var(--rhc-color-cool-grey-100)} - :host ::ng-deep .rte-chip[data-auto='false']{background:var(--rhc-color-geel-100)} - :host ::ng-deep .rte-chip::before{content:'\\7B';opacity:0.6;font-weight:700;margin-inline-end:0.1em} - :host ::ng-deep .rte-chip::after{content:'\\7D';opacity:0.6;font-weight:700;margin-inline-start:0.1em} - `], + :host ::ng-deep .rte-chip { + border: 1px dashed var(--rhc-color-border-default); + border-radius: var(--rhc-border-radius-sm); + padding: 0 0.3em; + white-space: nowrap; + } + :host ::ng-deep .rte-chip[data-auto='true'] { + background: var(--rhc-color-cool-grey-100); + } + :host ::ng-deep .rte-chip[data-auto='false'] { + background: var(--rhc-color-geel-100); + } + :host ::ng-deep .rte-chip::before { + content: '\\7B'; + opacity: 0.6; + font-weight: 700; + margin-inline-end: 0.1em; + } + :host ::ng-deep .rte-chip::after { + content: '\\7D'; + opacity: 0.6; + font-weight: 700; + margin-inline-start: 0.1em; + } + `, + ], template: ` @if (editable()) { } -
+
`, }) export class RichTextEditorComponent { @@ -97,7 +186,8 @@ export class RichTextEditorComponent { private lastEmitted = ''; private labelFor = (key: string) => this.placeholders().find((p) => p.key === key)?.label ?? key; - private autoFor = (key: string) => this.placeholders().find((p) => p.key === key)?.autoResolvable ?? false; + private autoFor = (key: string) => + this.placeholders().find((p) => p.key === key)?.autoResolvable ?? false; constructor() { // Render when content arrives/changes from OUTSIDE. Skip our own emitted value @@ -149,7 +239,11 @@ export class RichTextEditorComponent { return; } if (!(e.ctrlKey || e.metaKey) || e.altKey) return; - const cmd = { b: 'bold', i: 'italic', u: 'underline' }[e.key.toLowerCase()] as 'bold' | 'italic' | 'underline' | undefined; + const cmd = { b: 'bold', i: 'italic', u: 'underline' }[e.key.toLowerCase()] as + | 'bold' + | 'italic' + | 'underline' + | undefined; if (!cmd) return; e.preventDefault(); this.format(cmd); @@ -164,7 +258,11 @@ export class RichTextEditorComponent { if (!sel || !sel.isCollapsed || !sel.rangeCount) return; const range = sel.getRangeAt(0); if (!el.contains(range.startContainer)) return; - const chip = adjacentChip(range.startContainer, range.startOffset, e.key === 'Backspace' ? -1 : 1); + const chip = adjacentChip( + range.startContainer, + range.startOffset, + e.key === 'Backspace' ? -1 : 1, + ); if (!chip) return; e.preventDefault(); chip.remove(); diff --git a/src/app/shared/ui/rich-text-editor/rich-text-editor.stories.ts b/src/app/shared/ui/rich-text-editor/rich-text-editor.stories.ts index 9977c93..bd0c18a 100644 --- a/src/app/shared/ui/rich-text-editor/rich-text-editor.stories.ts +++ b/src/app/shared/ui/rich-text-editor/rich-text-editor.stories.ts @@ -11,7 +11,13 @@ const sample: RichTextBlock = { { type: 'text', text: ',' }, ], }, - { nodes: [{ type: 'text', text: 'Op ' }, { type: 'placeholder', key: 'datum' }, { type: 'text', text: ' hebben wij besloten.' }] }, + { + nodes: [ + { type: 'text', text: 'Op ' }, + { type: 'placeholder', key: 'datum' }, + { type: 'text', text: ' hebben wij besloten.' }, + ], + }, ], }; @@ -33,5 +39,7 @@ export default meta; type Story = StoryObj; export const Editing: Story = { args: { content: sample, placeholders, editable: true } }; -export const Empty: Story = { args: { content: { paragraphs: [{ nodes: [] }] }, placeholders, editable: true } }; +export const Empty: Story = { + args: { content: { paragraphs: [{ nodes: [] }] }, placeholders, editable: true }, +}; export const ReadOnly: Story = { args: { content: sample, placeholders, editable: false } }; diff --git a/src/app/shared/ui/skeleton/skeleton.component.ts b/src/app/shared/ui/skeleton/skeleton.component.ts index 0354dfe..ba9e017 100644 --- a/src/app/shared/ui/skeleton/skeleton.component.ts +++ b/src/app/shared/ui/skeleton/skeleton.component.ts @@ -4,13 +4,33 @@ import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/ on fast responses. Render `count` lines shaped roughly like the content. */ @Component({ selector: 'app-skeleton', - styles: [` - :host{display:block} - .sk{background:linear-gradient(90deg,var(--rhc-color-cool-grey-200) 25%,var(--rhc-color-cool-grey-100) 37%,var(--rhc-color-cool-grey-200) 63%); - background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:var(--rhc-border-radius-md); - margin-block-end:var(--rhc-space-max-lg)} - @keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}} - `], + styles: [ + ` + :host { + display: block; + } + .sk { + background: linear-gradient( + 90deg, + var(--rhc-color-cool-grey-200) 25%, + var(--rhc-color-cool-grey-100) 37%, + var(--rhc-color-cool-grey-200) 63% + ); + background-size: 400% 100%; + animation: sh 1.4s ease infinite; + border-radius: var(--rhc-border-radius-md); + margin-block-end: var(--rhc-space-max-lg); + } + @keyframes sh { + 0% { + background-position: 100% 0; + } + 100% { + background-position: 0 0; + } + } + `, + ], template: ` @if (visible()) { @for (l of lines(); track $index) { @@ -28,6 +48,10 @@ export class SkeletonComponent implements OnInit, OnDestroy { protected lines = computed(() => Array(this.count()).fill(0)); private timer?: ReturnType; - ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); } - ngOnDestroy() { clearTimeout(this.timer); } + ngOnInit() { + this.timer = setTimeout(() => this.visible.set(true), this.delay()); + } + ngOnDestroy() { + clearTimeout(this.timer); + } } diff --git a/src/app/shared/ui/spinner/spinner.component.ts b/src/app/shared/ui/spinner/spinner.component.ts index 5d90e77..7153bac 100644 --- a/src/app/shared/ui/spinner/spinner.component.ts +++ b/src/app/shared/ui/spinner/spinner.component.ts @@ -4,17 +4,35 @@ import { Component, OnDestroy, OnInit, input, signal } from '@angular/core'; flash a spinner, slow ones get feedback. */ @Component({ selector: 'app-spinner', - styles: [` - :host{display:block} - .sp{inline-size:var(--rhc-space-max-3xl);block-size:var(--rhc-space-max-3xl);border-radius:var(--rhc-border-radius-round); - border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-300); - border-block-start-color:var(--rhc-color-lintblauw-700); - animation:sp 0.8s linear infinite;margin:var(--rhc-space-max-2xl) auto} - @keyframes sp{to{transform:rotate(360deg)}} - `], + styles: [ + ` + :host { + display: block; + } + .sp { + inline-size: var(--rhc-space-max-3xl); + block-size: var(--rhc-space-max-3xl); + border-radius: var(--rhc-border-radius-round); + border: var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-300); + border-block-start-color: var(--rhc-color-lintblauw-700); + animation: sp 0.8s linear infinite; + margin: var(--rhc-space-max-2xl) auto; + } + @keyframes sp { + to { + transform: rotate(360deg); + } + } + `, + ], template: ` @if (visible()) { -
+
} `, }) @@ -23,6 +41,10 @@ export class SpinnerComponent implements OnInit, OnDestroy { protected visible = signal(false); private timer?: ReturnType; - ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); } - ngOnDestroy() { clearTimeout(this.timer); } + ngOnInit() { + this.timer = setTimeout(() => this.visible.set(true), this.delay()); + } + ngOnDestroy() { + clearTimeout(this.timer); + } } diff --git a/src/app/shared/ui/status-badge/status-badge.component.ts b/src/app/shared/ui/status-badge/status-badge.component.ts index 87375ba..82f5221 100644 --- a/src/app/shared/ui/status-badge/status-badge.component.ts +++ b/src/app/shared/ui/status-badge/status-badge.component.ts @@ -6,10 +6,21 @@ import { Component, input } from '@angular/core'; @Component({ selector: 'app-status-badge', // Local class is NOT Bootstrap's .badge (which would add pill padding/colour) — hence .status-badge. - styles: [` - .status-badge{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md)} - .dot{inline-size:0.75rem;block-size:0.75rem;border-radius:var(--rhc-border-radius-round);flex:none} - `], + styles: [ + ` + .status-badge { + display: inline-flex; + align-items: center; + gap: var(--rhc-space-max-md); + } + .dot { + inline-size: 0.75rem; + block-size: 0.75rem; + border-radius: var(--rhc-border-radius-round); + flex: none; + } + `, + ], template: ` diff --git a/src/app/shared/ui/status-badge/status-badge.stories.ts b/src/app/shared/ui/status-badge/status-badge.stories.ts index 0fb9305..a26994d 100644 --- a/src/app/shared/ui/status-badge/status-badge.stories.ts +++ b/src/app/shared/ui/status-badge/status-badge.stories.ts @@ -8,6 +8,12 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Geregistreerd: Story = { args: { label: 'Geregistreerd', color: 'var(--rhc-color-groen-500)' } }; -export const Geschorst: Story = { args: { label: 'Geschorst', color: 'var(--rhc-color-oranje-500)' } }; -export const Doorgehaald: Story = { args: { label: 'Doorgehaald', color: 'var(--rhc-color-rood-500)' } }; +export const Geregistreerd: Story = { + args: { label: 'Geregistreerd', color: 'var(--rhc-color-groen-500)' }, +}; +export const Geschorst: Story = { + args: { label: 'Geschorst', color: 'var(--rhc-color-oranje-500)' }, +}; +export const Doorgehaald: Story = { + args: { label: 'Doorgehaald', color: 'var(--rhc-color-rood-500)' }, +}; diff --git a/src/app/shared/ui/stepper/stepper.component.ts b/src/app/shared/ui/stepper/stepper.component.ts index 566e2a1..4307afe 100644 --- a/src/app/shared/ui/stepper/stepper.component.ts +++ b/src/app/shared/ui/stepper/stepper.component.ts @@ -12,12 +12,21 @@ import { Component, ElementRef, input, output, viewChild } from '@angular/core'; @for (label of steps(); track label; let i = $index) {
  • @if (i < current()) { - + Stap {{ i + 1 }} Voltooid } @else if (i === current()) { - + Stap {{ i + 1 }} Huidige stap @@ -30,8 +39,13 @@ import { Component, ElementRef, input, output, viewChild } from '@angular/core'; }

    - @if (processName()) { {{ processName() }} } - Stap {{ current() + 1 }} van {{ steps().length }}: {{ stepTitle() || steps()[current()] }} + @if (processName()) { + {{ processName() }} + } + Stap {{ current() + 1 }} van {{ steps().length }}: {{ stepTitle() || steps()[current()] }}

  • `, diff --git a/src/app/shared/ui/stepper/stepper.stories.ts b/src/app/shared/ui/stepper/stepper.stories.ts index d65ba98..c9f9a55 100644 --- a/src/app/shared/ui/stepper/stepper.stories.ts +++ b/src/app/shared/ui/stepper/stepper.stories.ts @@ -13,8 +13,15 @@ export default meta; type Story = StoryObj; const steps = ['Adres', 'Beroep', 'Controle']; -const base = { steps, processName: 'Inschrijven in het BIG-register', stepTitle: '', stepSelected: () => {} }; +const base = { + steps, + processName: 'Inschrijven in het BIG-register', + stepTitle: '', + stepSelected: () => {}, +}; export const Eerste: Story = { args: { ...base, current: 0 } }; -export const Midden: Story = { args: { ...base, current: 1, stepTitle: 'Beroep op basis van uw diploma' } }; +export const Midden: Story = { + args: { ...base, current: 1, stepTitle: 'Beroep op basis van uw diploma' }, +}; export const Laatste: Story = { args: { ...base, current: 2 } }; diff --git a/src/app/shared/ui/task-list/task-list.stories.ts b/src/app/shared/ui/task-list/task-list.stories.ts index ed40cb7..74cafd6 100644 --- a/src/app/shared/ui/task-list/task-list.stories.ts +++ b/src/app/shared/ui/task-list/task-list.stories.ts @@ -7,7 +7,10 @@ const meta: Meta = { title: 'Molecules/Task List', component: TaskListComponent, decorators: [applicationConfig({ providers: [provideRouter([])] })], - render: (args) => ({ props: args, template: `` }), + render: (args) => ({ + props: args, + template: ``, + }), parameters: { // Structural: app-choice-link's host sits between the keuzelijst
      and its
    • // — axe's list/listitem rule needs them adjacent regardless of `display:contents`. @@ -22,8 +25,18 @@ export const Default: Story = { args: { listHeading: 'Wat moet ik regelen', tasks: [ - { title: 'Vraag uw herregistratie aan', description: 'Verleng uw registratie vóór 31 december 2026.', to: '/herregistratie', actionLabel: 'Herregistratie aanvragen' }, - { title: 'Controleer uw adresgegevens', description: 'Uw adres is langer dan een jaar niet bevestigd.', to: '/registratie', actionLabel: 'Bekijk uw gegevens' }, + { + title: 'Vraag uw herregistratie aan', + description: 'Verleng uw registratie vóór 31 december 2026.', + to: '/herregistratie', + actionLabel: 'Herregistratie aanvragen', + }, + { + title: 'Controleer uw adresgegevens', + description: 'Uw adres is langer dan een jaar niet bevestigd.', + to: '/registratie', + actionLabel: 'Bekijk uw gegevens', + }, ], }, }; diff --git a/src/app/shared/ui/text-input/text-input.component.ts b/src/app/shared/ui/text-input/text-input.component.ts index 68b2066..288d3e0 100644 --- a/src/app/shared/ui/text-input/text-input.component.ts +++ b/src/app/shared/ui/text-input/text-input.component.ts @@ -4,10 +4,17 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; /** Atom: text input. Utrecht textbox wired up as a form control (ngModel/reactive). */ @Component({ selector: 'app-text-input', - styles: [` - :host{display:block} - input{inline-size:100%;box-sizing:border-box} - `], + styles: [ + ` + :host { + display: block; + } + input { + inline-size: 100%; + box-sizing: border-box; + } + `, + ], template: ` + (blur)="onTouched()" + /> `, - providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TextInputComponent), multi: true }], + providers: [ + { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TextInputComponent), multi: true }, + ], }) export class TextInputComponent implements ControlValueAccessor { type = input<'text' | 'password' | 'email'>('text'); @@ -39,8 +49,16 @@ export class TextInputComponent implements ControlValueAccessor { this.value = (e.target as HTMLInputElement).value; this.onChange(this.value); } - writeValue(v: string) { this.value = v ?? ''; } - registerOnChange(fn: (v: string) => void) { this.onChange = fn; } - registerOnTouched(fn: () => void) { this.onTouched = fn; } - setDisabledState(d: boolean) { this.disabled = d; } + writeValue(v: string) { + this.value = v ?? ''; + } + registerOnChange(fn: (v: string) => void) { + this.onChange = fn; + } + registerOnTouched(fn: () => void) { + this.onTouched = fn; + } + setDisabledState(d: boolean) { + this.disabled = d; + } } diff --git a/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts b/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts index 728f618..41fab35 100644 --- a/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts +++ b/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts @@ -5,16 +5,21 @@ import type { DeliveryChannel } from '@shared/upload/upload.machine'; Thin wrapper over the Utrecht/RHC radio CSS. Pure UI: emits the chosen channel. */ @Component({ selector: 'app-delivery-channel-toggle', - styles: [` - .radio-option { - display: flex; - align-items: center; - gap: var(--rhc-space-max-md); - padding-block: var(--rhc-space-max-sm); - margin: 0; - } - .radio-option .form-check-input { margin: 0; float: none; } - `], + styles: [ + ` + .radio-option { + display: flex; + align-items: center; + gap: var(--rhc-space-max-md); + padding-block: var(--rhc-space-max-sm); + margin: 0; + } + .radio-option .form-check-input { + margin: 0; + float: none; + } + `, + ], template: `
      @for (opt of options; track opt.value) { @@ -26,7 +31,8 @@ import type { DeliveryChannel } from '@shared/upload/upload.machine'; [value]="opt.value" [checked]="channel() === opt.value" [disabled]="disabled()" - (change)="channelChange.emit(opt.value)" /> + (change)="channelChange.emit(opt.value)" + /> {{ opt.label }} } diff --git a/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.stories.ts b/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.stories.ts index 940872d..84e9832 100644 --- a/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.stories.ts +++ b/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.stories.ts @@ -12,5 +12,7 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Digital: Story = { args: { channel: 'digital', name: 'diploma-channel', disabled: false } }; +export const Digital: Story = { + args: { channel: 'digital', name: 'diploma-channel', disabled: false }, +}; export const Post: Story = { args: { channel: 'post', name: 'diploma-channel', disabled: false } }; diff --git a/src/app/shared/ui/upload/document-category/document-category.component.ts b/src/app/shared/ui/upload/document-category/document-category.component.ts index 83899f3..33193bb 100644 --- a/src/app/shared/ui/upload/document-category/document-category.component.ts +++ b/src/app/shared/ui/upload/document-category/document-category.component.ts @@ -11,16 +11,37 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component' @Component({ selector: 'app-document-category', imports: [DeliveryChannelToggleComponent, FileInputComponent, SingleUploadComponent], - styles: [` - :host { display: block; } - .label { font-weight: var(--rhc-text-font-weight-semi-bold); margin-block-end: var(--rhc-space-max-sm); } - .req { font-weight: var(--rhc-text-font-weight-regular); color: var(--rhc-color-foreground-subtle); } - .desc { color: var(--rhc-color-foreground-subtle); font-size: var(--rhc-text-font-size-sm); margin-block-end: var(--rhc-space-max-md); } - .file-list { list-style: none; padding: 0; margin-block-start: var(--rhc-space-max-md); } - `], + styles: [ + ` + :host { + display: block; + } + .label { + font-weight: var(--rhc-text-font-weight-semi-bold); + margin-block-end: var(--rhc-space-max-sm); + } + .req { + font-weight: var(--rhc-text-font-weight-regular); + color: var(--rhc-color-foreground-subtle); + } + .desc { + color: var(--rhc-color-foreground-subtle); + font-size: var(--rhc-text-font-size-sm); + margin-block-end: var(--rhc-space-max-md); + } + .file-list { + list-style: none; + padding: 0; + margin-block-start: var(--rhc-space-max-md); + } + `, + ], template: `
      - {{ category().label }}@if (category().required) { (verplicht) } + {{ category().label }} + @if (category().required) { + (verplicht) + }
      @if (category().description) {
      {{ category().description }}
      @@ -30,7 +51,8 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component' + (channelChange)="channelChange.emit($event)" + /> } @if (channel() === 'digital') { @@ -47,18 +69,21 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component' [accept]="category().acceptedTypes" [maxSizeMb]="category().maxSizeMb" [multiple]="category().multiple" - (filesSelected)="fileSelected.emit($event)" /> + (filesSelected)="fileSelected.emit($event)" + /> @if (uploads().length) {
        @for (u of uploads(); track u.localId) { -
      • + (retry)="retryUpload.emit(u.localId)" + > }
      } @@ -73,7 +98,10 @@ export class DocumentCategoryComponent { previewUrlFor = input<(documentId: string) => string | undefined>(); /** Accessible name for the file picker, e.g. "Bestand kiezen voor Diploma". */ - protected fileInputLabel = computed(() => $localize`:@@upload.fileInput.labelFor:Bestand kiezen voor ${this.category().label}:category:`); + protected fileInputLabel = computed( + () => + $localize`:@@upload.fileInput.labelFor:Bestand kiezen voor ${this.category().label}:category:`, + ); fileSelected = output(); removeUpload = output(); diff --git a/src/app/shared/ui/upload/document-category/document-category.stories.ts b/src/app/shared/ui/upload/document-category/document-category.stories.ts index 8fcdd0c..6ae19a3 100644 --- a/src/app/shared/ui/upload/document-category/document-category.stories.ts +++ b/src/app/shared/ui/upload/document-category/document-category.stories.ts @@ -40,5 +40,10 @@ export const Digital: Story = { }; export const WithRejection: Story = { - args: { category, uploads: [], channel: 'digital', rejection: 'Dit bestandstype is niet toegestaan voor deze categorie.' }, + args: { + category, + uploads: [], + channel: 'digital', + rejection: 'Dit bestandstype is niet toegestaan voor deze categorie.', + }, }; diff --git a/src/app/shared/ui/upload/document-chip/document-chip.component.ts b/src/app/shared/ui/upload/document-chip/document-chip.component.ts index ed7890a..e3a10b8 100644 --- a/src/app/shared/ui/upload/document-chip/document-chip.component.ts +++ b/src/app/shared/ui/upload/document-chip/document-chip.component.ts @@ -19,17 +19,30 @@ const STATUS_LABELS: Record = { @Component({ selector: 'app-document-chip', imports: [UploadStatusIconComponent], - styles: [` - :host { display: contents; } - .file { display: flex; flex-direction: column; gap: var(--rhc-space-max-xs); min-inline-size: 0; } - .file-name { word-break: break-all; } - `], + styles: [ + ` + :host { + display: contents; + } + .file { + display: flex; + flex-direction: column; + gap: var(--rhc-space-max-xs); + min-inline-size: 0; + } + .file-name { + word-break: break-all; + } + `, + ], template: `
      @if (previewUrl()) { - {{ fileName() }} + {{ + fileName() + }} } @else { {{ fileName() }} } diff --git a/src/app/shared/ui/upload/document-chip/document-chip.stories.ts b/src/app/shared/ui/upload/document-chip/document-chip.stories.ts index 395cc3c..4ec68b5 100644 --- a/src/app/shared/ui/upload/document-chip/document-chip.stories.ts +++ b/src/app/shared/ui/upload/document-chip/document-chip.stories.ts @@ -15,13 +15,26 @@ export default meta; type Story = StoryObj; export const Complete: Story = { - args: { fileName: 'diploma.pdf', fileSizeMb: 1.2, status: { type: 'complete', documentId: 'doc-1' } as UploadStatus }, + args: { + fileName: 'diploma.pdf', + fileSizeMb: 1.2, + status: { type: 'complete', documentId: 'doc-1' } as UploadStatus, + }, }; export const WithPreview: Story = { - args: { fileName: 'diploma.pdf', fileSizeMb: 1.2, status: { type: 'complete', documentId: 'doc-1' } as UploadStatus, previewUrl: '/api/v1/uploads/doc-1/content' }, + args: { + fileName: 'diploma.pdf', + fileSizeMb: 1.2, + status: { type: 'complete', documentId: 'doc-1' } as UploadStatus, + previewUrl: '/api/v1/uploads/doc-1/content', + }, }; export const Failed: Story = { - args: { fileName: 'diploma.pdf', fileSizeMb: 1.2, status: { type: 'failed', reason: 'Netwerkfout' } as UploadStatus }, + args: { + fileName: 'diploma.pdf', + fileSizeMb: 1.2, + status: { type: 'failed', reason: 'Netwerkfout' } as UploadStatus, + }, }; diff --git a/src/app/shared/ui/upload/document-upload/document-upload.component.ts b/src/app/shared/ui/upload/document-upload/document-upload.component.ts index 9cb1283..8907d7b 100644 --- a/src/app/shared/ui/upload/document-upload/document-upload.component.ts +++ b/src/app/shared/ui/upload/document-upload/document-upload.component.ts @@ -9,9 +9,15 @@ import { UploadStatusBannerComponent } from '../upload-status-banner/upload-stat @Component({ selector: 'app-document-upload', imports: [DocumentCategoryComponent, UploadStatusBannerComponent], - styles: [` - :host { display: flex; flex-direction: column; gap: var(--rhc-space-max-xl); } - `], + styles: [ + ` + :host { + display: flex; + flex-direction: column; + gap: var(--rhc-space-max-xl); + } + `, + ], template: ` @if (state().categoriesError) { @@ -30,7 +36,8 @@ import { UploadStatusBannerComponent } from '../upload-status-banner/upload-stat (removeUpload)="removeUpload.emit($event)" (retryUpload)="retryUpload.emit($event)" (deleteUpload)="deleteUpload.emit($event)" - (channelChange)="channelChange.emit({ categoryId: c.categoryId, channel: $event })" /> + (channelChange)="channelChange.emit({ categoryId: c.categoryId, channel: $event })" + /> } } `, diff --git a/src/app/shared/ui/upload/file-input/file-input.component.ts b/src/app/shared/ui/upload/file-input/file-input.component.ts index 9c7fd04..4d76e4e 100644 --- a/src/app/shared/ui/upload/file-input/file-input.component.ts +++ b/src/app/shared/ui/upload/file-input/file-input.component.ts @@ -21,21 +21,47 @@ const TYPE_LABELS: Record = { `aria-describedby` (pattern requirement). Pure UI — no validation, no upload. */ @Component({ selector: 'app-file-input', - styles: [` - :host { display: block; } - .file-picker-drop-area { display: flex; flex-direction: column; align-items: flex-start; gap: var(--rhc-space-max-sm); } - /* Default (not subtle) foreground: subtle grey on the grey drop-area fails WCAG AA contrast. */ - .upload-instructions { margin: 0; color: var(--rhc-color-foreground-default); font-size: var(--rhc-text-font-size-sm); } - /* Solid CIBG-blue button; the .btn-upload ::before folder glyph is the icon, so + styles: [ + ` + :host { + display: block; + } + .file-picker-drop-area { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--rhc-space-max-sm); + } + /* Default (not subtle) foreground: subtle grey on the grey drop-area fails WCAG AA contrast. */ + .upload-instructions { + margin: 0; + color: var(--rhc-color-foreground-default); + font-size: var(--rhc-text-font-size-sm); + } + /* Solid CIBG-blue button; the .btn-upload ::before folder glyph is the icon, so drop its redundant centred background-image folder. */ - .btn-upload { cursor: pointer; background-image: none; } - .btn-upload.disabled { cursor: not-allowed; } - /* The input is visually hidden but still focusable; surface its focus on the button. */ - input:focus-visible + .btn-upload { outline: var(--rhc-border-width-md) solid var(--rhc-color-foreground-link); outline-offset: 2px; } - `], + .btn-upload { + cursor: pointer; + background-image: none; + } + .btn-upload.disabled { + cursor: not-allowed; + } + /* The input is visually hidden but still focusable; surface its focus on the button. */ + input:focus-visible + .btn-upload { + outline: var(--rhc-border-width-md) solid var(--rhc-color-foreground-link); + outline-offset: 2px; + } + `, + ], template: ` -
      +
      @if (instructions()) {

      {{ instructions() }}

      } @@ -49,8 +75,13 @@ const TYPE_LABELS: Record = { [accept]="accept().join(',')" [multiple]="multiple()" [disabled]="disabled()" - (change)="onChange($event)" /> -
      @@ -80,9 +111,12 @@ export class FileInputComponent { /** Always-visible instruction: allowed file types + max size (CIBG pattern). */ protected instructions = computed(() => { - const types = this.accept().map((m) => TYPE_LABELS[m] ?? m.split('/').pop()?.toUpperCase() ?? m).join(', '); + const types = this.accept() + .map((m) => TYPE_LABELS[m] ?? m.split('/').pop()?.toUpperCase() ?? m) + .join(', '); const size = this.maxSizeMb(); - if (types && size > 0) return $localize`:@@upload.fileInput.instrBoth:Toegestaan: ${types}:types: · max ${size}:size: MB`; + if (types && size > 0) + return $localize`:@@upload.fileInput.instrBoth:Toegestaan: ${types}:types: · max ${size}:size: MB`; if (types) return $localize`:@@upload.fileInput.instrTypes:Toegestaan: ${types}:types:`; if (size > 0) return $localize`:@@upload.fileInput.instrSize:Max ${size}:size: MB`; return ''; diff --git a/src/app/shared/ui/upload/file-input/file-input.stories.ts b/src/app/shared/ui/upload/file-input/file-input.stories.ts index 779ca82..0796d6a 100644 --- a/src/app/shared/ui/upload/file-input/file-input.stories.ts +++ b/src/app/shared/ui/upload/file-input/file-input.stories.ts @@ -13,13 +13,31 @@ export default meta; type Story = StoryObj; export const Default: Story = { - args: { inputId: 'diploma', accept: ['application/pdf', 'image/jpeg'], maxSizeMb: 10, multiple: false, disabled: false }, + args: { + inputId: 'diploma', + accept: ['application/pdf', 'image/jpeg'], + maxSizeMb: 10, + multiple: false, + disabled: false, + }, }; export const Multiple: Story = { - args: { inputId: 'cv', accept: ['application/pdf'], maxSizeMb: 5, multiple: true, disabled: false }, + args: { + inputId: 'cv', + accept: ['application/pdf'], + maxSizeMb: 5, + multiple: true, + disabled: false, + }, }; export const Disabled: Story = { - args: { inputId: 'diploma-disabled', accept: ['application/pdf'], maxSizeMb: 10, multiple: false, disabled: true }, + args: { + inputId: 'diploma-disabled', + accept: ['application/pdf'], + maxSizeMb: 10, + multiple: false, + disabled: true, + }, }; diff --git a/src/app/shared/ui/upload/single-upload/single-upload.component.ts b/src/app/shared/ui/upload/single-upload/single-upload.component.ts index 5b1e7b2..25454bd 100644 --- a/src/app/shared/ui/upload/single-upload/single-upload.component.ts +++ b/src/app/shared/ui/upload/single-upload/single-upload.component.ts @@ -11,24 +11,55 @@ import { UploadProgressBarComponent } from '../upload-progress-bar/upload-progre selector: 'li[app-single-upload]', host: { class: 'file-container' }, imports: [DocumentChipComponent, UploadProgressBarComponent], - styles: [` - :host { flex-wrap: wrap; align-items: center; } - .upload-progress { flex: 1 0 100%; margin-block-start: var(--rhc-space-max-sm); } - .actions .btn { background: none; border: none; cursor: pointer; padding: 0; } - .actions .btn-retry { color: var(--rhc-color-foreground-link); text-decoration: underline; font: inherit; } - `], + styles: [ + ` + :host { + flex-wrap: wrap; + align-items: center; + } + .upload-progress { + flex: 1 0 100%; + margin-block-start: var(--rhc-space-max-sm); + } + .actions .btn { + background: none; + border: none; + cursor: pointer; + padding: 0; + } + .actions .btn-retry { + color: var(--rhc-color-foreground-link); + text-decoration: underline; + font: inherit; + } + `, + ], template: ` + [previewUrl]="previewUrl()" + />
      @if (upload().status.type === 'failed') { - + } @if (upload().status.type !== 'deleting') { - + }
      @if (progressPct() !== null) { diff --git a/src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.component.ts b/src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.component.ts index 5b03c1d..7f0710d 100644 --- a/src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.component.ts +++ b/src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.component.ts @@ -4,16 +4,27 @@ import { Component, input } from '@angular/core'; the percentage. */ @Component({ selector: 'app-upload-progress-bar', - styles: [` - :host { display: flex; align-items: center; gap: var(--rhc-space-max-md); } - progress { flex: 1; height: var(--rhc-space-max-md); } - .pct { font-size: var(--rhc-text-font-size-sm); color: var(--rhc-color-foreground-subtle); min-width: 3ch; text-align: end; } - `], + styles: [ + ` + :host { + display: flex; + align-items: center; + gap: var(--rhc-space-max-md); + } + progress { + flex: 1; + height: var(--rhc-space-max-md); + } + .pct { + font-size: var(--rhc-text-font-size-sm); + color: var(--rhc-color-foreground-subtle); + min-width: 3ch; + text-align: end; + } + `, + ], template: ` - + {{ progressPct() }}% `, }) diff --git a/src/app/shared/ui/upload/upload-status-banner/upload-status-banner.component.ts b/src/app/shared/ui/upload/upload-status-banner/upload-status-banner.component.ts index 13fa187..3597a22 100644 --- a/src/app/shared/ui/upload/upload-status-banner/upload-status-banner.component.ts +++ b/src/app/shared/ui/upload/upload-status-banner/upload-status-banner.component.ts @@ -19,5 +19,7 @@ export class UploadStatusBannerComponent { message = input.required(); type = input('info'); - protected readonly alertType = computed(() => (this.type() === 'info' ? 'info' : this.type())); + protected readonly alertType = computed(() => + this.type() === 'info' ? 'info' : this.type(), + ); } diff --git a/src/app/shared/ui/upload/upload-status-icon/upload-status-icon.component.ts b/src/app/shared/ui/upload/upload-status-icon/upload-status-icon.component.ts index 45a328e..b5ac079 100644 --- a/src/app/shared/ui/upload/upload-status-icon/upload-status-icon.component.ts +++ b/src/app/shared/ui/upload/upload-status-icon/upload-status-icon.component.ts @@ -11,12 +11,21 @@ interface Glyph { label derive purely from the status type. */ @Component({ selector: 'app-upload-status-icon', - styles: [` - .glyph { font-weight: var(--rhc-text-font-weight-semi-bold); } - `], + styles: [ + ` + .glyph { + font-weight: var(--rhc-text-font-weight-semi-bold); + } + `, + ], template: ` @if (glyph()) { - + {{ glyph()!.char }} } @@ -28,15 +37,35 @@ export class UploadStatusIconComponent { protected readonly glyph = computed(() => { switch (this.status()) { case 'queued': - return { char: '…', label: $localize`:@@upload.status.queued:In wachtrij`, color: 'var(--rhc-color-foreground-subtle)' }; + return { + char: '…', + label: $localize`:@@upload.status.queued:In wachtrij`, + color: 'var(--rhc-color-foreground-subtle)', + }; case 'uploading': - return { char: '↑', label: $localize`:@@upload.status.uploading:Bezig met uploaden`, color: 'var(--rhc-color-lintblauw-600)' }; + return { + char: '↑', + label: $localize`:@@upload.status.uploading:Bezig met uploaden`, + color: 'var(--rhc-color-lintblauw-600)', + }; case 'complete': - return { char: '✓', label: $localize`:@@upload.status.complete:Geüpload`, color: 'var(--rhc-color-groen-500)' }; + return { + char: '✓', + label: $localize`:@@upload.status.complete:Geüpload`, + color: 'var(--rhc-color-groen-500)', + }; case 'failed': - return { char: '✕', label: $localize`:@@upload.status.failed:Mislukt`, color: 'var(--rhc-color-rood-500)' }; + return { + char: '✕', + label: $localize`:@@upload.status.failed:Mislukt`, + color: 'var(--rhc-color-rood-500)', + }; case 'deleting': - return { char: '⟳', label: $localize`:@@upload.status.deleting:Bezig met verwijderen`, color: 'var(--rhc-color-foreground-subtle)' }; + return { + char: '⟳', + label: $localize`:@@upload.status.deleting:Bezig met verwijderen`, + color: 'var(--rhc-color-foreground-subtle)', + }; case 'idle': case 'deleted': return null; diff --git a/src/app/shared/upload/upload-controller.ts b/src/app/shared/upload/upload-controller.ts index b51ae23..cf6cb56 100644 --- a/src/app/shared/upload/upload-controller.ts +++ b/src/app/shared/upload/upload-controller.ts @@ -34,7 +34,10 @@ export function createUploadController(deps: UploadControllerDeps) { if (status === 'resolved' || status === 'local') { deps.dispatch({ type: 'CategoriesLoaded', categories: categoriesRes.value() ?? [] }); } else if (status === 'error') { - deps.dispatch({ type: 'CategoriesLoadFailed', reason: problemDetail(categoriesRes.error(), SUBMIT_FAILED) }); + deps.dispatch({ + type: 'CategoriesLoadFailed', + reason: problemDetail(categoriesRes.error(), SUBMIT_FAILED), + }); } }); @@ -45,7 +48,13 @@ export function createUploadController(deps: UploadControllerDeps) { function start(categoryId: string, file: File) { const localId = crypto.randomUUID(); files.set(localId, file); - deps.dispatch({ type: 'FileSelected', categoryId, localId, fileName: file.name, fileSizeMb: file.size / 1e6 }); + deps.dispatch({ + type: 'FileSelected', + categoryId, + localId, + fileName: file.name, + fileSizeMb: file.size / 1e6, + }); shell.upload({ localId, categoryId, wizardId: deps.wizardId, file }, deps.dispatch); } @@ -73,7 +82,10 @@ export function createUploadController(deps: UploadControllerDeps) { const up = deps.getUpload().uploads.find((u) => u.localId === localId); if (!file || !up) return; deps.dispatch({ type: 'UploadRetried', localId }); - shell.upload({ localId, categoryId: up.categoryId, wizardId: deps.wizardId, file }, deps.dispatch); + shell.upload( + { localId, categoryId: up.categoryId, wizardId: deps.wizardId, file }, + deps.dispatch, + ); }, onDelete(e: { localId: string; documentId: string }) { shell.delete(e.localId, e.documentId, deps.dispatch); diff --git a/src/app/shared/upload/upload-shell.service.ts b/src/app/shared/upload/upload-shell.service.ts index 56266c0..4b88329 100644 --- a/src/app/shared/upload/upload-shell.service.ts +++ b/src/app/shared/upload/upload-shell.service.ts @@ -42,16 +42,26 @@ export class UploadShellService { /** Start (or retry) an upload: queue → progress → complete/failed. */ upload(req: XhrUploadRequest, dispatch: Dispatch): void { - dispatch({ type: 'UploadQueued', localId: req.localId, backgroundSync: this.backgroundSyncAvailable }); + dispatch({ + type: 'UploadQueued', + localId: req.localId, + backgroundSync: this.backgroundSyncAvailable, + }); const { done, cancel } = this.transport.send(req, (pct) => dispatch({ type: 'UploadProgress', localId: req.localId, progressPct: pct }), ); this.inflight.set(req.localId, cancel); done - .then(({ documentId }) => dispatch({ type: 'UploadComplete', localId: req.localId, documentId })) + .then(({ documentId }) => + dispatch({ type: 'UploadComplete', localId: req.localId, documentId }), + ) .catch((e) => { if (e !== UPLOAD_ABORTED) { - dispatch({ type: 'UploadFailed', localId: req.localId, reason: typeof e === 'string' ? e : '' }); + dispatch({ + type: 'UploadFailed', + localId: req.localId, + reason: typeof e === 'string' ? e : '', + }); } }) .finally(() => this.inflight.delete(req.localId)); @@ -63,7 +73,9 @@ export class UploadShellService { this.adapter .deleteDocument(documentId) .then(() => dispatch({ type: 'UploadDeleteComplete', localId })) - .catch((e) => dispatch({ type: 'UploadDeleteFailed', localId, reason: problemDetail(e, '') })); + .catch((e) => + dispatch({ type: 'UploadDeleteFailed', localId, reason: problemDetail(e, '') }), + ); } /** Cancel any in-flight XHRs (e.g. when a category switches to post-delivery). */ diff --git a/src/app/shared/upload/upload.adapter.ts b/src/app/shared/upload/upload.adapter.ts index 393cb54..e9055fc 100644 --- a/src/app/shared/upload/upload.adapter.ts +++ b/src/app/shared/upload/upload.adapter.ts @@ -56,13 +56,24 @@ export class UploadAdapter { // not on every unrelated draft edit (a computed with `equal` returns the cached // reference while equal, so the resource sees no change). const params = computed( - () => ({ wizardId, diplomaHerkomst: extra?.().diplomaHerkomst, taalvaardigheid: extra?.().taalvaardigheid }), - { equal: (a, b) => a.wizardId === b.wizardId && a.diplomaHerkomst === b.diplomaHerkomst && a.taalvaardigheid === b.taalvaardigheid }, + () => ({ + wizardId, + diplomaHerkomst: extra?.().diplomaHerkomst, + taalvaardigheid: extra?.().taalvaardigheid, + }), + { + equal: (a, b) => + a.wizardId === b.wizardId && + a.diplomaHerkomst === b.diplomaHerkomst && + a.taalvaardigheid === b.taalvaardigheid, + }, ); return resource({ params, loader: ({ params }) => - this.client.categories(params.wizardId, params.diplomaHerkomst, params.taalvaardigheid).then((r) => (r.categories ?? []).map(toCategory)), + this.client + .categories(params.wizardId, params.diplomaHerkomst, params.taalvaardigheid) + .then((r) => (r.categories ?? []).map(toCategory)), }); } @@ -94,7 +105,8 @@ export class UploadAdapter { */ xhrUpload(req: XhrUploadRequest, onProgress: (pct: number) => void): XhrUploadHandle { const scenario = currentScenario(); - if (scenario === 'upload-slow' || scenario === 'upload-fail') return simulateUpload(scenario, onProgress); + if (scenario === 'upload-slow' || scenario === 'upload-fail') + return simulateUpload(scenario, onProgress); const xhr = new XMLHttpRequest(); const form = new FormData(); @@ -120,7 +132,9 @@ export class UploadAdapter { } }); xhr.addEventListener('error', () => reject(genericError())); - xhr.addEventListener('abort', () => (aborted ? reject(UPLOAD_ABORTED) : reject(genericError()))); + xhr.addEventListener('abort', () => + aborted ? reject(UPLOAD_ABORTED) : reject(genericError()), + ); }); xhr.open('POST', `${environment.apiBaseUrl}/api/v1/uploads`); @@ -138,7 +152,10 @@ const genericError = (): string => UPLOAD_FAILED; * succeeds (`upload-slow`) or fails (`upload-fail`). ponytail: timer-based, cancel * via the returned handle; no network. */ -function simulateUpload(scenario: 'upload-slow' | 'upload-fail', onProgress: (pct: number) => void): XhrUploadHandle { +function simulateUpload( + scenario: 'upload-slow' | 'upload-fail', + onProgress: (pct: number) => void, +): XhrUploadHandle { let pct = 0; let cancelled = false; const done = new Promise<{ documentId: string }>((resolve, reject) => { @@ -182,5 +199,9 @@ function toCategory(c: DocumentCategoryDto): DocumentCategory { } function toStatusItem(s: UploadStatusItemDto): StatusItem { - return { localId: s.localId ?? '', status: s.status ?? 'unknown', documentId: s.documentId ?? undefined }; + return { + localId: s.localId ?? '', + status: s.status ?? 'unknown', + documentId: s.documentId ?? undefined, + }; } diff --git a/src/app/shared/upload/upload.machine.spec.ts b/src/app/shared/upload/upload.machine.spec.ts index 9a3df9b..0268880 100644 --- a/src/app/shared/upload/upload.machine.spec.ts +++ b/src/app/shared/upload/upload.machine.spec.ts @@ -28,28 +28,50 @@ const stateWith = (categories: DocumentCategory[], over: Partial = reduceUpload({ ...initialUpload, ...over }, { type: 'CategoriesLoaded', categories }); const select = (s: UploadState, categoryId: string, localId: string): UploadState => - reduceUpload(s, { type: 'FileSelected', categoryId, localId, fileName: `${localId}.pdf`, fileSizeMb: 1 }); + reduceUpload(s, { + type: 'FileSelected', + categoryId, + localId, + fileName: `${localId}.pdf`, + fileSizeMb: 1, + }); -const get = (s: UploadState, localId: string): Upload | undefined => s.uploads.find((u) => u.localId === localId); +const get = (s: UploadState, localId: string): Upload | undefined => + s.uploads.find((u) => u.localId === localId); describe('CategoriesLoaded', () => { it('defaults every category to digital without clobbering existing choices', () => { const s0 = stateWith([cat({ categoryId: 'a' })], { deliveryChannel: { a: 'post' } }); expect(s0.deliveryChannel['a']).toBe('post'); - const s1 = reduceUpload(s0, { type: 'CategoriesLoaded', categories: [cat({ categoryId: 'a' }), cat({ categoryId: 'b' })] }); + const s1 = reduceUpload(s0, { + type: 'CategoriesLoaded', + categories: [cat({ categoryId: 'a' }), cat({ categoryId: 'b' })], + }); expect(s1.deliveryChannel).toEqual({ a: 'post', b: 'digital' }); }); it('clears a prior categoriesError', () => { - const s = reduceUpload({ ...initialUpload, categoriesError: 'boom' }, { type: 'CategoriesLoaded', categories: [] }); + const s = reduceUpload( + { ...initialUpload, categoriesError: 'boom' }, + { type: 'CategoriesLoaded', categories: [] }, + ); expect(s.categoriesError).toBeUndefined(); }); it('drops uploads + channel choices for categories that disappear', () => { - const s0 = select(stateWith([cat({ categoryId: 'diploma' }), cat({ categoryId: 'id' })], { deliveryChannel: { id: 'post' } }), 'diploma', 'u1'); + const s0 = select( + stateWith([cat({ categoryId: 'diploma' }), cat({ categoryId: 'id' })], { + deliveryChannel: { id: 'post' }, + }), + 'diploma', + 'u1', + ); expect(get(s0, 'u1')).toBeDefined(); // Reload with the diploma category gone (e.g. DUO chosen). - const s1 = reduceUpload(s0, { type: 'CategoriesLoaded', categories: [cat({ categoryId: 'id' })] }); + const s1 = reduceUpload(s0, { + type: 'CategoriesLoaded', + categories: [cat({ categoryId: 'id' })], + }); expect(get(s1, 'u1')).toBeUndefined(); expect(s1.deliveryChannel).toEqual({ id: 'post' }); }); @@ -57,10 +79,15 @@ describe('CategoriesLoaded', () => { describe('CategoriesLoadFailed / BackgroundSyncAvailability', () => { it('records the error', () => { - expect(reduceUpload(initialUpload, { type: 'CategoriesLoadFailed', reason: 'boom' }).categoriesError).toBe('boom'); + expect( + reduceUpload(initialUpload, { type: 'CategoriesLoadFailed', reason: 'boom' }).categoriesError, + ).toBe('boom'); }); it('flips background sync availability', () => { - expect(reduceUpload(initialUpload, { type: 'BackgroundSyncAvailability', available: true }).backgroundSyncAvailable).toBe(true); + expect( + reduceUpload(initialUpload, { type: 'BackgroundSyncAvailability', available: true }) + .backgroundSyncAvailable, + ).toBe(true); }); }); @@ -96,7 +123,11 @@ describe('FileSelected', () => { describe('FileRejected', () => { it('stores a per-category message', () => { - const s = reduceUpload(stateWith([cat()]), { type: 'FileRejected', categoryId: 'diploma', reason: 'size' }); + const s = reduceUpload(stateWith([cat()]), { + type: 'FileRejected', + categoryId: 'diploma', + reason: 'size', + }); expect(s.rejections['diploma']).toBeTruthy(); }); }); @@ -134,12 +165,20 @@ describe('delete flow (optimistic, revertible)', () => { }; it('UploadDeleteRequested keeps the documentId for revert', () => { - const s = reduceUpload(completed(), { type: 'UploadDeleteRequested', localId: 'u1', documentId: 'doc1' }); + const s = reduceUpload(completed(), { + type: 'UploadDeleteRequested', + localId: 'u1', + documentId: 'doc1', + }); expect(get(s, 'u1')?.status).toEqual({ type: 'deleting', documentId: 'doc1' }); }); it('UploadDeleteComplete removes the upload', () => { - let s = reduceUpload(completed(), { type: 'UploadDeleteRequested', localId: 'u1', documentId: 'doc1' }); + let s = reduceUpload(completed(), { + type: 'UploadDeleteRequested', + localId: 'u1', + documentId: 'doc1', + }); s = reduceUpload(s, { type: 'UploadDeleteComplete', localId: 'u1' }); expect(s.uploads).toHaveLength(0); }); @@ -161,13 +200,21 @@ describe('DeliveryChannelChanged', () => { it('rejects post for a category that does not allow it', () => { const s = stateWith([cat({ allowPostDelivery: false })]); - const next = reduceUpload(s, { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' }); + const next = reduceUpload(s, { + type: 'DeliveryChannelChanged', + categoryId: 'diploma', + channel: 'post', + }); expect(next.deliveryChannel['diploma']).toBe('digital'); }); it('switching back to digital starts clean', () => { let s = stateWith([cat()], { deliveryChannel: { diploma: 'post' } }); - s = reduceUpload(s, { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'digital' }); + s = reduceUpload(s, { + type: 'DeliveryChannelChanged', + categoryId: 'diploma', + channel: 'digital', + }); expect(s.deliveryChannel['diploma']).toBe('digital'); expect(s.uploads).toHaveLength(0); }); @@ -203,7 +250,10 @@ describe('satisfaction helpers', () => { }); it('requiredCategoriesSatisfied ignores optional categories', () => { - const s = stateWith([cat({ categoryId: 'req', required: true }), cat({ categoryId: 'opt', required: false })]); + const s = stateWith([ + cat({ categoryId: 'req', required: true }), + cat({ categoryId: 'opt', required: false }), + ]); expect(requiredCategoriesSatisfied(s)).toBe(false); const s2 = select(s, 'req', 'u1'); expect(requiredCategoriesSatisfied(s2)).toBe(true); @@ -212,7 +262,9 @@ describe('satisfaction helpers', () => { describe('deliveryRefs', () => { it('emits documentId for completed digital uploads and channel for post', () => { - let s = stateWith([cat({ categoryId: 'a' }), cat({ categoryId: 'b' })], { deliveryChannel: { b: 'post' } }); + let s = stateWith([cat({ categoryId: 'a' }), cat({ categoryId: 'b' })], { + deliveryChannel: { b: 'post' }, + }); s = select(s, 'a', 'u1'); s = reduceUpload(s, { type: 'UploadComplete', localId: 'u1', documentId: 'doc1' }); expect(deliveryRefs(s)).toEqual([ @@ -229,16 +281,27 @@ describe('deliveryRefs', () => { describe('rejectReason', () => { it('rejects a disallowed type', () => { - expect(rejectReason(cat({ acceptedTypes: ['application/pdf'] }), { type: 'image/png', sizeMb: 1 })).toBe('type'); + expect( + rejectReason(cat({ acceptedTypes: ['application/pdf'] }), { type: 'image/png', sizeMb: 1 }), + ).toBe('type'); }); it('rejects an oversized file', () => { - expect(rejectReason(cat({ maxSizeMb: 10 }), { type: 'application/pdf', sizeMb: 11 })).toBe('size'); + expect(rejectReason(cat({ maxSizeMb: 10 }), { type: 'application/pdf', sizeMb: 11 })).toBe( + 'size', + ); }); it('accepts a valid file', () => { - expect(rejectReason(cat({ acceptedTypes: ['application/pdf'], maxSizeMb: 10 }), { type: 'application/pdf', sizeMb: 1 })).toBeNull(); + expect( + rejectReason(cat({ acceptedTypes: ['application/pdf'], maxSizeMb: 10 }), { + type: 'application/pdf', + sizeMb: 1, + }), + ).toBeNull(); }); it('allows any type when the category lists none', () => { - expect(rejectReason(cat({ acceptedTypes: [], maxSizeMb: 10 }), { type: 'image/png', sizeMb: 1 })).toBeNull(); + expect( + rejectReason(cat({ acceptedTypes: [], maxSizeMb: 10 }), { type: 'image/png', sizeMb: 1 }), + ).toBeNull(); }); }); @@ -249,6 +312,10 @@ describe('inFlight', () => { s = reduceUpload(s, { type: 'UploadProgress', localId: 'u2', progressPct: 10 }); s = select(s, 'diploma', 'u3'); s = reduceUpload(s, { type: 'UploadComplete', localId: 'u3', documentId: 'doc3' }); - expect(inFlight(s).map((u) => u.localId).sort()).toEqual(['u1', 'u2']); + expect( + inFlight(s) + .map((u) => u.localId) + .sort(), + ).toEqual(['u1', 'u2']); }); }); diff --git a/src/app/shared/upload/upload.machine.ts b/src/app/shared/upload/upload.machine.ts index 62f4e0d..221bf46 100644 --- a/src/app/shared/upload/upload.machine.ts +++ b/src/app/shared/upload/upload.machine.ts @@ -60,7 +60,13 @@ export type UploadMsg = | { type: 'CategoriesLoaded'; categories: DocumentCategory[] } | { type: 'CategoriesLoadFailed'; reason: string } | { type: 'BackgroundSyncAvailability'; available: boolean } - | { type: 'FileSelected'; categoryId: string; localId: string; fileName: string; fileSizeMb: number } + | { + type: 'FileSelected'; + categoryId: string; + localId: string; + fileName: string; + fileSizeMb: number; + } | { type: 'FileRejected'; categoryId: string; reason: 'type' | 'size' | 'multiple' } | { type: 'UploadQueued'; localId: string; backgroundSync: boolean } | { type: 'UploadProgress'; localId: string; progressPct: number } @@ -75,7 +81,12 @@ export type UploadMsg = | { type: 'DeliveryChannelChanged'; categoryId: string; channel: DeliveryChannel } | { type: 'BackgroundUploadsReturned'; - results: Array<{ localId: string } & ({ success: true; documentId: string } | { success: false; reason: string })>; + results: Array< + { localId: string } & ( + | { success: true; documentId: string } + | { success: false; reason: string } + ) + >; }; const REJECTION_MESSAGES: Record<'type' | 'size' | 'multiple', string> = { @@ -100,7 +111,10 @@ export function requiredCategoriesSatisfied(s: UploadState): boolean { * FE format-validation (never authority — the server re-validates). Returns the * rejection reason for one file, or null if it passes the category's type/size. */ -export function rejectReason(cat: DocumentCategory, file: { type: string; sizeMb: number }): 'type' | 'size' | null { +export function rejectReason( + cat: DocumentCategory, + file: { type: string; sizeMb: number }, +): 'type' | 'size' | null { if (cat.acceptedTypes.length > 0 && !cat.acceptedTypes.includes(file.type)) return 'type'; if (cat.maxSizeMb > 0 && file.sizeMb > cat.maxSizeMb) return 'size'; return null; @@ -112,7 +126,8 @@ function mapUpload(s: UploadState, localId: string, f: (u: Upload) => Upload): U } const find = (s: UploadState, localId: string) => s.uploads.find((u) => u.localId === localId); -const categoryOf = (s: UploadState, categoryId: string) => s.categories.find((c) => c.categoryId === categoryId); +const categoryOf = (s: UploadState, categoryId: string) => + s.categories.find((c) => c.categoryId === categoryId); export function reduceUpload(s: UploadState, m: UploadMsg): UploadState { switch (m.type) { @@ -121,9 +136,16 @@ export function reduceUpload(s: UploadState, m: UploadMsg): UploadState { // DUO is chosen). Drop uploads + channel choices for categories no longer present. const ids = new Set(m.categories.map((c) => c.categoryId)); const deliveryChannel: Record = {}; - for (const c of m.categories) deliveryChannel[c.categoryId] = s.deliveryChannel[c.categoryId] ?? 'digital'; + for (const c of m.categories) + deliveryChannel[c.categoryId] = s.deliveryChannel[c.categoryId] ?? 'digital'; const uploads = s.uploads.filter((u) => ids.has(u.categoryId)); - return { ...s, categories: m.categories, uploads, deliveryChannel, categoriesError: undefined }; + return { + ...s, + categories: m.categories, + uploads, + deliveryChannel, + categoriesError: undefined, + }; } case 'CategoriesLoadFailed': return { ...s, categoriesError: m.reason }; @@ -135,7 +157,9 @@ export function reduceUpload(s: UploadState, m: UploadMsg): UploadState { if (!categoryOf(s, m.categoryId) || s.deliveryChannel[m.categoryId] === 'post') return s; const cat = categoryOf(s, m.categoryId)!; // Single-file categories: a new selection replaces any existing upload. - const uploads = cat.multiple ? s.uploads : s.uploads.filter((u) => u.categoryId !== m.categoryId); + const uploads = cat.multiple + ? s.uploads + : s.uploads.filter((u) => u.categoryId !== m.categoryId); const next: Upload = { localId: m.localId, categoryId: m.categoryId, @@ -148,16 +172,32 @@ export function reduceUpload(s: UploadState, m: UploadMsg): UploadState { return { ...s, uploads: [...uploads, next], rejections }; } case 'FileRejected': - return { ...s, rejections: { ...s.rejections, [m.categoryId]: REJECTION_MESSAGES[m.reason] } }; + return { + ...s, + rejections: { ...s.rejections, [m.categoryId]: REJECTION_MESSAGES[m.reason] }, + }; case 'UploadQueued': - return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'queued' }, backgroundSync: m.backgroundSync })); + return mapUpload(s, m.localId, (u) => ({ + ...u, + status: { type: 'queued' }, + backgroundSync: m.backgroundSync, + })); case 'UploadProgress': - return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'uploading', progressPct: m.progressPct } })); + return mapUpload(s, m.localId, (u) => ({ + ...u, + status: { type: 'uploading', progressPct: m.progressPct }, + })); case 'UploadComplete': - return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'complete', documentId: m.documentId } })); + return mapUpload(s, m.localId, (u) => ({ + ...u, + status: { type: 'complete', documentId: m.documentId }, + })); case 'UploadFailed': - return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'failed', reason: m.reason } })); + return mapUpload(s, m.localId, (u) => ({ + ...u, + status: { type: 'failed', reason: m.reason }, + })); case 'UploadRetried': return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'queued' } })); @@ -168,15 +208,22 @@ export function reduceUpload(s: UploadState, m: UploadMsg): UploadState { case 'UploadDeleting': // Both mark the in-flight delete; keep the documentId so a failure can revert. return mapUpload(s, m.localId, (u) => { - const documentId = u.status.type === 'complete' ? u.status.documentId - : u.status.type === 'deleting' ? u.status.documentId : ''; + const documentId = + u.status.type === 'complete' + ? u.status.documentId + : u.status.type === 'deleting' + ? u.status.documentId + : ''; return { ...u, status: { type: 'deleting', documentId } }; }); case 'UploadDeleteComplete': return { ...s, uploads: s.uploads.filter((u) => u.localId !== m.localId) }; case 'UploadDeleteFailed': return mapUpload(s, m.localId, (u) => - u.status.type === 'deleting' ? { ...u, status: { type: 'complete', documentId: u.status.documentId } } : u); + u.status.type === 'deleting' + ? { ...u, status: { type: 'complete', documentId: u.status.documentId } } + : u, + ); case 'DeliveryChannelChanged': { const cat = categoryOf(s, m.categoryId); @@ -193,7 +240,9 @@ export function reduceUpload(s: UploadState, m: UploadMsg): UploadState { for (const r of m.results) { next = mapUpload(next, r.localId, (u) => ({ ...u, - status: r.success ? { type: 'complete', documentId: r.documentId } : { type: 'failed', reason: r.reason }, + status: r.success + ? { type: 'complete', documentId: r.documentId } + : { type: 'failed', reason: r.reason }, })); } return next; @@ -205,7 +254,9 @@ export function reduceUpload(s: UploadState, m: UploadMsg): UploadState { } /** The submit payload fragment: digital docs carry their documentId, post categories the channel. */ -export function deliveryRefs(s: UploadState): Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }> { +export function deliveryRefs( + s: UploadState, +): Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }> { const refs: Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }> = []; for (const c of s.categories) { if (s.deliveryChannel[c.categoryId] === 'post') { @@ -213,7 +264,11 @@ export function deliveryRefs(s: UploadState): Array<{ categoryId: string; channe } else { for (const u of s.uploads) { if (u.categoryId === c.categoryId && u.status.type === 'complete') { - refs.push({ categoryId: c.categoryId, channel: 'digital', documentId: u.status.documentId }); + refs.push({ + categoryId: c.categoryId, + channel: 'digital', + documentId: u.status.documentId, + }); } } } diff --git a/src/app/showcase/concepts.page.ts b/src/app/showcase/concepts.page.ts index d1a1aa1..b910aa0 100644 --- a/src/app/showcase/concepts.page.ts +++ b/src/app/showcase/concepts.page.ts @@ -14,7 +14,13 @@ import { parsePostcode } from '@registratie/domain/value-objects/postcode'; /** Minimal fake Resource so can be driven through every state without HTTP. */ function fakeResource(status: string, value?: T, error?: Error): Resource { - return { value: () => value as T, status: () => status, error: () => error, hasValue: () => value !== undefined, reload: () => {} } as unknown as Resource; + return { + value: () => value as T, + status: () => status, + error: () => error, + hasValue: () => value !== undefined, + reload: () => {}, + } as unknown as Resource; } /** Teaching showcase: each section pairs the impossible-state-permitting"before" @@ -22,38 +28,151 @@ function fakeResource(status: string, value?: T, error?: Error): Resource @Component({ selector: 'app-concepts-page', imports: [ - FormsModule, PageShellComponent, HeadingComponent, TextInputComponent, - ...ASYNC, SkeletonComponent, RegistrationSummaryComponent, HerregistratieWizardComponent, IntakeWizardComponent, + FormsModule, + PageShellComponent, + HeadingComponent, + TextInputComponent, + ...ASYNC, + SkeletonComponent, + RegistrationSummaryComponent, + HerregistratieWizardComponent, + IntakeWizardComponent, + ], + styles: [ + ` + .section { + margin: 0 0 3rem; + } + .lead { + color: var(--rhc-color-grijs-700); + max-width: 46rem; + margin: 0.25rem 0 1.25rem; + } + .cols { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); + gap: 1.5rem; + align-items: start; + } + .card { + border: 1px solid var(--rhc-color-grijs-200, #e5e5e5); + border-radius: 10px; + padding: 1.25rem; + background: #fff; + } + .card--bad { + border-color: var(--rhc-color-rood-300, #f0b4b4); + } + .card--good { + border-color: var(--rhc-color-groen-300, #b4e0b4); + } + .tag { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-weight: 700; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 0.75rem; + } + .tag::before { + content: ''; + width: 0.6rem; + height: 0.6rem; + border-radius: 50%; + } + .tag.bad { + color: var(--rhc-color-rood-600, #a30000); + } + .tag.bad::before { + background: var(--rhc-color-rood-500, #d52b1e); + } + .tag.good { + color: var(--rhc-color-groen-700, #277337); + } + .tag.good::before { + background: var(--rhc-color-groen-500, #39870c); + } + .tag.plain { + color: var(--rhc-color-grijs-700); + } + .tag.plain::before { + display: none; + } + pre { + background: #1e2430; + color: #e6e9ef; + padding: 1rem; + border-radius: 8px; + overflow: auto; + font-size: 0.82rem; + line-height: 1.55; + margin: 0; + } + pre .k { + color: #c792ea; + } + pre .s { + color: #c3e88d; + } + pre .c { + color: #7e8aa0; + font-style: italic; + } + .note { + font-size: 0.9rem; + color: var(--rhc-color-grijs-700); + margin: 0.75rem 0 0; + } + /* live state diagram */ + .machine { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0 0 1rem; + } + .node { + padding: 0.4rem 0.8rem; + border-radius: 999px; + border: 1px solid var(--rhc-color-grijs-300, #ccc); + font-size: 0.82rem; + color: var(--rhc-color-grijs-700); + transition: all 0.15s; + } + .node.on { + background: var(--rhc-color-hemelblauw-100, #e5f1fb); + border-color: var(--rhc-color-hemelblauw-500, #007bc7); + color: var(--rhc-color-hemelblauw-700, #00567d); + font-weight: 700; + } + .steplist { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; + margin: 0 0 1rem; + } + .pill { + padding: 0.3rem 0.7rem; + border-radius: 8px; + background: var(--rhc-color-grijs-100, #f3f3f3); + font-size: 0.8rem; + } + .pill.extra { + background: var(--rhc-color-geel-100, #fff6d6); + border: 1px dashed var(--rhc-color-geel-600, #c79a00); + } + .arrow { + color: var(--rhc-color-grijs-400, #999); + } + `, ], - styles: [` - .section { margin: 0 0 3rem } - .lead { color: var(--rhc-color-grijs-700); max-width: 46rem; margin: 0.25rem 0 1.25rem } - .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 1.5rem; align-items: start } - .card { border: 1px solid var(--rhc-color-grijs-200, #e5e5e5); border-radius: 10px; padding: 1.25rem; background: #fff } - .card--bad { border-color: var(--rhc-color-rood-300, #f0b4b4) } - .card--good { border-color: var(--rhc-color-groen-300, #b4e0b4) } - .tag { display: inline-flex; align-items: center; gap: 0.4rem; font-weight: 700; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 0.75rem } - .tag::before { content: ''; width: 0.6rem; height: 0.6rem; border-radius: 50% } - .tag.bad { color: var(--rhc-color-rood-600, #a30000) } .tag.bad::before { background: var(--rhc-color-rood-500, #d52b1e) } - .tag.good { color: var(--rhc-color-groen-700, #277337) } .tag.good::before { background: var(--rhc-color-groen-500, #39870c) } - .tag.plain { color: var(--rhc-color-grijs-700) } .tag.plain::before { display: none } - pre { background: #1e2430; color: #e6e9ef; padding: 1rem; border-radius: 8px; overflow: auto; font-size: 0.82rem; line-height: 1.55; margin: 0 } - pre .k { color: #c792ea } pre .s { color: #c3e88d } pre .c { color: #7e8aa0; font-style: italic } - .note { font-size: 0.9rem; color: var(--rhc-color-grijs-700); margin: 0.75rem 0 0 } - /* live state diagram */ - .machine { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0 0 1rem } - .node { padding: 0.4rem 0.8rem; border-radius: 999px; border: 1px solid var(--rhc-color-grijs-300, #ccc); font-size: 0.82rem; color: var(--rhc-color-grijs-700); transition: all .15s } - .node.on { background: var(--rhc-color-hemelblauw-100, #e5f1fb); border-color: var(--rhc-color-hemelblauw-500, #007bc7); color: var(--rhc-color-hemelblauw-700, #00567d); font-weight: 700 } - .steplist { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin: 0 0 1rem } - .pill { padding: 0.3rem 0.7rem; border-radius: 8px; background: var(--rhc-color-grijs-100, #f3f3f3); font-size: 0.8rem } - .pill.extra { background: var(--rhc-color-geel-100, #fff6d6); border: 1px dashed var(--rhc-color-geel-600, #c79a00) } - .arrow { color: var(--rhc-color-grijs-400, #999) } - `], template: `

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

      @@ -64,12 +183,17 @@ function fakeResource(status: string, value?: T, error?: Error): Resource

      Fout — vlakke interface

      
      -            

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

      +

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

      Goed — sum type

      -

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

      +

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

      @@ -77,23 +201,44 @@ function fakeResource(status: string, value?: T, error?: Error): Resource
      2 · RemoteData fold -

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

      +

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

      Vier toestanden, één molecuul

      Loading

      - {{ v }} + {{ v }}

      Empty

      - {{ v }} + {{ v }}

      Failure

      - {{ v }} + {{ v }}

      Success

      -
        @for (i of v; track i) {
      • {{ i }}
      • }
      +
        + @for (i of v; track i) { +
      • {{ i }}
      • + } +

      De exhaustieve fold

      
      -            

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

      +

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

      @@ -105,13 +250,21 @@ function fakeResource(status: string, value?: T, error?: Error): Resource

      Smart constructor → Result

      - +
      @if (parsed().ok) {

      ok

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

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

      +

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

      } @else {

      err

      {{ $any(parsed()).error }}
      @@ -123,17 +276,22 @@ function fakeResource(status: string, value?: T, error?: Error): Resource
      4 · Form als state machine -

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

      +

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

      Fout — losse booleans

      
      -            

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

      +

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

      Goed — één tagged union

      - @for (n of ['Editing','Submitting','Submitted','Failed']; track n) { + @for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) { {{ n }} }
      @@ -144,11 +302,14 @@ function fakeResource(status: string, value?: T, error?: Error): Resource
      - 5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen" + 5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen"

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

      @@ -156,10 +317,15 @@ function fakeResource(status: string, value?: T, error?: Error): Resource
      @for (s of iw.steps; track s; let last = $last) { {{ s }} - @if (!last) { } + @if (!last) { + + } }
      -

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

      +

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

      De wizard

      @@ -174,8 +340,11 @@ export class ConceptsPage { isEmpty = (v: string[]) => !v || v.length === 0; doorgehaald: Registration = { - bigNummer: '19012345601', naam: 'Dr. A. (Anna) de Vries', beroep: 'Arts', - registratiedatum: '2012-09-01', geboortedatum: '1985-03-14', + bigNummer: '19012345601', + naam: 'Dr. A. (Anna) de Vries', + beroep: 'Arts', + registratiedatum: '2012-09-01', + geboortedatum: '1985-03-14', status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' }, }; diff --git a/src/docs/atomic-design.mdx b/src/docs/atomic-design.mdx index 8d219d0..ac601bb 100644 --- a/src/docs/atomic-design.mdx +++ b/src/docs/atomic-design.mdx @@ -14,14 +14,42 @@ atoms — that is the whole point: fewer things to understand, nothing bespoke p
      {[ - ['Templates', 'shared/layout', 'shell, page-shell, wizard-shell — the page skeleton', '#1e3a5f'], - ['Organisms', 'shared/ui/upload/document-upload …', 'self-contained sections that own a bit of behaviour', '#2a5a8a'], + [ + 'Templates', + 'shared/layout', + 'shell, page-shell, wizard-shell — the page skeleton', + '#1e3a5f', + ], + [ + 'Organisms', + 'shared/ui/upload/document-upload …', + 'self-contained sections that own a bit of behaviour', + '#2a5a8a', + ], ['Molecules', 'shared/ui/form-field, async …', 'a label + control + error, grouped', '#3f7cb5'], - ['Atoms', 'shared/ui/button, text-input …', 'thin wrappers over CIBG Huisstijl (Bootstrap) CSS classes', '#6aa6d8'], + [ + 'Atoms', + 'shared/ui/button, text-input …', + 'thin wrappers over CIBG Huisstijl (Bootstrap) CSS classes', + '#6aa6d8', + ], ].map(([name, where, why, bg], i) => ( -
      +
      {name} — {why} -
      {where}
      +
      + {where} +
      ))}
      diff --git a/src/docs/design-tokens.mdx b/src/docs/design-tokens.mdx index c731e38..0bcd44c 100644 --- a/src/docs/design-tokens.mdx +++ b/src/docs/design-tokens.mdx @@ -9,7 +9,7 @@ We do not hand-write colours or spacing. `src/styles.scss` defines a semantic `- vocabulary and redefines every one of those tokens onto the vendored **CIBG Huisstijl** (Bootstrap 5.2) values — `--bs-*`/`--ro-*` custom properties where one exists, CIBG palette hex otherwise (that one file is exempt from `npm run check:tokens`, which fails the build on any -*other* hardcoded hex colour in atoms/molecules/chrome). The `--rhc-*` names are an internal +_other_ hardcoded hex colour in atoms/molecules/chrome). The `--rhc-*` names are an internal alias set now; the values are CIBG's. **Prefer a CIBG class over a token where one exists** — `.btn`, `.form-control`, `.card`, @@ -27,7 +27,11 @@ export const Resolved = ({ token }) => { useLayoutEffect(() => { if (ref.current) setVal(getComputedStyle(ref.current).getPropertyValue(token).trim()); }, [token]); - return {val || '…'}; + return ( + + {val || '…'} + + ); }; ## When to use which token @@ -43,12 +47,19 @@ export const Resolved = ({ token }) => { ## Spacing scale — `--rhc-space-max-*`
      - {['xs','sm','md','lg','xl','2xl','3xl','4xl','5xl'].map((step) => { + {['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl'].map((step) => { const token = `--rhc-space-max-${step}`; return (
      {token} -
      +
      ); @@ -57,7 +68,14 @@ export const Resolved = ({ token }) => { ## Semantic colours -
      +
      {[ '--rhc-color-foreground-default', '--rhc-color-foreground-subtle', @@ -72,7 +90,9 @@ export const Resolved = ({ token }) => {
      -
      {token}
      +
      + {token} +
      diff --git a/src/docs/fp-in-ui.mdx b/src/docs/fp-in-ui.mdx index 053c703..6bcf5ae 100644 --- a/src/docs/fp-in-ui.mdx +++ b/src/docs/fp-in-ui.mdx @@ -5,7 +5,7 @@ import * as AsyncStories from '../app/shared/ui/async/async.stories'; # Functional programming in the UI -The components in this library are the *view*. Behind them, three small functional tools do +The components in this library are the _view_. Behind them, three small functional tools do the heavy lifting — all so that **illegal states can't be represented**. This page is the Storybook front door; the full narrative lives in `docs/fp-tea-atomic-design.md`, and a side-by-side "before/after" runs at the app's **`/concepts`** route. @@ -25,7 +25,7 @@ construction: `src/app/shared/application/store.ts` + the `*.machine.ts` files. State is one tagged-union value; the template never mutates it, it `dispatch`es a message and a **pure** -`reduce(model, msg)` returns the next state. Side effects live in a *command*, never in the +`reduce(model, msg)` returns the next state. Side effects live in a _command_, never in the reducer: ```ts @@ -45,7 +45,7 @@ Because state is one value, the whole thing is inspectable and every transition `src/app/registratie/domain/value-objects/`. A `Postcode` is a distinct type from `string`, mintable only through `parsePostcode`, which returns a `Result`. Once you hold the type, you -never re-check it — the type *is* the proof. Compose the parse pipeline with the `Result` +never re-check it — the type _is_ the proof. Compose the parse pipeline with the `Result` combinators in `src/app/shared/kernel/fp.ts` (`map`, `mapErr`, `andThen`, `fold`) rather than hand-branching `r.ok ? … : …` at every step. diff --git a/src/index.html b/src/index.html index 01527e7..cfa4cc6 100644 --- a/src/index.html +++ b/src/index.html @@ -1,19 +1,19 @@ - - - BIG-register — Mijn omgeving - - - - - - - - - - + + + diff --git a/src/styles.scss b/src/styles.scss index d06983c..a7b61e3 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -13,8 +13,8 @@ /* — semantic foreground/background/border — */ --rhc-color-foreground-default: var(--bs-body-color); - --rhc-color-foreground-subtle: #64748b; /* CIBG grey-3 */ - --rhc-color-foreground-link: var(--ro-link-color); /* brand--cibg → hemelblauw-fixed */ + --rhc-color-foreground-subtle: #64748b; /* CIBG grey-3 */ + --rhc-color-foreground-link: var(--ro-link-color); /* brand--cibg → hemelblauw-fixed */ --rhc-color-foreground-link-hover: var(--ro-link-active-color); --rhc-color-foreground-on-primary: #fff; --rhc-color-border-default: var(--bs-border-color); @@ -34,23 +34,23 @@ /* — blues (CIBG primary family) — */ --rhc-color-lintblauw-100: #e6eef3; - --rhc-color-lintblauw-500: var(--ro-brand-primary); /* #01689b */ + --rhc-color-lintblauw-500: var(--ro-brand-primary); /* #01689b */ --rhc-color-lintblauw-600: var(--ro-brand-primary-focus); /* #015782 */ - --rhc-color-lintblauw-700: var(--ro-lintblauw); /* #154273 */ - --rhc-color-hemelblauw-100: #d9ebf7; /* CIBG light-blue (info bg) */ + --rhc-color-lintblauw-700: var(--ro-lintblauw); /* #154273 */ + --rhc-color-hemelblauw-100: #d9ebf7; /* CIBG light-blue (info bg) */ --rhc-color-hemelblauw-500: var(--ro-hemelblauw); /* #007bc7 CIBG info blue */ --rhc-color-hemelblauw-700: #005a94; /* — status colors (CIBG semantic, kleuren page) — */ - --rhc-color-groen-300: #e1eddb; /* light-green */ - --rhc-color-groen-500: #39870c; /* green */ + --rhc-color-groen-300: #e1eddb; /* light-green */ + --rhc-color-groen-500: #39870c; /* green */ --rhc-color-groen-700: #176e1b; - --rhc-color-rood-100: #f7d2dd; /* light-red */ + --rhc-color-rood-100: #f7d2dd; /* light-red */ --rhc-color-rood-300: #eda3b6; - --rhc-color-rood-500: var(--ro-brand-danger); /* #cc003d CIBG red */ + --rhc-color-rood-500: var(--ro-brand-danger); /* #cc003d CIBG red */ --rhc-color-rood-600: #b30035; - --rhc-color-geel-100: #fff4dc; /* light-yellow */ - --rhc-color-geel-600: #ffb612; /* yellow */ + --rhc-color-geel-100: #fff4dc; /* light-yellow */ + --rhc-color-geel-600: #ffb612; /* yellow */ --rhc-color-oranje-500: #e17000; /* — spacing scale (rem, Bootstrap-aligned) — */ @@ -81,20 +81,28 @@ --rhc-text-line-height-sm: 1.4; /* System-font stack (licensed Rijksoverheid fonts intentionally not bundled — POC). */ - --bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --bs-font-sans-serif: + system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; } -html, body { margin: 0; min-height: 100%; } +html, +body { + margin: 0; + min-height: 100%; +} /* Pages sit on white; cards/panels carry the subtle grey. */ -body { background: var(--rhc-color-wit); color: var(--rhc-color-foreground-default); } +body { + background: var(--rhc-color-wit); + color: var(--rhc-color-foreground-default); +} /* App theme layer: a few app-specific measures RHC has no token for (content/form widths), defined ONCE here and mapped onto RHC where possible. Components reference these tokens instead of raw values. */ :root { - --app-content-max: 67rem; /* readable page column */ - --app-form-narrow: 32rem; /* page-shell narrow variant */ - --app-skip-link-offset: -999px; /* off-screen skip link */ + --app-content-max: 67rem; /* readable page column */ + --app-form-narrow: 32rem; /* page-shell narrow variant */ + --app-skip-link-offset: -999px; /* off-screen skip link */ /* Dev-only state inspector (debug-state.component.ts, isDevMode-gated, never in prod): a dark code-editor palette kept deliberately OFF the CIBG design system @@ -110,9 +118,15 @@ body { background: var(--rhc-color-wit); color: var(--rhc-color-foreground-defau /* App utility classes: centralise the repeated inline layout idioms so components stay token-based and free of raw values. */ /* vertical rhythm between stacked blocks */ -.app-stack > * + * { margin-block-start: var(--rhc-space-max-xl); } -.app-section { margin-block-start: var(--rhc-space-max-2xl); } -.app-text-subtle { color: var(--rhc-color-foreground-subtle); } +.app-stack > * + * { + margin-block-start: var(--rhc-space-max-xl); +} +.app-section { + margin-block-start: var(--rhc-space-max-2xl); +} +.app-text-subtle { + color: var(--rhc-color-foreground-subtle); +} /* Bordered info box (outline, no fill) — e.g. a "Vragen?" contact block. */ .app-info-box { @@ -124,8 +138,12 @@ body { background: var(--rhc-color-wit); color: var(--rhc-color-foreground-defau /* Route transitions (withViewTransitions): cross-fade the routed CONTENT only. The chrome gets its own stable view-transition-name so it's lifted out of the `root` snapshot and stays put while the content fades. */ -app-site-header { view-transition-name: site-header; } -app-site-footer { view-transition-name: site-footer; } +app-site-header { + view-transition-name: site-header; +} +app-site-footer { + view-transition-name: site-footer; +} ::view-transition-old(root), ::view-transition-new(root) { @@ -135,14 +153,34 @@ app-site-footer { view-transition-name: site-footer; } /* List-item enter/leave for lists that add/remove at runtime (aanvraag cards, upload rows). Used via Angular's native `animate.enter`/`animate.leave` — no @angular/animations. */ -@keyframes app-item-enter { from { opacity: 0; transform: translateY(-0.5rem); } to { opacity: 1; transform: none; } } -@keyframes app-item-leave { - from { opacity: 1; max-block-size: 50rem; } - to { opacity: 0; transform: translateY(-0.25rem); max-block-size: 0; margin-block: 0; padding-block: 0; } +@keyframes app-item-enter { + from { + opacity: 0; + transform: translateY(-0.5rem); + } + to { + opacity: 1; + transform: none; + } +} +@keyframes app-item-leave { + from { + opacity: 1; + max-block-size: 50rem; + } + to { + opacity: 0; + transform: translateY(-0.25rem); + max-block-size: 0; + margin-block: 0; + padding-block: 0; + } +} +.app-item-enter { + animation: app-item-enter 220ms ease both; } -.app-item-enter { animation: app-item-enter 220ms ease both; } .app-item-leave { - display: block; /* collapse needs block formatting (host is inline/flex by default) */ + display: block; /* collapse needs block formatting (host is inline/flex by default) */ overflow: hidden; pointer-events: none; /* ponytail: 50rem max-height hack; if an item is taller the collapse eases slightly late — measure height in JS then. */ @@ -152,7 +190,12 @@ app-site-footer { view-transition-name: site-footer; } @media (prefers-reduced-motion: reduce) { ::view-transition-group(*), ::view-transition-old(*), - ::view-transition-new(*) { animation: none !important; } + ::view-transition-new(*) { + animation: none !important; + } /* No animation → Angular removes the leaving node immediately (instant, no motion). */ - .app-item-enter, .app-item-leave { animation: none; } + .app-item-enter, + .app-item-leave { + animation: none; + } }