Compare commits
29
Commits
dff5f96bb3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1866947438 | ||
|
|
6330773fd5 | ||
|
|
856463b738 | ||
|
|
44dcc69811 | ||
|
|
d9aef9541f | ||
|
|
682db41344 | ||
|
|
241fe1b57b | ||
|
|
d31e054504 | ||
|
|
12f17d9d73 | ||
|
|
097e8468e0 | ||
|
|
2aa343f255 | ||
|
|
f3e5745145 | ||
|
|
e6bc19c790 | ||
|
|
fc2a3c348b | ||
|
|
551cabce5e | ||
|
|
b6bd7eea5a | ||
|
|
d4c5a9450a | ||
|
|
57fc72f254 | ||
|
|
43dc3210cd | ||
|
|
7a8eab917b | ||
|
|
bcac3789d3 | ||
|
|
cf1f641534 | ||
|
|
5aed15bb98 | ||
|
|
630d68045f | ||
|
|
d5a7a25a78 | ||
|
|
11e3191099 | ||
|
|
8e1de38c68 | ||
|
|
4b3e6a6cfd | ||
|
|
8a3e42015b |
@@ -20,11 +20,11 @@ The repo splits docs by kind (`docs/README.md`). Pick one — don't write all th
|
|||||||
_decision/pattern_ (a tradeoff, a "we chose X over Y"). One format, no drift:
|
_decision/pattern_ (a tradeoff, a "we chose X over Y"). One format, no drift:
|
||||||
`# ADR-NNNN — Title`, then `Status: Accepted · Date: YYYY-MM-DD`, then
|
`# ADR-NNNN — Title`, then `Status: Accepted · Date: YYYY-MM-DD`, then
|
||||||
Problem / Options / Decision / Consequences.
|
Problem / Options / Decision / Consequences.
|
||||||
- **WP** — `docs/project/backlog/WP-NN-<slug>.md`. Tracked work; use the template at the
|
- **WP** — `docs/project/archive/backlog/WP-NN-<slug>.md`. Tracked work; use the template at the
|
||||||
bottom of `docs/project/backlog/README.md`.
|
bottom of `docs/project/archive/backlog/README.md`.
|
||||||
|
|
||||||
Then **add the index row**: `docs/README.md` table for a reference doc/ADR,
|
Then **add the index row**: `docs/README.md` table for a reference doc/ADR,
|
||||||
`docs/project/backlog/README.md` for a WP. A doc with no index row is invisible.
|
`docs/project/archive/backlog/README.md` for a WP. A doc with no index row is invisible.
|
||||||
|
|
||||||
## Adjusted feature → update what already describes it
|
## Adjusted feature → update what already describes it
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ Work through that checklist, keeping the GREEN gate below passing at every step.
|
|||||||
re-target it at an arbitrary new context, so it's deleted alongside `registratie`, along
|
re-target it at an arbitrary new context, so it's deleted alongside `registratie`, along
|
||||||
with its three wiring lines in `shell.component.ts` (import, `imports:` entry, template tag).
|
with its three wiring lines in `shell.component.ts` (import, `imports:` entry, template tag).
|
||||||
- The `dashboard` route is **not** deleted even though it currently imports
|
- The `dashboard` route is **not** deleted even though it currently imports
|
||||||
`@registratie/ui/dashboard.page` — too much else hardcodes `/dashboard` (login's post-auth
|
`@overzicht/ui/overzicht.page` — too much else hardcodes `/dashboard` (login's post-auth
|
||||||
redirect, `authGuard`'s fallback, header nav/logo, breadcrumb trail, several stories/specs).
|
redirect, `authGuard`'s fallback, header nav/logo, breadcrumb trail, several stories/specs).
|
||||||
The script rewrites its `loadComponent` to point at the freshly scaffolded `--context` page
|
The script rewrites its `loadComponent` to point at the freshly scaffolded `--context` page
|
||||||
instead (a `TODO(create-frontend)` stopgap landing page, not a real overview).
|
instead (a `TODO(create-frontend)` stopgap landing page, not a real overview).
|
||||||
@@ -85,8 +85,8 @@ Work through that checklist, keeping the GREEN gate below passing at every step.
|
|||||||
placeholder path and creates an empty `public/<name>-huisstijl/` — it cannot generate a
|
placeholder path and creates an empty `public/<name>-huisstijl/` — it cannot generate a
|
||||||
real house style. Vendor your CSS there, then re-point the `--rhc-*` bridge in
|
real house style. Vendor your CSS there, then re-point the `--rhc-*` bridge in
|
||||||
`src/styles.scss` (ADR-0003 pattern: bridge, don't rewrite tokens), then `npm run check:tokens`.
|
`src/styles.scss` (ADR-0003 pattern: bridge, don't rewrite tokens), then `npm run check:tokens`.
|
||||||
- `docs/project/backlog/` WPs, PRDs, and memory-specific docs — new portal, new backlog
|
- `docs/project/archive/backlog/` WPs, PRDs, and memory-specific docs — new portal, new backlog
|
||||||
(keep `docs/project/backlog/README.md`'s WP process/template if you like the workflow).
|
(keep `docs/project/archive/backlog/README.md`'s WP process/template if you like the workflow).
|
||||||
`docs/reference/scaffolding.md` also names `BigRegister.Api` in prose — update by hand.
|
`docs/reference/scaffolding.md` also names `BigRegister.Api` in prose — update by hand.
|
||||||
- `e2e/*.spec.ts` (`smoke.spec.ts`, `brief-v2.spec.ts`, `error-state.spec.ts`): full
|
- `e2e/*.spec.ts` (`smoke.spec.ts`, `brief-v2.spec.ts`, `error-state.spec.ts`): full
|
||||||
BIG-register user-flow tests (BSN login → registration wizard → submission assertions).
|
BIG-register user-flow tests (BSN login → registration wizard → submission assertions).
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ description: Add a shared UI building block (atom, molecule, organism) with its
|
|||||||
|
|
||||||
# UI component (atom / molecule / organism)
|
# UI component (atom / molecule / organism)
|
||||||
|
|
||||||
First: check `shared/ui/` and `shared/layout/` — a new page should be composition of
|
First: check `libs/shared/src/ui/` and `libs/shared/src/layout/` — a new page should be
|
||||||
existing blocks. Only add a block when nothing fits.
|
composition of existing blocks. Only add a block when nothing fits.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- **Folder = atomic layer**: `shared/ui/` atoms → molecules → organisms;
|
- **Folder = atomic layer**: `libs/shared/src/ui/` atoms → molecules → organisms;
|
||||||
`shared/layout/` templates. Each level only uses levels below.
|
`libs/shared/src/layout/` templates. Each level only uses levels below.
|
||||||
- Standalone component, **English name** (shared = language-agnostic), signal
|
- Standalone component, **English name** (shared = language-agnostic), signal
|
||||||
`input()`s only, `inject()` over constructor DI.
|
`input()`s only, `inject()` over constructor DI.
|
||||||
- **Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2) classes** (`btn`,
|
- **Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2) classes** (`btn`,
|
||||||
@@ -21,7 +21,8 @@ existing blocks. Only add a block when nothing fits.
|
|||||||
- **Tokens only** — `var(--rhc-*)` / `var(--app-*)`, never hardcoded colors
|
- **Tokens only** — `var(--rhc-*)` / `var(--app-*)`, never hardcoded colors
|
||||||
(`npm run check:tokens` fails the build; escape hatch: `token-ok` marker + reason).
|
(`npm run check:tokens` fails the build; escape hatch: `token-ok` marker + reason).
|
||||||
- **No hardcoded Dutch** in shared components — expose copy as `input()`s with
|
- **No hardcoded Dutch** in shared components — expose copy as `input()`s with
|
||||||
`$localize` defaults; the domain caller supplies the text (see `shared/ui/async`).
|
`$localize` defaults; the domain caller supplies the text (see
|
||||||
|
`libs/shared/src/ui/molecules/async`).
|
||||||
- Components with content-projected slots export a spread constant so callers import
|
- Components with content-projected slots export a spread constant so callers import
|
||||||
one thing: `export const ASYNC = [AsyncComponent, AsyncLoadedDirective, …] as const;`
|
one thing: `export const ASYNC = [AsyncComponent, AsyncLoadedDirective, …] as const;`
|
||||||
|
|
||||||
@@ -41,9 +42,10 @@ UI test surface.
|
|||||||
|
|
||||||
## Worked examples
|
## Worked examples
|
||||||
|
|
||||||
- Atom: `src/app/shared/ui/button/` — typed variant API over `btn` classes.
|
- Atom: `libs/shared/src/ui/atoms/button/` — typed variant API over `btn` classes.
|
||||||
- Molecule: `src/app/shared/ui/async/` — slot directives, localizable input defaults, spread constant.
|
- Molecule: `libs/shared/src/ui/molecules/async/` — slot directives, localizable input
|
||||||
- Template: `src/app/shared/layout/wizard-shell/` — the canonical wizard outline.
|
defaults, spread constant.
|
||||||
|
- Template: `libs/shared/src/layout/wizard-shell/` — the canonical wizard outline.
|
||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,29 @@ module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName)
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- Atomic ladder within libs/shared/src/ui (folder = layer, CLAUDE.md decision 2) ---
|
||||||
|
{
|
||||||
|
name: 'atoms-compose-nothing-above',
|
||||||
|
comment: 'An atom composes nothing above it — no molecule or organism. See CLAUDE.md §2.',
|
||||||
|
severity: 'error',
|
||||||
|
from: { path: '^libs/shared/src/ui/atoms/' },
|
||||||
|
to: { path: '^libs/shared/src/ui/(molecules|organisms)/' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'molecules-below-organisms',
|
||||||
|
comment: 'A molecule composes nothing above it — no organism. See CLAUDE.md §2.',
|
||||||
|
severity: 'error',
|
||||||
|
from: { path: '^libs/shared/src/ui/molecules/' },
|
||||||
|
to: { path: '^libs/shared/src/ui/organisms/' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'design-system-not-layout',
|
||||||
|
comment: 'The design system (ui/) does not depend on layout/ templates. See CLAUDE.md §2.',
|
||||||
|
severity: 'error',
|
||||||
|
from: { path: '^libs/shared/src/ui/' },
|
||||||
|
to: { path: '^libs/shared/src/layout/' },
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'no-testing-in-production',
|
name: 'no-testing-in-production',
|
||||||
comment:
|
comment:
|
||||||
|
|||||||
@@ -124,8 +124,15 @@ jobs:
|
|||||||
# app's messages.en.xlf is missing a unit its source (WP-20) or libs/shared gains.
|
# app's messages.en.xlf is missing a unit its source (WP-20) or libs/shared gains.
|
||||||
- run: npx ng build ssp --localize && npx ng build behandelportal --localize
|
- run: npx ng build ssp --localize && npx ng build behandelportal --localize
|
||||||
if: needs.changes.outputs.frontend == 'true'
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
# The shipped bundle must stay clean; dev-only advisories are excluded.
|
# The shipped bundle must stay clean; dev-only advisories are excluded. The gate is
|
||||||
- run: npm audit --omit=dev
|
# `high`, not the default `low`, because two moderate Angular advisories stay open
|
||||||
|
# while we hold at 22.0.5: GHSA-p297-fm68-3q8c and GHSA-hh8m-fm6v-7cvg. Neither is
|
||||||
|
# reachable — the app calls no `withRequestsMadeViaParent` and no
|
||||||
|
# `provideClientHydration`, and binds no untrusted value into a directive host
|
||||||
|
# binding. The fix is Angular 22.1.x, which emits `var(--%NS%name)` and breaks every
|
||||||
|
# `--rhc-*` token. Restore `low` after an Angular release substitutes the
|
||||||
|
# placeholder; verify with `grep -rl '%NS%' dist/` after `npm run build`.
|
||||||
|
- run: npm audit --omit=dev --audit-level=high
|
||||||
if: needs.changes.outputs.frontend == 'true'
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
|
|
||||||
storybook-a11y:
|
storybook-a11y:
|
||||||
|
|||||||
+2
-2
@@ -28,5 +28,5 @@ plop-templates/
|
|||||||
backend/
|
backend/
|
||||||
|
|
||||||
# Agent prompts — their exact wording is the input, reflowing markdown edits the prompt
|
# Agent prompts — their exact wording is the input, reflowing markdown edits the prompt
|
||||||
docs/project/refactor-backlog-setup/agents/
|
docs/project/archive/refactor-backlog-setup/agents/
|
||||||
docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/
|
docs/project/archive/refactor-backlog-setup/refactor-backlog/final-prompts/
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ signals. Auth is faked; **data and business rules are served by a minimal ASP.NE
|
|||||||
Core backend** (`backend/`, see its README) and consumed through an NSwag-generated
|
Core backend** (`backend/`, see its README) and consumed through an NSwag-generated
|
||||||
typed client. The FE renders the backend's decisions. Reference data mimicking
|
typed client. The FE renders the backend's decisions. Reference data mimicking
|
||||||
BRP/DUO (`Data/SeedData.cs`) is in-memory; applications, documents and the brief
|
BRP/DUO (`Data/SeedData.cs`) is in-memory; applications, documents and the brief
|
||||||
persist to a SQLite file via EF Core (WP-22) — `docs/project/backlog/WP-22-durable-persistence.md`.
|
persist to a SQLite file via EF Core (WP-22) — `docs/project/archive/backlog/WP-22-durable-persistence.md`.
|
||||||
|
|
||||||
**Monorepo (WP-67):** two Angular projects share one backend + one shared library —
|
**Monorepo (WP-67):** two Angular projects share one backend + one shared library —
|
||||||
`apps/ssp` (Zorgverlener self-service, this doc's main subject) and `apps/behandelportal`
|
`apps/ssp` (Zorgverlener self-service, this doc's main subject) and `apps/behandelportal`
|
||||||
@@ -34,6 +34,7 @@ npm run gen:api # regenerate the ONE typed client (libs/shared) f
|
|||||||
npm run ci # run the CI gate locally BEFORE pushing (mirrors ci.yml); `npm run ci --full` adds storybook-a11y
|
npm run ci # run the CI gate locally BEFORE pushing (mirrors ci.yml); `npm run ci --full` adds storybook-a11y
|
||||||
docker compose up # run both FE apps + backend together (Swagger at :5000/swagger)
|
docker compose up # run both FE apps + backend together (Swagger at :5000/swagger)
|
||||||
cd backend && dotnet test # backend rule + endpoint tests
|
cd backend && dotnet test # backend rule + endpoint tests
|
||||||
|
task # list every task (a thin facade over the commands above)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Two Storybook instances, not one:** `apps/ssp` and `apps/behandelportal` each have their own
|
**Two Storybook instances, not one:** `apps/ssp` and `apps/behandelportal` each have their own
|
||||||
@@ -57,7 +58,10 @@ catches a miss before CI does.
|
|||||||
|
|
||||||
`.npmrc` sets `legacy-peer-deps=true` (Storybook's peer range lags Angular 22).
|
`.npmrc` sets `legacy-peer-deps=true` (Storybook's peer range lags Angular 22).
|
||||||
Do not run `npm audit fix --force` — it downgrades Angular 22→21. Dev-only
|
Do not run `npm audit fix --force` — it downgrades Angular 22→21. Dev-only
|
||||||
advisories are pinned via `package.json` `overrides`; the shipped bundle audits clean.
|
advisories are pinned via `package.json` `overrides`. Angular is pinned to the exact
|
||||||
|
version 22.0.5: 22.1.x emits `var(--%NS%name)` and breaks every `--rhc-*` token, so the
|
||||||
|
two moderate advisories it fixes stay open. Neither is reachable, so the audit gate runs
|
||||||
|
at `--audit-level=high` (see the comment in `ci.yml`).
|
||||||
|
|
||||||
## Model routing for agent delegation
|
## Model routing for agent delegation
|
||||||
|
|
||||||
@@ -72,7 +76,7 @@ so this doesn't depend on a human remembering to run `/model` at the right momen
|
|||||||
`git status`/`grep`, verifying a file exists. No Edit/Write access.
|
`git status`/`grep`, verifying a file exists. No Edit/Write access.
|
||||||
|
|
||||||
Delegate to the matching agent only when the _current_ session isn't already on that
|
Delegate to the matching agent only when the _current_ session isn't already on that
|
||||||
model — don't add indirection for its own sake. `docs/project/backlog/README.md`'s
|
model — don't add indirection for its own sake. `docs/project/archive/backlog/README.md`'s
|
||||||
session protocol is the worked example of this in practice.
|
session protocol is the worked example of this in practice.
|
||||||
|
|
||||||
## The decisions (non-negotiable working agreements)
|
## The decisions (non-negotiable working agreements)
|
||||||
@@ -81,10 +85,11 @@ session protocol is the worked example of this in practice.
|
|||||||
|
|
||||||
`apps/<app>/src/app/<context>/<layer>/` for an app-local context; `libs/<lib>/src/<layer>/`
|
`apps/<app>/src/app/<context>/<layer>/` for an app-local context; `libs/<lib>/src/<layer>/`
|
||||||
for a cross-app library (WP-67). Two apps today: `apps/ssp` (Zorgverlener self-service —
|
for a cross-app library (WP-67). Two apps today: `apps/ssp` (Zorgverlener self-service —
|
||||||
contexts `auth`, `registratie`, `herregistratie`, `brief` (letter-composition teaching
|
contexts `auth`, `overzicht` (the portal home; composes `registratie`'s dashboard sections
|
||||||
slice), `showcase` (teaching page, not a feature; **sanctioned** to read every context in
|
plus its own cross-context nav sections), `registratie`, `herregistratie`, `brief`
|
||||||
its own app — nothing imports it)) and `apps/behandelportal` (Behandelaar backoffice,
|
(letter-composition teaching slice), `showcase` (teaching page, not a feature; **sanctioned**
|
||||||
ADR-0002 — contexts `auth`, `behandeling`). Two cross-app libraries: `libs/shared` (the
|
to read every context in its own app — nothing imports it)) and `apps/behandelportal`
|
||||||
|
(Behandelaar backoffice, ADR-0002 — contexts `auth`, `behandeling`). Two cross-app libraries: `libs/shared` (the
|
||||||
design system + kernel + generated API client — no business logic) and `libs/beheer` (the
|
design system + kernel + generated API client — no business logic) and `libs/beheer` (the
|
||||||
admin/stamdata context, identical for both apps today — WP-67 folded a silently-diverging
|
admin/stamdata context, identical for both apps today — WP-67 folded a silently-diverging
|
||||||
duplicate copy back into one). `auth` is deliberately **not** shared even though today it's
|
duplicate copy back into one). `auth` is deliberately **not** shared even though today it's
|
||||||
@@ -106,10 +111,11 @@ depend on `libs/beheer` either — it stays the base). `ui`/`layout` never impor
|
|||||||
lint-enforced (per app, since each app is cruised against its own tsconfig — WP-67's
|
lint-enforced (per app, since each app is cruised against its own tsconfig — WP-67's
|
||||||
`.dependency-cruiser.base.js` + one thin `.dependency-cruiser.<app>.js` per app). An app
|
`.dependency-cruiser.base.js` + one thin `.dependency-cruiser.<app>.js` per app). An app
|
||||||
may not import the other app's source directly. Cross-context only
|
may not import the other app's source directly. Cross-context only
|
||||||
`herregistratie → registratie → libs/shared|beheer`, `auth → libs/shared|beheer`,
|
`overzicht → registratie → libs/shared|beheer`, `herregistratie → registratie →
|
||||||
`brief → libs/shared|beheer` (ssp); `behandeling → libs/shared|beheer`, `auth →
|
libs/shared|beheer`, `auth → libs/shared|beheer`, `brief → libs/shared|beheer` (ssp);
|
||||||
libs/shared|beheer` (behandelportal). Imports use aliases as direction statements:
|
`behandeling → libs/shared|beheer`, `auth → libs/shared|beheer` (behandelportal). Imports use
|
||||||
`@shared/* @beheer/* @auth/* @registratie/* @herregistratie/* @brief/*` (ssp) —
|
aliases as direction statements:
|
||||||
|
`@shared/* @beheer/* @auth/* @overzicht/* @registratie/* @herregistratie/* @brief/*` (ssp) —
|
||||||
`@shared/* @beheer/* @auth/* @behandeling/*` (behandelportal); each app's own
|
`@shared/* @beheer/* @auth/* @behandeling/*` (behandelportal); each app's own
|
||||||
`tsconfig.json` declares its full map (the root `tsconfig.json` intentionally has no
|
`tsconfig.json` declares its full map (the root `tsconfig.json` intentionally has no
|
||||||
`paths` — see its comment). `domain/` imports nothing from Angular.
|
`paths` — see its comment). `domain/` imports nothing from Angular.
|
||||||
@@ -129,6 +135,18 @@ the design system does the visuals. (Where CIBG lacks a class — e.g. `skeleton
|
|||||||
`// CIBG-GAP EXTENSION:` marker; see ADR-0003. `alert` is **not** such a case: it wraps the
|
`// CIBG-GAP EXTENSION:` marker; see ADR-0003. `alert` is **not** such a case: it wraps the
|
||||||
vendored `.feedback feedback-*` classes.)
|
vendored `.feedback feedback-*` classes.)
|
||||||
|
|
||||||
|
**The step-component contract.** A wizard step follows the same rule as
|
||||||
|
`address-fields.component.ts`: values in, events out, no internal state. Three clauses:
|
||||||
|
|
||||||
|
1. **Inputs down.** A step reads its data only from `input()`s the container passes it.
|
||||||
|
2. **One narrow output up.** A step emits one specific event, not the container's whole
|
||||||
|
`dispatch`.
|
||||||
|
3. **`dispatch` is never passed down.** The container owns the Model and decides what a
|
||||||
|
step's event means; a step never calls `dispatch` itself.
|
||||||
|
|
||||||
|
Corollary: a step gets **no** story of its own. The wizard's own story already mounts every
|
||||||
|
step, because it seeds the machine.
|
||||||
|
|
||||||
### 3. State: make illegal states unrepresentable
|
### 3. State: make illegal states unrepresentable
|
||||||
|
|
||||||
Default reflex — **if you're about to add a second/third boolean to track state,
|
Default reflex — **if you're about to add a second/third boolean to track state,
|
||||||
@@ -136,7 +154,7 @@ model a discriminated union instead.** Three tools, all in `libs/shared/src/appl
|
|||||||
|
|
||||||
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`.
|
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`.
|
||||||
Combine sources with `map`/`map2`/`andThen` (Failure > Loading > Success).
|
Combine sources with `map`/`map2`/`andThen` (Failure > Loading > Success).
|
||||||
Render it via the `<app-async>` molecule (`libs/shared/src/ui/async`) — one of four
|
Render it via the `<app-async>` molecule (`libs/shared/src/ui/molecules/async`) — one of four
|
||||||
templates, mutually exclusive by construction. Default loading spinner/skeleton
|
templates, mutually exclusive by construction. Default loading spinner/skeleton
|
||||||
is delay-gated (~250ms) so fast connections don't flash.
|
is delay-gated (~250ms) so fast connections don't flash.
|
||||||
- **Elm-style store** (`store.ts` → `createStore(initial, reduce)`) — all state in
|
- **Elm-style store** (`store.ts` → `createStore(initial, reduce)`) — all state in
|
||||||
@@ -210,10 +228,10 @@ each app has its **own Storybook instance** (`.storybook-ssp/`, `.storybook-beha
|
|||||||
WP-67 — a single merged tsconfig can't resolve both apps' `@auth/*` at once), each globbing
|
WP-67 — a single merged tsconfig can't resolve both apps' `@auth/*` at once), each globbing
|
||||||
its own app's stories plus both shared libraries'. **Story titles mirror the sidebar's
|
its own app's stories plus both shared libraries'. **Story titles mirror the sidebar's
|
||||||
Design System/Domein split** (see `libs/shared/docs/layers.mdx`): a `libs/shared/ui|layout`
|
Design System/Domein split** (see `libs/shared/docs/layers.mdx`): a `libs/shared/ui|layout`
|
||||||
or `libs/beheer/ui` component is titled `Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`;
|
component is titled `Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`;
|
||||||
a component in an app context's `ui/` is titled `Domein/<Context>/<Name>` — full stop,
|
a component in an app context's `ui/`, or in `libs/beheer/ui`, is titled
|
||||||
regardless of which atomic layer it is (a context organism doesn't get its own
|
`Domein/<Context>/<Name>` — full stop, regardless of which atomic layer it is (a context
|
||||||
`Organisms/` bucket).
|
organism doesn't get its own `Organisms/` bucket).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
@@ -242,7 +260,7 @@ regardless of which atomic layer it is (a context organism doesn't get its own
|
|||||||
(`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a
|
(`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a
|
||||||
translation file, not a code change (the seam). Shared/English components must **not**
|
translation file, not a code change (the seam). Shared/English components must **not**
|
||||||
hardcode Dutch — expose copy as `input()`s with localizable defaults; the domain caller
|
hardcode Dutch — expose copy as `input()`s with localizable defaults; the domain caller
|
||||||
supplies the text (see `libs/shared/src/ui/async`). Format-validation messages in
|
supplies the text (see `libs/shared/src/ui/molecules/async`). Format-validation messages in
|
||||||
`domain/value-objects/` stay co-located but are still `$localize`-wrapped.
|
`domain/value-objects/` stay co-located but are still `$localize`-wrapped.
|
||||||
- **Forms = one idiom.** Any form with validation or submission uses a `*.machine.ts`
|
- **Forms = one idiom.** Any form with validation or submission uses a `*.machine.ts`
|
||||||
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
|
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
|
||||||
@@ -272,7 +290,10 @@ regardless of which atomic layer it is (a context organism doesn't get its own
|
|||||||
- Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`,
|
- Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`,
|
||||||
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
|
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
|
||||||
- **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`, scoped to
|
- **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`, scoped to
|
||||||
`{apps,libs}/**`) fails the build on `any`; `npm run dep:check`
|
`{apps,libs}/**`) fails the build on `any`; the same config's `max-lines` rule caps every
|
||||||
|
`{apps,libs}/**/*.{page,component,section,step}.ts` file at 250 lines
|
||||||
|
(`skipBlankLines: true`, `skipComments: true`). 250 is reachable, not a style-guide
|
||||||
|
default — the dashboard page lands at 42 lines. `npm run dep:check`
|
||||||
(`.dependency-cruiser.base.js` + one `.dependency-cruiser.<app>.js` per app, WP-67) fails
|
(`.dependency-cruiser.base.js` + one `.dependency-cruiser.<app>.js` per app, WP-67) fails
|
||||||
on illegal imports — `domain/` importing Angular, a context importing "upward" (the
|
on illegal imports — `domain/` importing Angular, a context importing "upward" (the
|
||||||
`herregistratie → registratie → shared`, `auth → shared` direction), an app importing the
|
`herregistratie → registratie → shared`, `auth → shared` direction), an app importing the
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ Admin pages (`/beheer/*`, `/brief/huisstijl`) need the `admin` role — see
|
|||||||
| know _why_ a decision was made | [the ADRs](docs/reference/architecture/) — BFF-lite, contexts, huisstijl, stamdata, ZGW, test data |
|
| know _why_ a decision was made | [the ADRs](docs/reference/architecture/) — BFF-lite, contexts, huisstijl, stamdata, ZGW, test data |
|
||||||
| work on the backend / BFF | [backend/README.md](backend/README.md) |
|
| work on the backend / BFF | [backend/README.md](backend/README.md) |
|
||||||
| run OpenZaak locally | [backend/openzaak/README.md](backend/openzaak/README.md) |
|
| run OpenZaak locally | [backend/openzaak/README.md](backend/openzaak/README.md) |
|
||||||
| see what shipped, or pick up work | [docs/project/backlog/README.md](docs/project/backlog/README.md) |
|
| see what shipped, or pick up work | [docs/project/archive/backlog/README.md](docs/project/archive/backlog/README.md) |
|
||||||
| build a feature the house way | [`.claude/skills/`](.claude/skills/) — invocable recipes (`new-feature`, `form-machine`, …) |
|
| build a feature the house way | [`.claude/skills/`](.claude/skills/) — invocable recipes (`new-feature`, `form-machine`, …) |
|
||||||
| know the import rules | [dependencies.md](docs/reference/architecture/dependencies.md) — enforced by `dep:check` |
|
| know the import rules | [dependencies.md](docs/reference/architecture/dependencies.md) — enforced by `dep:check` |
|
||||||
| work on this repo as an AI agent | [CLAUDE.md](CLAUDE.md) |
|
| work on this repo as an AI agent | [CLAUDE.md](CLAUDE.md) |
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# ponytail: a thin façade over the npm scripts + dotnet + docker, NOT a second copy of
|
||||||
|
# package.json. `task` (no args) lists everything runnable; the logic stays where it is —
|
||||||
|
# `task ci` shells to scripts/ci-local.sh, which remains the gate. npm keeps working
|
||||||
|
# exactly as before, and CI does not depend on `task` being installed.
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
default:
|
||||||
|
silent: true
|
||||||
|
cmd: task --list
|
||||||
|
|
||||||
|
deps:
|
||||||
|
internal: true
|
||||||
|
status:
|
||||||
|
- test -d node_modules
|
||||||
|
cmd: npm ci
|
||||||
|
|
||||||
|
dev:
|
||||||
|
desc: Serve the SSP on :4200 (proxies /api to the backend)
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm start
|
||||||
|
|
||||||
|
dev:bp:
|
||||||
|
desc: Serve the behandelportal on :4201
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm run start:behandelportal
|
||||||
|
|
||||||
|
api:
|
||||||
|
desc: Run the backend on :5000 (Swagger at /swagger)
|
||||||
|
dir: backend
|
||||||
|
cmd: dotnet run --project src/BigRegister.Api
|
||||||
|
|
||||||
|
test:
|
||||||
|
desc: Run the frontend tests (both apps + both shared libraries)
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm test
|
||||||
|
|
||||||
|
test:api:
|
||||||
|
desc: Run the backend rule + endpoint tests (skips the OpenZaak integration tests)
|
||||||
|
dir: backend
|
||||||
|
# Same filter as scripts/ci-local.sh: the Integration tests need a running OpenZaak
|
||||||
|
# container (scripts/openzaak-ui-up.sh), so they are not part of the default gate.
|
||||||
|
cmd: dotnet test BigRegister.slnx --filter "Category!=Integration"
|
||||||
|
|
||||||
|
lint:
|
||||||
|
desc: Lint the frontend (no `any`, import/layer boundaries)
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm run lint
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
desc: Format the frontend with prettier and the backend with dotnet format
|
||||||
|
deps: [deps]
|
||||||
|
cmds:
|
||||||
|
- npm run format
|
||||||
|
- dotnet format backend/BigRegister.slnx
|
||||||
|
|
||||||
|
build:
|
||||||
|
desc: Build both Angular apps
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm run build
|
||||||
|
|
||||||
|
ci:
|
||||||
|
desc: Run the CI gate locally before pushing (scripts/ci-local.sh)
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm run ci
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
desc: Run the Playwright end-to-end tests (starts its own servers)
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm run e2e
|
||||||
|
|
||||||
|
sb:
|
||||||
|
desc: Run the SSP Storybook instance
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm run storybook
|
||||||
|
|
||||||
|
sb:bp:
|
||||||
|
desc: Run the behandelportal Storybook instance
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm run storybook:behandelportal
|
||||||
|
|
||||||
|
gen:api:
|
||||||
|
desc: Regenerate the typed API client from the backend OpenAPI document
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm run gen:api
|
||||||
|
|
||||||
|
up:
|
||||||
|
desc: Start both apps + the backend with docker compose
|
||||||
|
cmd: docker compose up
|
||||||
|
|
||||||
|
down:
|
||||||
|
desc: Stop the docker compose stack
|
||||||
|
cmd: docker compose down
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Component, output } from '@angular/core';
|
import { Component, output } from '@angular/core';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Organism: employee-SSO-style mock login (ADR-C-004). No real auth — and,
|
* Organism: employee-SSO-style mock login (ADR-C-004). No real auth — and,
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { Component, computed, inject } from '@angular/core';
|
|||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { successOf } from '@shared/application/remote-data';
|
import { successOf } from '@shared/application/remote-data';
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
|
||||||
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
|
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
|
||||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||||
import { BeoordelingStore } from '@behandeling/application/beoordeling.store';
|
import { BeoordelingStore } from '@behandeling/application/beoordeling.store';
|
||||||
import { detailRows } from '@behandeling/domain/beoordeling-view';
|
import { detailRows } from '@behandeling/domain/beoordeling-view';
|
||||||
import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component';
|
import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component';
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { Component, computed, input, output } from '@angular/core';
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
|
||||||
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
|
import {
|
||||||
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
|
RadioGroupComponent,
|
||||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
RadioOption,
|
||||||
|
} from '@shared/ui/atoms/radio-group/radio-group.component';
|
||||||
|
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
|
||||||
|
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
import { whenTag } from '@shared/kernel/fp';
|
import { whenTag } from '@shared/kernel/fp';
|
||||||
import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine';
|
import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine';
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import { Component, input } from '@angular/core';
|
import { Component, input } from '@angular/core';
|
||||||
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
|
import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
|
||||||
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
|
import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
|
||||||
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
|
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
|
||||||
import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view';
|
import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view';
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Component, computed, effect, inject } from '@angular/core';
|
import { Component, computed, effect, inject } from '@angular/core';
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||||
import { AccessStore } from '@shared/application/access.store';
|
import { AccessStore } from '@shared/application/access.store';
|
||||||
import { successOr } from '@shared/application/remote-data';
|
import { successOr } from '@shared/application/remote-data';
|
||||||
import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store';
|
import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Component, output } from '@angular/core';
|
import { Component, output } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
|
|
||||||
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
|
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
|
||||||
@Component({
|
@Component({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, signal } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
|
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
|
||||||
import { SessionStore } from '@auth/application/session.store';
|
import { SessionStore } from '@auth/application/session.store';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Brief } from './brief';
|
||||||
|
|
||||||
|
/** A minimal read-only sample letter, so the admin sees the org identity in context
|
||||||
|
while editing (content itself is not the admin's to change). Production content —
|
||||||
|
the letter the org-template editor previews — not a test fixture, so it lives here
|
||||||
|
rather than in `brief.testing.ts` (dependency-cruiser's no-testing-in-production
|
||||||
|
rule forbids production code from reaching any `*.testing.ts`). */
|
||||||
|
export const SAMPLE_LETTER_BRIEF: Brief = {
|
||||||
|
briefId: 'VOORBEELD-0001',
|
||||||
|
beroep: 'arts',
|
||||||
|
templateId: 'sample',
|
||||||
|
drafterId: 'sample',
|
||||||
|
status: { tag: 'draft' },
|
||||||
|
placeholders: [
|
||||||
|
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||||
|
{ key: 'datum', label: 'Datum', autoResolvable: true },
|
||||||
|
],
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
sectionKey: 'body',
|
||||||
|
title: 'Voorbeeldinhoud',
|
||||||
|
required: true,
|
||||||
|
locked: true,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: 'freeText',
|
||||||
|
blockId: 'sample-1',
|
||||||
|
content: {
|
||||||
|
paragraphs: [
|
||||||
|
{
|
||||||
|
nodes: [
|
||||||
|
{ type: 'text', text: 'Geachte ' },
|
||||||
|
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||||
|
{ type: 'text', text: ',' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core';
|
import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core';
|
||||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
|
import { MaskedValueComponent } from '@shared/ui/atoms/masked-value/masked-value.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
|
import { StepperComponent } from '@shared/ui/molecules/stepper/stepper.component';
|
||||||
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
|
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
|
||||||
import { besluitGuidance, inferSelection } from '@brief/domain/besluit';
|
import { besluitGuidance, inferSelection } from '@brief/domain/besluit';
|
||||||
import { Diagnostic } from '@brief/domain/placeholders';
|
import { Diagnostic } from '@brief/domain/placeholders';
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Component, computed, input, linkedSignal, output } from '@angular/core';
|
import { Component, computed, input, linkedSignal, output } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
|
import { CheckboxComponent } from '@shared/ui/atoms/checkbox/checkbox.component';
|
||||||
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
|
import {
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
RadioGroupComponent,
|
||||||
|
RadioOption,
|
||||||
|
} from '@shared/ui/atoms/radio-group/radio-group.component';
|
||||||
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
import { Besluit, LibraryPassage } from '@brief/domain/brief';
|
import { Besluit, LibraryPassage } from '@brief/domain/brief';
|
||||||
import { redenenFor } from '@brief/domain/besluit';
|
import { redenenFor } from '@brief/domain/besluit';
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||||
import { BriefStore } from '@brief/application/brief.store';
|
import { BriefStore } from '@brief/application/brief.store';
|
||||||
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
|
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
|
||||||
import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-scherm.component';
|
import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-scherm.component';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Component, computed, input, output } from '@angular/core';
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { Diagnostic } from '@brief/domain/placeholders';
|
import { Diagnostic } from '@brief/domain/placeholders';
|
||||||
|
|
||||||
/** Molecule: lists all letter diagnostics grouped by severity. Errors block
|
/** Molecule: lists all letter diagnostics grouped by severity. Errors block
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { RichTextBlock } from '@shared/kernel/rich-text';
|
|||||||
import {
|
import {
|
||||||
RichTextEditorComponent,
|
RichTextEditorComponent,
|
||||||
PlaceholderOption,
|
PlaceholderOption,
|
||||||
} from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
} from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
import { LetterBlock } from '@brief/domain/brief';
|
import { LetterBlock } from '@brief/domain/brief';
|
||||||
|
|
||||||
/** Molecule: one block in a section — its editor plus provenance + block controls.
|
/** Molecule: one block in a section — its editor plus provenance + block controls.
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
/* eslint-disable max-lines */ // 77 lines are CSS, the rest is one letter — RD-26 rewrites this reason, keeps the disable
|
/* eslint-disable max-lines */ // 77 lines of CSS + one letter's markup; splitting it into
|
||||||
|
// letterhead/body/signature/footer makes "what does the letter look like" a five-file
|
||||||
|
// question for no behavioural seam. Deliberate, not deferred.
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
@@ -12,9 +14,7 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgTemplateOutlet } from '@angular/common';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
|
||||||
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
|
||||||
import { formatDatumNl } from '@shared/kernel/datum';
|
import { formatDatumNl } from '@shared/kernel/datum';
|
||||||
import { Paragraph } from '@shared/kernel/rich-text';
|
import { Paragraph } from '@shared/kernel/rich-text';
|
||||||
import { Brief, LetterBlock } from '@brief/domain/brief';
|
import { Brief, LetterBlock } from '@brief/domain/brief';
|
||||||
@@ -22,6 +22,7 @@ import { OrgTemplate } from '@brief/domain/org-template';
|
|||||||
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
||||||
import { Diagnostic } from '@brief/domain/placeholders';
|
import { Diagnostic } from '@brief/domain/placeholders';
|
||||||
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
||||||
|
import { LetterLineComponent } from './letter-line.component';
|
||||||
|
|
||||||
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
|
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
|
||||||
type PreviewSegment = {
|
type PreviewSegment = {
|
||||||
@@ -40,12 +41,6 @@ function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
|
|
||||||
const SAMPLE_VALUES: Record<string, string> = {
|
|
||||||
naam_zorgverlener: 'J. Jansen',
|
|
||||||
big_nummer: '12345678901',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */
|
/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */
|
||||||
const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||||
|
|
||||||
@@ -55,10 +50,12 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
renders everything read-only (approver/locked, absorbs the old letter-preview),
|
renders everything read-only (approver/locked, absorbs the old letter-preview),
|
||||||
`'template'` reserves the org-identity regions for the admin editor.
|
`'template'` reserves the org-identity regions for the admin editor.
|
||||||
Letter typography/geometry come from the shared `public/letter.css` contract —
|
Letter typography/geometry come from the shared `public/letter.css` contract —
|
||||||
the same file the backend preview renderer inlines. */
|
the same file the backend preview renderer inlines. Each rendered line is its own
|
||||||
|
`app-letter-line` (RD-26), replacing the outlet-template indirection that stood
|
||||||
|
in for it. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-letter-canvas',
|
selector: 'app-letter-canvas',
|
||||||
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent],
|
imports: [ButtonComponent, LetterLineComponent],
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
:host {
|
:host {
|
||||||
@@ -137,37 +134,19 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
`,
|
`,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<ng-template #line let-nodes>
|
|
||||||
@for (node of nodes; track $index) {
|
|
||||||
@switch (node.type) {
|
|
||||||
@case ('text') {
|
|
||||||
<span>{{ node.text }}</span>
|
|
||||||
}
|
|
||||||
@case ('lineBreak') {
|
|
||||||
<br />
|
|
||||||
}
|
|
||||||
@case ('placeholder') {
|
|
||||||
@if (showSample() && autoFor(node.key)) {
|
|
||||||
<span>{{ sampleFor(node.key) }}</span>
|
|
||||||
} @else {
|
|
||||||
<app-placeholder-chip
|
|
||||||
[label]="labelFor(node.key)"
|
|
||||||
[autoResolvable]="autoFor(node.key)"
|
|
||||||
[state]="stateFor(node.key)"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</ng-template>
|
|
||||||
|
|
||||||
@if (editableRegions() !== 'template') {
|
@if (editableRegions() !== 'template') {
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="zoom" role="group" [attr.aria-label]="zoomGroupLabel()">
|
<div
|
||||||
|
class="zoom"
|
||||||
|
role="group"
|
||||||
|
aria-label="Zoomniveau"
|
||||||
|
i18n-aria-label="@@brief.canvas.zoom"
|
||||||
|
>
|
||||||
<app-button
|
<app-button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
[disabled]="zoomLevel() <= 0.5"
|
[disabled]="zoomLevel() <= 0.5"
|
||||||
[attr.aria-label]="zoomOutLabel()"
|
aria-label="Uitzoomen"
|
||||||
|
i18n-aria-label="@@brief.canvas.zoomOut"
|
||||||
(click)="zoomBy(-0.1)"
|
(click)="zoomBy(-0.1)"
|
||||||
>−</app-button
|
>−</app-button
|
||||||
>
|
>
|
||||||
@@ -175,13 +154,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
<app-button
|
<app-button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
[disabled]="zoomLevel() >= 1.5"
|
[disabled]="zoomLevel() >= 1.5"
|
||||||
[attr.aria-label]="zoomInLabel()"
|
aria-label="Inzoomen"
|
||||||
|
i18n-aria-label="@@brief.canvas.zoomIn"
|
||||||
(click)="zoomBy(0.1)"
|
(click)="zoomBy(0.1)"
|
||||||
>+</app-button
|
>+</app-button
|
||||||
>
|
>
|
||||||
<app-button variant="subtle" (click)="zoomLevel.set(1)">{{
|
<app-button variant="subtle" (click)="zoomLevel.set(1)" i18n="@@brief.canvas.zoomReset"
|
||||||
zoomResetLabel()
|
>100%</app-button
|
||||||
}}</app-button>
|
>
|
||||||
</div>
|
</div>
|
||||||
@if (editableRegions() === 'none') {
|
@if (editableRegions() === 'none') {
|
||||||
<app-button
|
<app-button
|
||||||
@@ -189,7 +169,13 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
(click)="showSample.set(!showSample())"
|
(click)="showSample.set(!showSample())"
|
||||||
[attr.aria-pressed]="showSample()"
|
[attr.aria-pressed]="showSample()"
|
||||||
>
|
>
|
||||||
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
|
@if (showSample()) {
|
||||||
|
<ng-container i18n="@@brief.preview.hideSample">Testwaarden verbergen</ng-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container i18n="@@brief.preview.showSample"
|
||||||
|
>Voorbeeld met testwaarden</ng-container
|
||||||
|
>
|
||||||
|
}
|
||||||
</app-button>
|
</app-button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -201,20 +187,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
(robijn footer background) — the letter surface must stay letter.css-only. -->
|
(robijn footer background) — the letter surface must stay letter.css-only. -->
|
||||||
<div class="letter__letterhead">
|
<div class="letter__letterhead">
|
||||||
@if (logoUrl()) {
|
@if (logoUrl()) {
|
||||||
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
|
<img
|
||||||
|
class="org-logo"
|
||||||
|
[src]="logoUrl()"
|
||||||
|
alt="Logo van de organisatie"
|
||||||
|
i18n-alt="@@brief.canvas.logoAlt"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
@if (editing()) {
|
@if (editing()) {
|
||||||
<input
|
<input
|
||||||
class="tmpl-input org-wordmark"
|
class="tmpl-input org-wordmark"
|
||||||
[value]="orgTemplate().orgName"
|
[value]="orgTemplate().orgName"
|
||||||
[attr.aria-label]="orgNameLabel()"
|
aria-label="Organisatienaam"
|
||||||
|
i18n-aria-label="@@brief.canvas.orgName"
|
||||||
(input)="emitEdit('orgName', $event)"
|
(input)="emitEdit('orgName', $event)"
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
class="tmpl-textarea return-address"
|
class="tmpl-textarea return-address"
|
||||||
rows="2"
|
rows="2"
|
||||||
[value]="orgTemplate().returnAddress"
|
[value]="orgTemplate().returnAddress"
|
||||||
[attr.aria-label]="returnAddressLabel()"
|
aria-label="Retouradres"
|
||||||
|
i18n-aria-label="@@brief.canvas.returnAddress"
|
||||||
(input)="emitEdit('returnAddress', $event)"
|
(input)="emitEdit('returnAddress', $event)"
|
||||||
></textarea>
|
></textarea>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -224,11 +217,11 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
<address class="address-window">{{ recipientText() }}</address>
|
<address class="address-window">{{ recipientText() }}</address>
|
||||||
<dl class="reference">
|
<dl class="reference">
|
||||||
<div>
|
<div>
|
||||||
<dt>{{ referenceLabel() }}</dt>
|
<dt i18n="@@brief.canvas.reference">Ons kenmerk</dt>
|
||||||
<dd>{{ brief().briefId }}</dd>
|
<dd>{{ brief().briefId }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>{{ dateLabel() }}</dt>
|
<dt i18n="@@brief.canvas.date">Datum</dt>
|
||||||
<dd>{{ letterDate }}</dd>
|
<dd>{{ letterDate }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -242,18 +235,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
|
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
|
||||||
<div class="diff-block" [class.diff-changed]="!!diffKind">
|
<div class="diff-block" [class.diff-changed]="!!diffKind">
|
||||||
@if (diffKind) {
|
@if (diffKind) {
|
||||||
<span class="diff-badge" [class.added]="diffKind === 'added'">{{
|
<span class="diff-badge" [class.added]="diffKind === 'added'">
|
||||||
diffLabel(diffKind)
|
@if (diffKind === 'added') {
|
||||||
}}</span>
|
<ng-container i18n="@@brief.diff.added">nieuw</ng-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container i18n="@@brief.diff.changed"
|
||||||
|
>gewijzigd sinds afwijzing</ng-container
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
@for (seg of segmentsOf(block); track $index) {
|
@for (seg of segmentsOf(block); track $index) {
|
||||||
@if (seg.list === 'bullet') {
|
@if (seg.list === 'bullet') {
|
||||||
<ul>
|
<ul>
|
||||||
@for (para of seg.items; track $index) {
|
@for (para of seg.items; track $index) {
|
||||||
<li>
|
<li>
|
||||||
<ng-container
|
<app-letter-line
|
||||||
[ngTemplateOutlet]="line"
|
[nodes]="para.nodes"
|
||||||
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
|
[showSample]="showSample()"
|
||||||
|
[placeholders]="brief().placeholders"
|
||||||
|
[diagnostics]="diagnostics()"
|
||||||
|
[sampleDate]="letterDate"
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -262,18 +264,24 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
<ol>
|
<ol>
|
||||||
@for (para of seg.items; track $index) {
|
@for (para of seg.items; track $index) {
|
||||||
<li>
|
<li>
|
||||||
<ng-container
|
<app-letter-line
|
||||||
[ngTemplateOutlet]="line"
|
[nodes]="para.nodes"
|
||||||
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
|
[showSample]="showSample()"
|
||||||
|
[placeholders]="brief().placeholders"
|
||||||
|
[diagnostics]="diagnostics()"
|
||||||
|
[sampleDate]="letterDate"
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ol>
|
</ol>
|
||||||
} @else {
|
} @else {
|
||||||
<p>
|
<p>
|
||||||
<ng-container
|
<app-letter-line
|
||||||
[ngTemplateOutlet]="line"
|
[nodes]="seg.items[0].nodes"
|
||||||
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }"
|
[showSample]="showSample()"
|
||||||
|
[placeholders]="brief().placeholders"
|
||||||
|
[diagnostics]="diagnostics()"
|
||||||
|
[sampleDate]="letterDate"
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
@@ -289,19 +297,22 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
<input
|
<input
|
||||||
class="tmpl-input"
|
class="tmpl-input"
|
||||||
[value]="orgTemplate().signatureClosing"
|
[value]="orgTemplate().signatureClosing"
|
||||||
[attr.aria-label]="signatureClosingLabel()"
|
aria-label="Afsluiting"
|
||||||
|
i18n-aria-label="@@brief.canvas.signatureClosing"
|
||||||
(input)="emitEdit('signatureClosing', $event)"
|
(input)="emitEdit('signatureClosing', $event)"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
class="tmpl-input signature-name"
|
class="tmpl-input signature-name"
|
||||||
[value]="orgTemplate().signatureName"
|
[value]="orgTemplate().signatureName"
|
||||||
[attr.aria-label]="signatureNameLabel()"
|
aria-label="Naam ondertekenaar"
|
||||||
|
i18n-aria-label="@@brief.canvas.signatureName"
|
||||||
(input)="emitEdit('signatureName', $event)"
|
(input)="emitEdit('signatureName', $event)"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
class="tmpl-input"
|
class="tmpl-input"
|
||||||
[value]="orgTemplate().signatureRole"
|
[value]="orgTemplate().signatureRole"
|
||||||
[attr.aria-label]="signatureRoleLabel()"
|
aria-label="Functie ondertekenaar"
|
||||||
|
i18n-aria-label="@@brief.canvas.signatureRole"
|
||||||
(input)="emitEdit('signatureRole', $event)"
|
(input)="emitEdit('signatureRole', $event)"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -317,13 +328,15 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
class="tmpl-textarea footer-contact"
|
class="tmpl-textarea footer-contact"
|
||||||
rows="2"
|
rows="2"
|
||||||
[value]="orgTemplate().footerContact"
|
[value]="orgTemplate().footerContact"
|
||||||
[attr.aria-label]="footerContactLabel()"
|
aria-label="Contactgegevens (voettekst)"
|
||||||
|
i18n-aria-label="@@brief.canvas.footerContact"
|
||||||
(input)="emitEdit('footerContact', $event)"
|
(input)="emitEdit('footerContact', $event)"
|
||||||
></textarea>
|
></textarea>
|
||||||
<input
|
<input
|
||||||
class="tmpl-input footer-legal"
|
class="tmpl-input footer-legal"
|
||||||
[value]="orgTemplate().footerLegal"
|
[value]="orgTemplate().footerLegal"
|
||||||
[attr.aria-label]="footerLegalLabel()"
|
aria-label="Juridische voettekst"
|
||||||
|
i18n-aria-label="@@brief.canvas.footerLegal"
|
||||||
(input)="emitEdit('footerLegal', $event)"
|
(input)="emitEdit('footerLegal', $event)"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -334,7 +347,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
|
|
||||||
@for (top of pageBreaks(); track $index) {
|
@for (top of pageBreaks(); track $index) {
|
||||||
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
|
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
|
||||||
<span>{{ pageBreakCaption() }}</span>
|
<span i18n="@@brief.canvas.pageBreak">±pagina-einde — afdrukvoorbeeld is leidend</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -360,30 +373,13 @@ export class LetterCanvasComponent {
|
|||||||
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
|
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
|
||||||
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
|
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
|
||||||
|
|
||||||
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
|
/** The one label kept as an `input()` rather than inlined `i18n` (RD-26, decision 2):
|
||||||
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
its message embeds a literal `\n`. As template text that `\n` becomes a source
|
||||||
pageBreakCaption = input(
|
line break, a different string to Angular's extractor — so inlining it would
|
||||||
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,
|
change the extracted source text, unlike the other 19 labels this ticket inlines. */
|
||||||
);
|
|
||||||
recipientText = input(
|
recipientText = input(
|
||||||
$localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`,
|
$localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`,
|
||||||
);
|
);
|
||||||
referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);
|
|
||||||
dateLabel = input($localize`:@@brief.canvas.date:Datum`);
|
|
||||||
logoAlt = input($localize`:@@brief.canvas.logoAlt:Logo van de organisatie`);
|
|
||||||
orgNameLabel = input($localize`:@@brief.canvas.orgName:Organisatienaam`);
|
|
||||||
returnAddressLabel = input($localize`:@@brief.canvas.returnAddress:Retouradres`);
|
|
||||||
signatureClosingLabel = input($localize`:@@brief.canvas.signatureClosing:Afsluiting`);
|
|
||||||
signatureNameLabel = input($localize`:@@brief.canvas.signatureName:Naam ondertekenaar`);
|
|
||||||
signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);
|
|
||||||
footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);
|
|
||||||
footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);
|
|
||||||
zoomGroupLabel = input($localize`:@@brief.canvas.zoom:Zoomniveau`);
|
|
||||||
zoomInLabel = input($localize`:@@brief.canvas.zoomIn:Inzoomen`);
|
|
||||||
zoomOutLabel = input($localize`:@@brief.canvas.zoomOut:Uitzoomen`);
|
|
||||||
zoomResetLabel = input($localize`:@@brief.canvas.zoomReset:100%`);
|
|
||||||
addedLabel = input($localize`:@@brief.diff.added:nieuw`);
|
|
||||||
changedLabel = input($localize`:@@brief.diff.changed:gewijzigd sinds afwijzing`);
|
|
||||||
|
|
||||||
protected showSample = signal(false);
|
protected showSample = signal(false);
|
||||||
protected letterDate = formatDatumNl(new Date());
|
protected letterDate = formatDatumNl(new Date());
|
||||||
@@ -395,9 +391,6 @@ export class LetterCanvasComponent {
|
|||||||
// clamp 0.5–1.5; round to avoid float drift accumulating on repeated clicks.
|
// clamp 0.5–1.5; round to avoid float drift accumulating on repeated clicks.
|
||||||
this.zoomLevel.update((z) => Math.round(Math.min(1.5, Math.max(0.5, z + delta)) * 10) / 10);
|
this.zoomLevel.update((z) => Math.round(Math.min(1.5, Math.max(0.5, z + delta)) * 10) / 10);
|
||||||
}
|
}
|
||||||
protected diffLabel = (kind: BlockDiffKind) =>
|
|
||||||
kind === 'added' ? this.addedLabel() : this.changedLabel();
|
|
||||||
|
|
||||||
/** Admin edit-in-place: the org-identity regions render as controls. */
|
/** Admin edit-in-place: the org-identity regions render as controls. */
|
||||||
protected editing = computed(() => this.editableRegions() === 'template');
|
protected editing = computed(() => this.editableRegions() === 'template');
|
||||||
|
|
||||||
@@ -418,25 +411,9 @@ export class LetterCanvasComponent {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- read-only rendering helpers (migrated from the superseded letter-preview) ---
|
// --- read-only rendering helper (migrated from the superseded letter-preview) ---
|
||||||
|
|
||||||
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
|
|
||||||
private worst = computed(() => {
|
|
||||||
const m = new Map<string, 'error' | 'warning'>();
|
|
||||||
for (const d of this.diagnostics()) {
|
|
||||||
if (!d.placeholderKey) continue;
|
|
||||||
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
|
|
||||||
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
});
|
|
||||||
|
|
||||||
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
|
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
|
||||||
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.letterDate : this.labelFor(key));
|
|
||||||
|
|
||||||
// --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) ---
|
// --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) ---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Component, computed, input } from '@angular/core';
|
||||||
|
import { PlaceholderChipComponent } from '@shared/ui/atoms/placeholder-chip/placeholder-chip.component';
|
||||||
|
import { RichTextNode } from '@shared/kernel/rich-text';
|
||||||
|
import { Diagnostic, PlaceholderDef } from '@brief/domain/placeholders';
|
||||||
|
|
||||||
|
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
|
||||||
|
const SAMPLE_VALUES: Record<string, string> = {
|
||||||
|
naam_zorgverlener: 'J. Jansen',
|
||||||
|
big_nummer: '12345678901',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `brief().placeholders` keyed by `key` for O(1) lookup from a node. */
|
||||||
|
export function placeholderDefs(
|
||||||
|
placeholders: readonly PlaceholderDef[],
|
||||||
|
): Map<string, PlaceholderDef> {
|
||||||
|
return new Map(placeholders.map((p) => [p.key, p]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The worst (error over warning) diagnostic severity per placeholder key. */
|
||||||
|
export function worstSeverities(
|
||||||
|
diagnostics: readonly Diagnostic[],
|
||||||
|
): Map<string, 'error' | 'warning'> {
|
||||||
|
const m = new Map<string, 'error' | 'warning'>();
|
||||||
|
for (const d of diagnostics) {
|
||||||
|
if (!d.placeholderKey) continue;
|
||||||
|
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
|
||||||
|
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLabel(defs: ReadonlyMap<string, PlaceholderDef>, key: string): string {
|
||||||
|
return defs.get(key)?.label ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAuto(defs: ReadonlyMap<string, PlaceholderDef>, key: string): boolean {
|
||||||
|
return defs.get(key)?.autoResolvable ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveState(
|
||||||
|
worst: ReadonlyMap<string, 'error' | 'warning'>,
|
||||||
|
key: string,
|
||||||
|
): 'ok' | 'warning' | 'error' {
|
||||||
|
return worst.get(key) ?? 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "Voorbeeld" toggle's stand-in for an auto-resolvable placeholder: a canned
|
||||||
|
sample, the caller's sample date for `datum`, or the field's own label. */
|
||||||
|
export function resolveSample(
|
||||||
|
defs: ReadonlyMap<string, PlaceholderDef>,
|
||||||
|
sampleDate: string,
|
||||||
|
key: string,
|
||||||
|
): string {
|
||||||
|
return SAMPLE_VALUES[key] ?? (key === 'datum' ? sampleDate : resolveLabel(defs, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Organism: one rendered line of letter content — text runs, line breaks and
|
||||||
|
placeholder chips (an auto-resolvable one swaps to a sample value when
|
||||||
|
`showSample` is on). Extracted from `letter-canvas` (RD-26): the `#line`
|
||||||
|
template plus the label/auto/state/sample helpers it needs, so the canvas's
|
||||||
|
three `ngTemplateOutlet` incantations become one tag each. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-letter-line',
|
||||||
|
imports: [PlaceholderChipComponent],
|
||||||
|
template: `
|
||||||
|
@for (node of nodes(); track $index) {
|
||||||
|
@switch (node.type) {
|
||||||
|
@case ('text') {
|
||||||
|
<span>{{ node.text }}</span>
|
||||||
|
}
|
||||||
|
@case ('lineBreak') {
|
||||||
|
<br />
|
||||||
|
}
|
||||||
|
@case ('placeholder') {
|
||||||
|
@if (showSample() && autoFor(node.key)) {
|
||||||
|
<span>{{ sampleFor(node.key) }}</span>
|
||||||
|
} @else {
|
||||||
|
<app-placeholder-chip
|
||||||
|
[label]="labelFor(node.key)"
|
||||||
|
[autoResolvable]="autoFor(node.key)"
|
||||||
|
[state]="stateFor(node.key)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class LetterLineComponent {
|
||||||
|
nodes = input.required<readonly RichTextNode[]>();
|
||||||
|
showSample = input(false);
|
||||||
|
placeholders = input<readonly PlaceholderDef[]>([]);
|
||||||
|
diagnostics = input<readonly Diagnostic[]>([]);
|
||||||
|
/** The canvas's `formatDatumNl(new Date())`, passed down so every line agrees. */
|
||||||
|
sampleDate = input('');
|
||||||
|
|
||||||
|
private defs = computed(() => placeholderDefs(this.placeholders()));
|
||||||
|
private worst = computed(() => worstSeverities(this.diagnostics()));
|
||||||
|
|
||||||
|
protected labelFor = (key: string) => resolveLabel(this.defs(), key);
|
||||||
|
protected autoFor = (key: string) => resolveAuto(this.defs(), key);
|
||||||
|
protected stateFor = (key: string) => resolveState(this.worst(), key);
|
||||||
|
protected sampleFor = (key: string) => resolveSample(this.defs(), this.sampleDate(), key);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { PlaceholderDef, Diagnostic } from '@brief/domain/placeholders';
|
||||||
|
import {
|
||||||
|
placeholderDefs,
|
||||||
|
resolveAuto,
|
||||||
|
resolveLabel,
|
||||||
|
resolveSample,
|
||||||
|
resolveState,
|
||||||
|
worstSeverities,
|
||||||
|
} from './letter-line.component';
|
||||||
|
|
||||||
|
const DEFS: readonly PlaceholderDef[] = [
|
||||||
|
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||||
|
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOCATION = { blockId: 'b1', paragraphIndex: 0, nodeIndex: 0 };
|
||||||
|
|
||||||
|
function diagnostic(placeholderKey: string, severity: 'error' | 'warning'): Diagnostic {
|
||||||
|
return { severity, code: 'unresolved-at-send', message: 'x', placeholderKey, location: LOCATION };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('placeholderDefs', () => {
|
||||||
|
it('keys the placeholder list by its key', () => {
|
||||||
|
const defs = placeholderDefs(DEFS);
|
||||||
|
expect(defs.get('naam_zorgverlener')?.label).toBe('Naam zorgverlener');
|
||||||
|
expect(defs.get('unknown')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('worstSeverities', () => {
|
||||||
|
it('ignores a diagnostic with no placeholder key', () => {
|
||||||
|
const worst = worstSeverities([
|
||||||
|
{ severity: 'error', code: 'malformed', message: 'x', location: LOCATION },
|
||||||
|
]);
|
||||||
|
expect(worst.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps error over a warning already recorded for the same key', () => {
|
||||||
|
const worst = worstSeverities([
|
||||||
|
diagnostic('naam_zorgverlener', 'warning'),
|
||||||
|
diagnostic('naam_zorgverlener', 'error'),
|
||||||
|
]);
|
||||||
|
expect(worst.get('naam_zorgverlener')).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a later warning downgrade an error', () => {
|
||||||
|
const worst = worstSeverities([
|
||||||
|
diagnostic('naam_zorgverlener', 'error'),
|
||||||
|
diagnostic('naam_zorgverlener', 'warning'),
|
||||||
|
]);
|
||||||
|
expect(worst.get('naam_zorgverlener')).toBe('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveLabel', () => {
|
||||||
|
it('returns the field label for a known key', () => {
|
||||||
|
expect(resolveLabel(placeholderDefs(DEFS), 'reden_besluit')).toBe('Reden besluit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the bare key when the field is unknown', () => {
|
||||||
|
expect(resolveLabel(placeholderDefs(DEFS), 'unknown')).toBe('unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveAuto', () => {
|
||||||
|
it('reads autoResolvable off the field', () => {
|
||||||
|
const defs = placeholderDefs(DEFS);
|
||||||
|
expect(resolveAuto(defs, 'naam_zorgverlener')).toBe(true);
|
||||||
|
expect(resolveAuto(defs, 'reden_besluit')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to false for an unknown key', () => {
|
||||||
|
expect(resolveAuto(placeholderDefs(DEFS), 'unknown')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveState', () => {
|
||||||
|
it('defaults to ok when the key has no diagnostic', () => {
|
||||||
|
expect(resolveState(worstSeverities([]), 'naam_zorgverlener')).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the worst recorded severity', () => {
|
||||||
|
const worst = worstSeverities([diagnostic('naam_zorgverlener', 'warning')]);
|
||||||
|
expect(resolveState(worst, 'naam_zorgverlener')).toBe('warning');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveSample', () => {
|
||||||
|
it('prefers the canned sample value over the label', () => {
|
||||||
|
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'naam_zorgverlener')).toBe(
|
||||||
|
'J. Jansen',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves datum to the caller-supplied sample date', () => {
|
||||||
|
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'datum')).toBe(
|
||||||
|
'4 september 2026',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the field label for anything else', () => {
|
||||||
|
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'reden_besluit')).toBe(
|
||||||
|
'Reden besluit',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Component, computed, input, output, signal } from '@angular/core';
|
import { Component, computed, input, output, signal } from '@angular/core';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
|
import { StatusBadgeComponent } from '@shared/ui/atoms/status-badge/status-badge.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { Brief } from '@brief/domain/brief';
|
import { Brief } from '@brief/domain/brief';
|
||||||
import { OrgTemplate } from '@brief/domain/org-template';
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
import { Diagnostic } from '@brief/domain/placeholders';
|
import { Diagnostic } from '@brief/domain/placeholders';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Component, computed, input, output } from '@angular/core';
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
|
||||||
import { Brief } from '@brief/domain/brief';
|
import { Brief } from '@brief/domain/brief';
|
||||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||||
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
|
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Component, input, output } from '@angular/core';
|
import { Component, input, output } from '@angular/core';
|
||||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
import { LetterSection } from '@brief/domain/brief';
|
import { LetterSection } from '@brief/domain/brief';
|
||||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||||
import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component';
|
import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component';
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
|
import { FileInputComponent } from '@shared/ui/atoms/upload/file-input/file-input.component';
|
||||||
|
import { SingleUploadComponent } from '@shared/ui/molecules/upload/single-upload/single-upload.component';
|
||||||
|
import { UploadState } from '@shared/domain/upload.machine';
|
||||||
|
|
||||||
|
const LOGO_CATEGORY = 'org-logo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organism: the org-template editor's logo-upload block, split out of
|
||||||
|
* `org-template-editor.component.ts` (RD-25) — one of its two self-contained
|
||||||
|
* mutation clusters. Presentational: every mutation is an output the parent
|
||||||
|
* re-emits unchanged.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-logo-upload',
|
||||||
|
imports: [HeadingComponent, AlertComponent, FileInputComponent, SingleUploadComponent],
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-block-start: var(--rhc-space-max-xl);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<section class="section">
|
||||||
|
<app-heading [level]="3" i18n="@@orgTemplate.logo">Logo</app-heading>
|
||||||
|
@if (logoCategory()) {
|
||||||
|
<app-file-input
|
||||||
|
inputId="org-logo-input"
|
||||||
|
[accept]="logoCategory()!.acceptedTypes"
|
||||||
|
[maxSizeMb]="logoCategory()!.maxSizeMb"
|
||||||
|
i18n-label="@@orgTemplate.logo"
|
||||||
|
label="Logo"
|
||||||
|
(filesSelected)="logoSelected.emit($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@if (logoRejection()) {
|
||||||
|
<app-alert type="error">{{ logoRejection() }}</app-alert>
|
||||||
|
}
|
||||||
|
@if (logoUploads().length) {
|
||||||
|
<ul class="file-list">
|
||||||
|
@for (u of logoUploads(); track u.localId) {
|
||||||
|
<li
|
||||||
|
app-single-upload
|
||||||
|
[upload]="u"
|
||||||
|
[previewUrlFor]="previewUrlFor()"
|
||||||
|
(remove)="logoRemoved.emit(u.localId)"
|
||||||
|
(retry)="logoRetry.emit(u.localId)"
|
||||||
|
></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class LogoUploadComponent {
|
||||||
|
logoUrl = input<string | null>(null);
|
||||||
|
uploadState = input.required<UploadState>();
|
||||||
|
previewUrlFor = input<(documentId: string) => string | undefined>();
|
||||||
|
|
||||||
|
logoSelected = output<File[]>();
|
||||||
|
logoRemoved = output<string>();
|
||||||
|
logoRetry = output<string>();
|
||||||
|
|
||||||
|
protected logoCategory = computed(() =>
|
||||||
|
this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),
|
||||||
|
);
|
||||||
|
protected logoUploads = computed(() =>
|
||||||
|
this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),
|
||||||
|
);
|
||||||
|
protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);
|
||||||
|
}
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
/* eslint-disable max-lines */ // sample letter + labels + editor in one file — removed by RD-25
|
|
||||||
import { Component, computed, input, output } from '@angular/core';
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
import { DatePipe } from '@angular/common';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
|
||||||
import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';
|
|
||||||
import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';
|
|
||||||
import { UploadState } from '@shared/domain/upload.machine';
|
import { UploadState } from '@shared/domain/upload.machine';
|
||||||
import { Brief } from '@brief/domain/brief';
|
import { Brief } from '@brief/domain/brief';
|
||||||
|
import { SAMPLE_LETTER_BRIEF } from '@brief/domain/sample-letter';
|
||||||
import {
|
import {
|
||||||
MARGIN_MAX_MM,
|
MARGIN_MAX_MM,
|
||||||
MARGIN_MIN_MM,
|
MARGIN_MIN_MM,
|
||||||
@@ -18,74 +14,29 @@ import {
|
|||||||
} from '@brief/domain/org-template';
|
} from '@brief/domain/org-template';
|
||||||
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
||||||
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
||||||
|
import { LogoUploadComponent } from './logo-upload.component';
|
||||||
|
import { VersionHistoryComponent } from './version-history.component';
|
||||||
|
|
||||||
const LOGO_CATEGORY = 'org-logo';
|
|
||||||
const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm'];
|
const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm'];
|
||||||
|
|
||||||
/** A minimal read-only sample letter, so the admin sees the org identity in context
|
|
||||||
while editing (content itself is not the admin's to change). */
|
|
||||||
export const SAMPLE_LETTER_BRIEF: Brief = {
|
|
||||||
briefId: 'VOORBEELD-0001',
|
|
||||||
beroep: 'arts',
|
|
||||||
templateId: 'sample',
|
|
||||||
drafterId: 'sample',
|
|
||||||
status: { tag: 'draft' },
|
|
||||||
placeholders: [
|
|
||||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
|
||||||
{ key: 'datum', label: 'Datum', autoResolvable: true },
|
|
||||||
],
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
sectionKey: 'body',
|
|
||||||
title: 'Voorbeeldinhoud',
|
|
||||||
required: true,
|
|
||||||
locked: true,
|
|
||||||
blocks: [
|
|
||||||
{
|
|
||||||
type: 'freeText',
|
|
||||||
blockId: 'sample-1',
|
|
||||||
content: {
|
|
||||||
paragraphs: [
|
|
||||||
{
|
|
||||||
nodes: [
|
|
||||||
{ type: 'text', text: 'Geachte ' },
|
|
||||||
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
|
||||||
{ type: 'text', text: ',' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Organism: the admin org-template editor. The mirror of the drafter's
|
* Organism: the admin org-template editor. The mirror of the drafter's
|
||||||
* composer — the letter canvas runs in `editableRegions='template'` so the
|
* composer — the letter canvas runs in `editableRegions='template'` so the
|
||||||
* letterhead/signature/footer are edited in place, while the content is a read-only
|
* letterhead/signature/footer are edited in place, while the content is a read-only
|
||||||
* sample. Margins, logo upload, version history and the publish bar sit around it.
|
* sample. Margins and the publish bar sit around it; the logo uploader and version
|
||||||
* Presentational: every mutation is an output the store turns into a command.
|
* history are their own children (`app-logo-upload`, `app-version-history`, RD-25) —
|
||||||
|
* each a self-contained mutation cluster. Presentational: every mutation is an
|
||||||
|
* output the store turns into a command, whether sourced here or re-emitted from
|
||||||
|
* a child.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-org-template-editor',
|
selector: 'app-org-template-editor',
|
||||||
imports: [
|
imports: [
|
||||||
DatePipe,
|
|
||||||
HeadingComponent,
|
|
||||||
ButtonComponent,
|
ButtonComponent,
|
||||||
AlertComponent,
|
AlertComponent,
|
||||||
FileInputComponent,
|
|
||||||
SingleUploadComponent,
|
|
||||||
LetterCanvasComponent,
|
LetterCanvasComponent,
|
||||||
|
LogoUploadComponent,
|
||||||
|
VersionHistoryComponent,
|
||||||
],
|
],
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
@@ -123,22 +74,6 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
|
|||||||
.margins input {
|
.margins input {
|
||||||
width: 6rem;
|
width: 6rem;
|
||||||
}
|
}
|
||||||
.history-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--rhc-space-max-sm);
|
|
||||||
}
|
|
||||||
.history-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--rhc-space-max-md);
|
|
||||||
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
|
||||||
padding-block-end: var(--rhc-space-max-sm);
|
|
||||||
}
|
|
||||||
.bar {
|
.bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -154,7 +89,7 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
|
|||||||
template: `
|
template: `
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>{{ subOrgLabel() }}</span>
|
<span i18n="@@orgTemplate.subOrg">Organisatieonderdeel</span>
|
||||||
<select class="form-select" (change)="onSelectSubOrg($event)">
|
<select class="form-select" (change)="onSelectSubOrg($event)">
|
||||||
@for (o of subOrgs(); track o.subOrgId) {
|
@for (o of subOrgs(); track o.subOrgId) {
|
||||||
<option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()">
|
<option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()">
|
||||||
@@ -191,80 +126,62 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
|
|||||||
}
|
}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<section class="section">
|
<app-logo-upload
|
||||||
<app-heading [level]="3">{{ logoHeading() }}</app-heading>
|
[logoUrl]="logoUrl()"
|
||||||
@if (logoCategory()) {
|
[uploadState]="uploadState()"
|
||||||
<app-file-input
|
[previewUrlFor]="previewUrlFor()"
|
||||||
inputId="org-logo-input"
|
(logoSelected)="logoSelected.emit($event)"
|
||||||
[accept]="logoCategory()!.acceptedTypes"
|
(logoRemoved)="logoRemoved.emit($event)"
|
||||||
[maxSizeMb]="logoCategory()!.maxSizeMb"
|
(logoRetry)="logoRetry.emit($event)"
|
||||||
[label]="logoHeading()"
|
/>
|
||||||
(filesSelected)="logoSelected.emit($event)"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
@if (logoRejection()) {
|
|
||||||
<app-alert type="error">{{ logoRejection() }}</app-alert>
|
|
||||||
}
|
|
||||||
@if (logoUploads().length) {
|
|
||||||
<ul class="file-list">
|
|
||||||
@for (u of logoUploads(); track u.localId) {
|
|
||||||
<li
|
|
||||||
app-single-upload
|
|
||||||
[upload]="u"
|
|
||||||
[previewUrlFor]="previewUrlFor()"
|
|
||||||
(remove)="logoRemoved.emit(u.localId)"
|
|
||||||
(retry)="logoRetry.emit(u.localId)"
|
|
||||||
></li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
<app-version-history
|
||||||
<app-heading [level]="3">{{ historyHeading() }}</app-heading>
|
[history]="history()"
|
||||||
@if (history().length === 0) {
|
[publishedVersion]="publishedVersion()"
|
||||||
<p class="published">{{ noHistory() }}</p>
|
[busy]="busy()"
|
||||||
} @else {
|
(rollback)="rollback.emit($event)"
|
||||||
<ul class="history-list">
|
/>
|
||||||
@for (v of history(); track v.version) {
|
|
||||||
<li class="history-row">
|
|
||||||
<span
|
|
||||||
>{{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}</span
|
|
||||||
>
|
|
||||||
<app-button variant="subtle" [disabled]="busy()" (click)="rollback.emit(v.version)">
|
|
||||||
{{ rollbackLabel() }}
|
|
||||||
</app-button>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
<span class="published">{{ publishedLabel() }} {{ publishedVersion() }}</span>
|
<span class="published"
|
||||||
|
><span i18n="@@orgTemplate.published">Gepubliceerde versie:</span>
|
||||||
|
{{ publishedVersion() }}</span
|
||||||
|
>
|
||||||
@if (pendingPublish()) {
|
@if (pendingPublish()) {
|
||||||
<app-alert type="warning">{{ impactText() }}</app-alert>
|
<app-alert type="warning">{{ impactText() }}</app-alert>
|
||||||
<app-button variant="primary" [disabled]="busy()" (click)="confirmPublish.emit()">
|
<app-button
|
||||||
{{ confirmLabel() }}
|
variant="primary"
|
||||||
</app-button>
|
[disabled]="busy()"
|
||||||
<app-button variant="subtle" [disabled]="busy()" (click)="cancelPublish.emit()">
|
(click)="confirmPublish.emit()"
|
||||||
{{ cancelLabel() }}
|
i18n="@@orgTemplate.publish.confirm"
|
||||||
</app-button>
|
>Bevestigen</app-button
|
||||||
|
>
|
||||||
|
<app-button
|
||||||
|
variant="subtle"
|
||||||
|
[disabled]="busy()"
|
||||||
|
(click)="cancelPublish.emit()"
|
||||||
|
i18n="@@orgTemplate.publish.cancel"
|
||||||
|
>Annuleren</app-button
|
||||||
|
>
|
||||||
} @else {
|
} @else {
|
||||||
<app-button
|
<app-button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
[disabled]="!draftValid() || busy()"
|
[disabled]="!draftValid() || busy()"
|
||||||
(click)="requestPublish.emit()"
|
(click)="requestPublish.emit()"
|
||||||
|
i18n="@@orgTemplate.publish"
|
||||||
|
>Publiceren</app-button
|
||||||
>
|
>
|
||||||
{{ publishLabel() }}
|
|
||||||
</app-button>
|
|
||||||
@if (!draftValid()) {
|
@if (!draftValid()) {
|
||||||
<span class="published">{{ invalidHint() }}</span>
|
<span class="published">{{ invalidHint() }}</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
<app-button variant="secondary" [disabled]="busy()" (click)="proefbrief.emit()">
|
<app-button
|
||||||
{{ proefbriefLabel() }}
|
variant="secondary"
|
||||||
</app-button>
|
[disabled]="busy()"
|
||||||
|
(click)="proefbrief.emit()"
|
||||||
|
i18n="@@orgTemplate.proefbrief"
|
||||||
|
>Proefbrief</app-button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -300,14 +217,6 @@ export class OrgTemplateEditorComponent {
|
|||||||
protected readonly MIN = MARGIN_MIN_MM;
|
protected readonly MIN = MARGIN_MIN_MM;
|
||||||
protected readonly MAX = MARGIN_MAX_MM;
|
protected readonly MAX = MARGIN_MAX_MM;
|
||||||
|
|
||||||
protected logoCategory = computed(() =>
|
|
||||||
this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),
|
|
||||||
);
|
|
||||||
protected logoUploads = computed(() =>
|
|
||||||
this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),
|
|
||||||
);
|
|
||||||
protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);
|
|
||||||
|
|
||||||
protected onSelectSubOrg(event: Event) {
|
protected onSelectSubOrg(event: Event) {
|
||||||
this.selectSubOrg.emit((event.target as HTMLSelectElement).value);
|
this.selectSubOrg.emit((event.target as HTMLSelectElement).value);
|
||||||
}
|
}
|
||||||
@@ -334,20 +243,9 @@ export class OrgTemplateEditorComponent {
|
|||||||
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
|
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
|
||||||
);
|
);
|
||||||
|
|
||||||
protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);
|
|
||||||
protected marginsLegend = input(
|
protected marginsLegend = input(
|
||||||
$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`,
|
$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`,
|
||||||
);
|
);
|
||||||
protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`);
|
|
||||||
protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);
|
|
||||||
protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`);
|
|
||||||
protected versionLabel = input($localize`:@@orgTemplate.version:Versie`);
|
|
||||||
protected rollbackLabel = input($localize`:@@orgTemplate.rollback:Terugzetten in concept`);
|
|
||||||
protected publishedLabel = input($localize`:@@orgTemplate.published:Gepubliceerde versie:`);
|
|
||||||
protected publishLabel = input($localize`:@@orgTemplate.publish:Publiceren`);
|
|
||||||
protected confirmLabel = input($localize`:@@orgTemplate.publish.confirm:Bevestigen`);
|
|
||||||
protected cancelLabel = input($localize`:@@orgTemplate.publish.cancel:Annuleren`);
|
|
||||||
protected proefbriefLabel = input($localize`:@@orgTemplate.proefbrief:Proefbrief`);
|
|
||||||
protected invalidHint = input(
|
protected invalidHint = input(
|
||||||
$localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,
|
$localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Component, input, output } from '@angular/core';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
|
import { OrgTemplateVersion } from '@brief/domain/org-template';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organism: the org-template editor's version-history block, split out of
|
||||||
|
* `org-template-editor.component.ts` (RD-25) — one of its two self-contained
|
||||||
|
* mutation clusters. Presentational: `rollback` is the parent's own output,
|
||||||
|
* re-emitted unchanged.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-version-history',
|
||||||
|
imports: [DatePipe, HeadingComponent, ButtonComponent],
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-block-start: var(--rhc-space-max-xl);
|
||||||
|
}
|
||||||
|
.history-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--rhc-space-max-sm);
|
||||||
|
}
|
||||||
|
.history-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--rhc-space-max-md);
|
||||||
|
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||||
|
padding-block-end: var(--rhc-space-max-sm);
|
||||||
|
}
|
||||||
|
.published {
|
||||||
|
color: var(--rhc-color-foreground-subtle);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<section class="section">
|
||||||
|
<app-heading [level]="3" i18n="@@orgTemplate.history">Versiegeschiedenis</app-heading>
|
||||||
|
@if (history().length === 0) {
|
||||||
|
<p class="published" i18n="@@orgTemplate.history.none">Nog niets gepubliceerd.</p>
|
||||||
|
} @else {
|
||||||
|
<ul class="history-list">
|
||||||
|
@for (v of history(); track v.version) {
|
||||||
|
<li class="history-row">
|
||||||
|
<span
|
||||||
|
><span i18n="@@orgTemplate.version">Versie</span> {{ v.version }} ·
|
||||||
|
{{ v.publishedAt | date: 'longDate' }}</span
|
||||||
|
>
|
||||||
|
<app-button
|
||||||
|
variant="subtle"
|
||||||
|
[disabled]="busy()"
|
||||||
|
(click)="rollback.emit(v.version)"
|
||||||
|
i18n="@@orgTemplate.rollback"
|
||||||
|
>Terugzetten in concept</app-button
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class VersionHistoryComponent {
|
||||||
|
history = input<readonly OrgTemplateVersion[]>([]);
|
||||||
|
publishedVersion = input(0);
|
||||||
|
busy = input(false);
|
||||||
|
|
||||||
|
rollback = output<number>();
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Component, computed, effect, inject } from '@angular/core';
|
import { Component, computed, effect, inject } from '@angular/core';
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||||
import { AccessStore } from '@shared/application/access.store';
|
import { AccessStore } from '@shared/application/access.store';
|
||||||
import { OrgTemplateStore } from '@brief/application/org-template.store';
|
import { OrgTemplateStore } from '@brief/application/org-template.store';
|
||||||
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';
|
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Component, computed, input, output, signal } from '@angular/core';
|
import { Component, computed, input, output, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
|
import { CheckboxComponent } from '@shared/ui/atoms/checkbox/checkbox.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
|
||||||
import { textOf } from '@shared/kernel/rich-text';
|
import { textOf } from '@shared/kernel/rich-text';
|
||||||
import { LibraryPassage } from '@brief/domain/brief';
|
import { LibraryPassage } from '@brief/domain/brief';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, input, output, signal } from '@angular/core';
|
import { Component, input, output, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
|
|
||||||
/** Molecule: shows the rejection comments (drafter view) or collects them from the
|
/** Molecule: shows the rejection comments (drafter view) or collects them from the
|
||||||
approver. The approver rejects WITH comments; they never edit the letter. */
|
approver. The approver rejects WITH comments; they never edit the letter. */
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
reduceUpload,
|
reduceUpload,
|
||||||
requiredCategoriesSatisfied,
|
requiredCategoriesSatisfied,
|
||||||
deliveryRefs,
|
deliveryRefs,
|
||||||
|
digitalDocumentIds,
|
||||||
} from '@shared/domain/upload.machine';
|
} from '@shared/domain/upload.machine';
|
||||||
|
|
||||||
/** What the user is typing (raw, possibly invalid). */
|
/** What the user is typing (raw, possibly invalid). */
|
||||||
@@ -53,7 +54,7 @@ export function hasProgress(s: Extract<WizardState, { tag: 'Editing' }>): boolea
|
|||||||
!!s.draft.uren ||
|
!!s.draft.uren ||
|
||||||
!!s.draft.jaren ||
|
!!s.draft.jaren ||
|
||||||
!!s.draft.punten ||
|
!!s.draft.punten ||
|
||||||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)
|
digitalDocumentIds(s.upload).length > 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
|
|||||||
// #endregion showcase:steps
|
// #endregion showcase:steps
|
||||||
|
|
||||||
/** Per-field error map: one message per question, since a step holds several. */
|
/** Per-field error map: one message per question, since a step holds several. */
|
||||||
type Errors = Partial<Record<keyof Answers, string>>;
|
export type Errors = Partial<Record<keyof Answers, string>>;
|
||||||
|
|
||||||
export type IntakeState =
|
export type IntakeState =
|
||||||
| {
|
| {
|
||||||
|
|||||||
+53
-44
@@ -1,8 +1,8 @@
|
|||||||
import { Component, computed, inject, input } from '@angular/core';
|
import { Component, computed, inject, input } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import {
|
import {
|
||||||
WizardShellComponent,
|
WizardShellComponent,
|
||||||
WizardError,
|
WizardError,
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
naarStapLabel,
|
naarStapLabel,
|
||||||
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||||
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
|
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
|
||||||
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
import { whenTag } from '@shared/kernel/fp';
|
import { whenTag } from '@shared/kernel/fp';
|
||||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||||
@@ -23,9 +23,9 @@ import {
|
|||||||
hasProgress,
|
hasProgress,
|
||||||
} from '@herregistratie/domain/herregistratie.machine';
|
} from '@herregistratie/domain/herregistratie.machine';
|
||||||
import { createDraftSync } from '@registratie/application/draft-sync';
|
import { createDraftSync } from '@registratie/application/draft-sync';
|
||||||
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
|
import { DocumentUploadComponent } from '@shared/ui/organisms/upload/document-upload/document-upload.component';
|
||||||
import { createUploadController } from '@shared/application/upload-controller';
|
import { createUploadController } from '@shared/application/upload-controller';
|
||||||
import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine';
|
import { UploadState, initialUpload, digitalDocumentIds } from '@shared/domain/upload.machine';
|
||||||
|
|
||||||
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
|
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
|
||||||
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
|
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
|
||||||
@@ -148,8 +148,14 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.
|
|||||||
})
|
})
|
||||||
export class HerregistratieWizardComponent {
|
export class HerregistratieWizardComponent {
|
||||||
private profile = inject(BigProfileStore);
|
private profile = inject(BigProfileStore);
|
||||||
// Effect fires once, on Editing -> Submitting (RD-05's tag-transition rule; `Seed` is
|
|
||||||
// exempt, so a story mounting straight into `Submitting` does not call the network).
|
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||||
|
seed = input<WizardState>(initial);
|
||||||
|
|
||||||
|
// --- The store: all state in one signal, changed only by a pure reduce -----
|
||||||
|
// The effect fires once, on the `Editing -> Submitting` transition. `Seed` is exempt,
|
||||||
|
// so a story that mounts straight into `Submitting` does not call the network.
|
||||||
|
// `draftSync` is declared below (both callbacks are deferred, so the cycle is safe).
|
||||||
private store = createStore<WizardState, WizardMsg>(initial, reduce, {
|
private store = createStore<WizardState, WizardMsg>(initial, reduce, {
|
||||||
Submitting: async (s, store) => {
|
Submitting: async (s, store) => {
|
||||||
this.profile.beginHerregistratie();
|
this.profile.beginHerregistratie();
|
||||||
@@ -163,36 +169,10 @@ export class HerregistratieWizardComponent {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Preview/download link for a completed upload; delegates to the upload
|
|
||||||
controller (application layer), which knows the dev-simulation `demo-*` ids
|
|
||||||
have no stored bytes and returns no link for them. */
|
|
||||||
protected previewUrlFor = (documentId: string): string | undefined =>
|
|
||||||
this.uploadCtl.previewUrlFor(documentId);
|
|
||||||
|
|
||||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
|
||||||
seed = input<WizardState>(initial);
|
|
||||||
|
|
||||||
readonly state = this.store.model; // public so the showcase can highlight the live state
|
readonly state = this.store.model; // public so the showcase can highlight the live state
|
||||||
protected dispatch = this.store.dispatch;
|
protected dispatch = this.store.dispatch;
|
||||||
|
|
||||||
// Backend draft-sync (new persistence for this wizard): create a Concept on first
|
// --- Static copy: stepper labels and per-step headings ---------------------
|
||||||
// progress, debounced-sync the snapshot, resume by `?aanvraag=<id>`.
|
|
||||||
private draftSync = createDraftSync({
|
|
||||||
type: 'herregistratie',
|
|
||||||
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!);
|
|
||||||
return { draft: s, stepIndex: s.step - 1, stepCount: this.stepLabels.length, documentIds };
|
|
||||||
},
|
|
||||||
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }),
|
|
||||||
enabled: () => this.seed() === initial,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Stepper labels + per-step heading titles (presentational only).
|
|
||||||
readonly stepLabels = [
|
readonly stepLabels = [
|
||||||
$localize`:@@herregWizard.step.werkervaring:Werkervaring`,
|
$localize`:@@herregWizard.step.werkervaring:Werkervaring`,
|
||||||
$localize`:@@herregWizard.step.nascholing:Nascholing`,
|
$localize`:@@herregWizard.step.nascholing:Nascholing`,
|
||||||
@@ -204,6 +184,7 @@ export class HerregistratieWizardComponent {
|
|||||||
$localize`:@@herregWizard.title.documenten:Documenten aanleveren`,
|
$localize`:@@herregWizard.title.documenten:Documenten aanleveren`,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// --- State projections: one narrow, then read-only views of it -------------
|
||||||
private editing = computed(() => whenTag(this.state(), 'Editing'));
|
private editing = computed(() => whenTag(this.state(), 'Editing'));
|
||||||
protected step = computed(() => this.editing()?.step ?? 1);
|
protected step = computed(() => this.editing()?.step ?? 1);
|
||||||
protected draft = computed<Draft>(
|
protected draft = computed<Draft>(
|
||||||
@@ -214,11 +195,35 @@ export class HerregistratieWizardComponent {
|
|||||||
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
|
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
|
||||||
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
||||||
protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? '');
|
protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? '');
|
||||||
|
|
||||||
|
// --- Controllers: persistence and uploads ----------------------------------
|
||||||
|
// Create a Concept on first progress, then debounced-sync the snapshot.
|
||||||
|
// `?aanvraag=<id>` resumes it.
|
||||||
|
private draftSync = createDraftSync({
|
||||||
|
type: 'herregistratie',
|
||||||
|
snapshot: () => {
|
||||||
|
const s = this.state();
|
||||||
|
if (s.tag !== 'Editing' || !hasProgress(s)) return null;
|
||||||
|
return {
|
||||||
|
draft: s,
|
||||||
|
stepIndex: s.step - 1,
|
||||||
|
stepCount: this.stepLabels.length,
|
||||||
|
documentIds: digitalDocumentIds(s.upload),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }),
|
||||||
|
enabled: () => this.seed() === initial,
|
||||||
|
});
|
||||||
protected uploadCtl = createUploadController({
|
protected uploadCtl = createUploadController({
|
||||||
wizardId: 'herregistratie',
|
wizardId: 'herregistratie',
|
||||||
getUpload: () => this.upload(),
|
getUpload: () => this.upload(),
|
||||||
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
|
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
|
||||||
});
|
});
|
||||||
|
/** Preview/download link for a completed upload; delegates to the upload
|
||||||
|
controller (application layer), which knows the dev-simulation `demo-*` ids
|
||||||
|
have no stored bytes and returns no link for them. */
|
||||||
|
protected previewUrlFor = (documentId: string): string | undefined =>
|
||||||
|
this.uploadCtl.previewUrlFor(documentId);
|
||||||
|
|
||||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||||
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
|
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
|
||||||
@@ -228,11 +233,6 @@ export class HerregistratieWizardComponent {
|
|||||||
? naarStapLabel(step + 1, this.stepLabels[step])
|
? naarStapLabel(step + 1, this.stepLabels[step])
|
||||||
: $localize`:@@herregWizard.indienen:Herregistratie aanvragen`;
|
: $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 });
|
|
||||||
}
|
|
||||||
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
|
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
|
||||||
composing the localized failure prefix so the `Failed` message arrives intact. */
|
composing the localized failure prefix so the `Failed` message arrives intact. */
|
||||||
protected phase = computed<WizardPhase>(() => {
|
protected phase = computed<WizardPhase>(() => {
|
||||||
@@ -254,13 +254,22 @@ export class HerregistratieWizardComponent {
|
|||||||
/** Current step's field errors, flattened for the shell's error summary. */
|
/** Current step's field errors, flattened for the shell's error summary. */
|
||||||
protected errorList = computed<WizardError[]>(() => toWizardErrors(this.editing()?.errors ?? {}));
|
protected errorList = computed<WizardError[]>(() => toWizardErrors(this.editing()?.errors ?? {}));
|
||||||
|
|
||||||
|
// --- Event handlers: narrow a child event into a message -------------------
|
||||||
|
/** 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 });
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
|
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
|
||||||
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
|
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
|
||||||
const seeded = this.seed();
|
// Read `seed()` INSIDE the microtask: Angular binds inputs after the constructor
|
||||||
queueMicrotask(() =>
|
// runs, so an eager read here always returns the `initial` default.
|
||||||
seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume(),
|
queueMicrotask(() => {
|
||||||
);
|
const seeded = this.seed();
|
||||||
|
if (seeded !== initial) this.dispatch({ tag: 'Seed', state: seeded });
|
||||||
|
else void this.draftSync.resume();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset the wizard to a fresh, empty start. */
|
/** Reset the wizard to a fresh, empty start. */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||||
import { map } from '@shared/application/remote-data';
|
import { map } from '@shared/application/remote-data';
|
||||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||||
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
|
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Component, input, output } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
|
||||||
|
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
|
||||||
|
import { RadioGroupComponent, JA_NEE } from '@shared/ui/atoms/radio-group/radio-group.component';
|
||||||
|
import { Answers, Errors } from '@herregistratie/domain/intake.machine';
|
||||||
|
|
||||||
|
/** Step: the intake wizard's first screen (foreign work in the last 5 years).
|
||||||
|
Pure & presentational — values in via `answers`/`errors`, every keystroke out
|
||||||
|
via `answerChange`. No store, no services, no internal state; the parent owns
|
||||||
|
the Model and decides what a change means. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-intake-buitenland-step',
|
||||||
|
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent],
|
||||||
|
template: `
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@intake.q.buitenland"
|
||||||
|
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
|
||||||
|
fieldId="buitenlandGewerkt"
|
||||||
|
required
|
||||||
|
[error]="err('buitenlandGewerkt')"
|
||||||
|
>
|
||||||
|
<app-radio-group
|
||||||
|
name="buitenlandGewerkt"
|
||||||
|
[options]="jaNee"
|
||||||
|
[ngModel]="answers().buitenlandGewerkt ?? ''"
|
||||||
|
(ngModelChange)="answerChange.emit({ key: 'buitenlandGewerkt', value: $event })"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
@if (answers().buitenlandGewerkt === 'ja') {
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@intake.q.land"
|
||||||
|
label="In welk land?"
|
||||||
|
fieldId="land"
|
||||||
|
required
|
||||||
|
[error]="err('land')"
|
||||||
|
>
|
||||||
|
<app-text-input
|
||||||
|
inputId="land"
|
||||||
|
[ngModel]="answers().land ?? ''"
|
||||||
|
(ngModelChange)="answerChange.emit({ key: 'land', value: $event })"
|
||||||
|
name="land"
|
||||||
|
i18n-placeholder="@@intake.q.landPlaceholder"
|
||||||
|
placeholder="bijv. België"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@intake.q.buitenlandseUren"
|
||||||
|
label="Hoeveel uur heeft u daar gewerkt?"
|
||||||
|
fieldId="buitenlandseUren"
|
||||||
|
required
|
||||||
|
[error]="err('buitenlandseUren')"
|
||||||
|
>
|
||||||
|
<app-text-input
|
||||||
|
inputId="buitenlandseUren"
|
||||||
|
[ngModel]="answers().buitenlandseUren ?? ''"
|
||||||
|
(ngModelChange)="answerChange.emit({ key: 'buitenlandseUren', value: $event })"
|
||||||
|
name="buitenlandseUren"
|
||||||
|
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
|
||||||
|
placeholder="bijv. 800"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class BuitenlandStep {
|
||||||
|
answers = input.required<Answers>();
|
||||||
|
errors = input.required<Errors>();
|
||||||
|
answerChange = output<{ key: keyof Answers; value: string }>();
|
||||||
|
|
||||||
|
readonly jaNee = JA_NEE;
|
||||||
|
protected err = (k: keyof Answers) => this.errors()[k] ?? '';
|
||||||
|
}
|
||||||
@@ -26,14 +26,30 @@ const buitenlandJa: IntakeState = {
|
|||||||
scholingThreshold: 1000,
|
scholingThreshold: 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Mount with a seed and let the constructor's microtask apply it. */
|
||||||
|
async function mountSeeded(state: IntakeState) {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideApiClient()],
|
||||||
|
});
|
||||||
|
const fixture = TestBed.createComponent(IntakeWizardComponent);
|
||||||
|
fixture.componentRef.setInput('seed', state);
|
||||||
|
await Promise.resolve(); // the seed is applied in a queueMicrotask
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
describe('IntakeWizardComponent', () => {
|
describe('IntakeWizardComponent', () => {
|
||||||
it('renders each field group as its own grey <fieldset>', () => {
|
// Regression: the constructor must read `seed()` INSIDE its microtask. Angular binds
|
||||||
TestBed.configureTestingModule({
|
// inputs after the constructor runs, so an eager read silently yields the `initial`
|
||||||
providers: [provideHttpClient(), provideApiClient()],
|
// default and every seeded story renders step 1 instead of the state it asked for.
|
||||||
});
|
it('honours the seed input', async () => {
|
||||||
const fixture = TestBed.createComponent(IntakeWizardComponent);
|
const fixture = await mountSeeded(buitenlandJa);
|
||||||
fixture.componentInstance.dispatch({ tag: 'Seed', state: buitenlandJa });
|
|
||||||
fixture.detectChanges();
|
expect(fixture.componentInstance.state()).toEqual(buitenlandJa);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders each field group as its own grey <fieldset>', async () => {
|
||||||
|
const fixture = await mountSeeded(buitenlandJa);
|
||||||
|
|
||||||
const fieldsets: HTMLElement[] = Array.from(
|
const fieldsets: HTMLElement[] = Array.from(
|
||||||
fixture.nativeElement.querySelectorAll('form.form-horizontal fieldset'),
|
fixture.nativeElement.querySelectorAll('form.form-horizontal fieldset'),
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
/* eslint-disable max-lines */ // one wizard shell for the intake steps — removed by RD-22
|
|
||||||
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
|
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
|
||||||
import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component';
|
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
|
||||||
import { 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 {
|
import {
|
||||||
WizardShellComponent,
|
WizardShellComponent,
|
||||||
WizardError,
|
WizardError,
|
||||||
@@ -23,35 +15,35 @@ import {
|
|||||||
IntakeState,
|
IntakeState,
|
||||||
IntakeMsg,
|
IntakeMsg,
|
||||||
Answers,
|
Answers,
|
||||||
|
Errors,
|
||||||
StepId,
|
StepId,
|
||||||
initial,
|
initial,
|
||||||
reduce,
|
reduce,
|
||||||
STEPS,
|
STEPS,
|
||||||
lageUren,
|
|
||||||
hasProgress,
|
hasProgress,
|
||||||
SCHOLING_THRESHOLD_DEFAULT,
|
SCHOLING_THRESHOLD_DEFAULT,
|
||||||
} from '@herregistratie/domain/intake.machine';
|
} from '@herregistratie/domain/intake.machine';
|
||||||
import { createDraftSync } from '@registratie/application/draft-sync';
|
import { createDraftSync } from '@registratie/application/draft-sync';
|
||||||
import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';
|
import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';
|
||||||
|
import { BuitenlandStep } from './buitenland.step';
|
||||||
|
import { WerkStep } from './werk.step';
|
||||||
|
import { ReviewStep } from './review.step';
|
||||||
|
|
||||||
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
|
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
|
||||||
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
|
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
|
||||||
from the answers via `visibleSteps`, never stored — so editing an earlier
|
from the answers via `visibleSteps`, never stored — so editing an earlier
|
||||||
answer immediately changes the remaining steps. Answers are persisted to
|
answer immediately changes the remaining steps. The draft persists to the
|
||||||
sessionStorage so a page reload keeps the user's progress (cleared on tab close). */
|
backend as a Concept aanvraag (createDraftSync), so a reload — or a "Verder
|
||||||
|
gaan" from the dashboard via `?aanvraag=<id>` — resumes progress. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-intake-wizard',
|
selector: 'app-intake-wizard',
|
||||||
imports: [
|
imports: [
|
||||||
FormsModule,
|
|
||||||
FormFieldComponent,
|
|
||||||
TextInputComponent,
|
|
||||||
RadioGroupComponent,
|
|
||||||
ButtonComponent,
|
ButtonComponent,
|
||||||
AlertComponent,
|
|
||||||
DataRowComponent,
|
|
||||||
ReviewSectionComponent,
|
|
||||||
ConfirmationComponent,
|
ConfirmationComponent,
|
||||||
WizardShellComponent,
|
WizardShellComponent,
|
||||||
|
BuitenlandStep,
|
||||||
|
WerkStep,
|
||||||
|
ReviewStep,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<app-wizard-shell
|
<app-wizard-shell
|
||||||
@@ -72,180 +64,26 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
|
|||||||
>
|
>
|
||||||
@switch (step()) {
|
@switch (step()) {
|
||||||
@case ('buitenland') {
|
@case ('buitenland') {
|
||||||
<fieldset>
|
<app-intake-buitenland-step
|
||||||
<app-form-field
|
[answers]="answers()"
|
||||||
i18n-label="@@intake.q.buitenland"
|
[errors]="errors()"
|
||||||
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
|
(answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
|
||||||
fieldId="buitenlandGewerkt"
|
/>
|
||||||
required
|
|
||||||
[error]="err('buitenlandGewerkt')"
|
|
||||||
>
|
|
||||||
<app-radio-group
|
|
||||||
name="buitenlandGewerkt"
|
|
||||||
[options]="jaNee"
|
|
||||||
[ngModel]="answers().buitenlandGewerkt ?? ''"
|
|
||||||
(ngModelChange)="set('buitenlandGewerkt', $event)"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
@if (answers().buitenlandGewerkt === 'ja') {
|
|
||||||
<fieldset>
|
|
||||||
<app-form-field
|
|
||||||
i18n-label="@@intake.q.land"
|
|
||||||
label="In welk land?"
|
|
||||||
fieldId="land"
|
|
||||||
required
|
|
||||||
[error]="err('land')"
|
|
||||||
>
|
|
||||||
<app-text-input
|
|
||||||
inputId="land"
|
|
||||||
[ngModel]="answers().land ?? ''"
|
|
||||||
(ngModelChange)="set('land', $event)"
|
|
||||||
name="land"
|
|
||||||
i18n-placeholder="@@intake.q.landPlaceholder"
|
|
||||||
placeholder="bijv. België"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
<app-form-field
|
|
||||||
i18n-label="@@intake.q.buitenlandseUren"
|
|
||||||
label="Hoeveel uur heeft u daar gewerkt?"
|
|
||||||
fieldId="buitenlandseUren"
|
|
||||||
required
|
|
||||||
[error]="err('buitenlandseUren')"
|
|
||||||
>
|
|
||||||
<app-text-input
|
|
||||||
inputId="buitenlandseUren"
|
|
||||||
[ngModel]="answers().buitenlandseUren ?? ''"
|
|
||||||
(ngModelChange)="set('buitenlandseUren', $event)"
|
|
||||||
name="buitenlandseUren"
|
|
||||||
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
|
|
||||||
placeholder="bijv. 800"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@case ('werk') {
|
@case ('werk') {
|
||||||
<fieldset>
|
<app-intake-werk-step
|
||||||
<app-form-field
|
[answers]="answers()"
|
||||||
i18n-label="@@intake.q.urenNl"
|
[errors]="errors()"
|
||||||
label="Gewerkte uren in Nederland (afgelopen 5 jaar)"
|
[scholingThreshold]="scholingThreshold()"
|
||||||
fieldId="uren"
|
(answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
|
||||||
required
|
/>
|
||||||
[error]="err('uren')"
|
|
||||||
>
|
|
||||||
<app-text-input
|
|
||||||
inputId="uren"
|
|
||||||
[ngModel]="answers().uren ?? ''"
|
|
||||||
(ngModelChange)="set('uren', $event)"
|
|
||||||
name="uren"
|
|
||||||
i18n-placeholder="@@intake.q.urenNlPlaceholder"
|
|
||||||
placeholder="bijv. 4160"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
@if (scholingZichtbaar()) {
|
|
||||||
<fieldset>
|
|
||||||
<app-form-field
|
|
||||||
i18n-label="@@intake.q.scholing"
|
|
||||||
label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?"
|
|
||||||
fieldId="scholingGevolgd"
|
|
||||||
required
|
|
||||||
[error]="err('scholingGevolgd')"
|
|
||||||
>
|
|
||||||
<app-radio-group
|
|
||||||
name="scholingGevolgd"
|
|
||||||
[options]="jaNee"
|
|
||||||
[ngModel]="answers().scholingGevolgd ?? ''"
|
|
||||||
(ngModelChange)="set('scholingGevolgd', $event)"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
}
|
|
||||||
@if (answers().scholingGevolgd === 'ja') {
|
|
||||||
<fieldset>
|
|
||||||
<app-form-field
|
|
||||||
i18n-label="@@intake.q.punten"
|
|
||||||
label="Behaalde nascholingspunten"
|
|
||||||
fieldId="punten"
|
|
||||||
required
|
|
||||||
[error]="err('punten')"
|
|
||||||
>
|
|
||||||
<app-text-input
|
|
||||||
inputId="punten"
|
|
||||||
[ngModel]="answers().punten ?? ''"
|
|
||||||
(ngModelChange)="set('punten', $event)"
|
|
||||||
name="punten"
|
|
||||||
i18n-placeholder="@@intake.q.puntenPlaceholder"
|
|
||||||
placeholder="bijv. 200"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@case ('review') {
|
@case ('review') {
|
||||||
<app-alert type="info" i18n="@@intake.review.controleer"
|
<app-intake-review-step
|
||||||
>Controleer uw antwoorden en dien de aanvraag in.</app-alert
|
[answers]="answers()"
|
||||||
>
|
[scholingThreshold]="scholingThreshold()"
|
||||||
<app-review-section
|
(edit)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
|
||||||
i18n-heading="@@intake.sectie.buitenland"
|
/>
|
||||||
heading="Buitenland"
|
|
||||||
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
|
|
||||||
editAriaLabel="Wijzigen buitenland"
|
|
||||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@intake.review.buitenNl"
|
|
||||||
key="Buiten NL gewerkt"
|
|
||||||
[value]="answers().buitenlandGewerkt ?? '—'"
|
|
||||||
></div>
|
|
||||||
@if (answers().buitenlandGewerkt === 'ja') {
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@intake.review.land"
|
|
||||||
key="Land"
|
|
||||||
[value]="answers().land ?? ''"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@intake.review.buitenlandseUren"
|
|
||||||
key="Buitenlandse uren"
|
|
||||||
[value]="answers().buitenlandseUren ?? ''"
|
|
||||||
></div>
|
|
||||||
}
|
|
||||||
</app-review-section>
|
|
||||||
<app-review-section
|
|
||||||
class="app-section"
|
|
||||||
i18n-heading="@@intake.sectie.werk"
|
|
||||||
heading="Werk in Nederland"
|
|
||||||
i18n-editAriaLabel="@@intake.werkWijzigenAria"
|
|
||||||
editAriaLabel="Wijzigen werk in Nederland"
|
|
||||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@intake.review.urenNl"
|
|
||||||
key="Uren NL"
|
|
||||||
[value]="answers().uren ?? ''"
|
|
||||||
></div>
|
|
||||||
@if (scholingZichtbaar()) {
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@intake.review.scholing"
|
|
||||||
key="Aanvullende scholing"
|
|
||||||
[value]="answers().scholingGevolgd ?? ''"
|
|
||||||
></div>
|
|
||||||
}
|
|
||||||
@if (answers().scholingGevolgd === 'ja') {
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@intake.review.punten"
|
|
||||||
key="Nascholingspunten"
|
|
||||||
[value]="answers().punten ?? ''"
|
|
||||||
></div>
|
|
||||||
}
|
|
||||||
</app-review-section>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,8 +107,14 @@ export class IntakeWizardComponent {
|
|||||||
// Server-owned policy (scholing threshold): fetched from the backend via the
|
// Server-owned policy (scholing threshold): fetched from the backend via the
|
||||||
// application facade, not hardcoded. The backend stays the authority on submit.
|
// application facade, not hardcoded. The backend stays the authority on submit.
|
||||||
private policyStore = inject(IntakePolicyStore);
|
private policyStore = inject(IntakePolicyStore);
|
||||||
// Effect fires once, on Answering -> Submitting (RD-05's tag-transition rule; `Seed` is
|
|
||||||
// exempt, so a story mounting straight into `Submitting` does not call the network).
|
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||||
|
seed = input<IntakeState>(initial);
|
||||||
|
|
||||||
|
// --- The store: all state in one signal, changed only by a pure reduce -----
|
||||||
|
// The effect fires once, on the `Answering -> Submitting` transition. `Seed` is exempt,
|
||||||
|
// so a story that mounts straight into `Submitting` does not call the network.
|
||||||
|
// `draftSync` is declared below (both callbacks are deferred, so the cycle is safe).
|
||||||
private store = createStore<IntakeState, IntakeMsg>(initial, reduce, {
|
private store = createStore<IntakeState, IntakeMsg>(initial, reduce, {
|
||||||
Submitting: async (s, store) => {
|
Submitting: async (s, store) => {
|
||||||
this.profile.beginHerregistratie();
|
this.profile.beginHerregistratie();
|
||||||
@@ -292,14 +136,37 @@ export class IntakeWizardComponent {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
|
||||||
seed = input<IntakeState>(initial);
|
|
||||||
|
|
||||||
readonly jaNee = JA_NEE;
|
|
||||||
readonly state = this.store.model;
|
readonly state = this.store.model;
|
||||||
readonly dispatch = this.store.dispatch;
|
readonly dispatch = this.store.dispatch;
|
||||||
|
|
||||||
// Backend draft-sync (replaces sessionStorage); the intake has no uploads.
|
// --- Static copy: stepper labels and per-step headings ---------------------
|
||||||
|
readonly stepLabels = [
|
||||||
|
$localize`:@@intake.step.buitenland:Buitenland`,
|
||||||
|
$localize`:@@intake.step.werk:Werk`,
|
||||||
|
$localize`:@@intake.step.controle:Controle`,
|
||||||
|
];
|
||||||
|
private stepTitles: Record<StepId, string> = {
|
||||||
|
buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,
|
||||||
|
werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,
|
||||||
|
review: $localize`:@@intake.title.review:Controleren en indienen`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- State projections: one narrow, then read-only views of it -------------
|
||||||
|
private answering = computed(() => whenTag(this.state(), 'Answering'));
|
||||||
|
/** Public so the showcase can render the (fixed) step list next to the wizard. */
|
||||||
|
readonly steps = STEPS;
|
||||||
|
protected cursor = computed(() => this.answering()?.cursor ?? 0);
|
||||||
|
protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
|
||||||
|
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
|
||||||
|
/** Server-owned threshold from the policy endpoint (mirrored into machine state). */
|
||||||
|
protected scholingThreshold = computed(
|
||||||
|
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
|
||||||
|
);
|
||||||
|
protected errors = computed<Errors>(() => this.answering()?.errors ?? {});
|
||||||
|
|
||||||
|
// --- Controllers: persistence and uploads ----------------------------------
|
||||||
|
// Create a Concept on first progress, then debounced-sync the snapshot.
|
||||||
|
// `?aanvraag=<id>` resumes it. The intake has no uploads.
|
||||||
private draftSync = createDraftSync({
|
private draftSync = createDraftSync({
|
||||||
type: 'intake',
|
type: 'intake',
|
||||||
snapshot: () => {
|
snapshot: () => {
|
||||||
@@ -311,30 +178,7 @@ export class IntakeWizardComponent {
|
|||||||
enabled: () => this.seed() === initial,
|
enabled: () => this.seed() === initial,
|
||||||
});
|
});
|
||||||
|
|
||||||
private answering = computed(() => whenTag(this.state(), 'Answering'));
|
|
||||||
/** Public so the showcase can render the (fixed) step list next to the wizard. */
|
|
||||||
readonly steps = STEPS;
|
|
||||||
protected cursor = computed(() => this.answering()?.cursor ?? 0);
|
|
||||||
protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
|
|
||||||
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
|
|
||||||
/** Server-owned threshold from the policy endpoint (mirrored into machine state). */
|
|
||||||
protected scholingThreshold = computed(
|
|
||||||
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
|
|
||||||
);
|
|
||||||
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
|
|
||||||
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
|
||||||
|
|
||||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||||
readonly stepLabels = [
|
|
||||||
$localize`:@@intake.step.buitenland:Buitenland`,
|
|
||||||
$localize`:@@intake.step.werk:Werk`,
|
|
||||||
$localize`:@@intake.step.controle:Controle`,
|
|
||||||
];
|
|
||||||
private stepTitles: Record<StepId, string> = {
|
|
||||||
buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,
|
|
||||||
werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,
|
|
||||||
review: $localize`:@@intake.title.review:Controleren en indienen`,
|
|
||||||
};
|
|
||||||
protected stepTitle = computed(() => this.stepTitles[this.step()]);
|
protected stepTitle = computed(() => this.stepTitles[this.step()]);
|
||||||
protected primaryLabel = computed(() => {
|
protected primaryLabel = computed(() => {
|
||||||
if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`;
|
if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`;
|
||||||
@@ -365,17 +209,16 @@ export class IntakeWizardComponent {
|
|||||||
toWizardErrors(this.answering()?.errors ?? {}),
|
toWizardErrors(this.answering()?.errors ?? {}),
|
||||||
);
|
);
|
||||||
|
|
||||||
protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
|
|
||||||
protected set = (key: keyof Answers, value: string) =>
|
|
||||||
this.dispatch({ tag: 'SetAnswer', key, value });
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
|
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
|
||||||
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
|
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
|
||||||
const seeded = this.seed();
|
// Read `seed()` INSIDE the microtask: Angular binds inputs after the constructor
|
||||||
queueMicrotask(() =>
|
// runs, so an eager read here always returns the `initial` default.
|
||||||
seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume(),
|
queueMicrotask(() => {
|
||||||
);
|
const seeded = this.seed();
|
||||||
|
if (seeded !== initial) this.dispatch({ tag: 'Seed', state: seeded });
|
||||||
|
else void this.draftSync.resume();
|
||||||
|
});
|
||||||
// Apply the server-owned threshold into machine state as it arrives. Track
|
// Apply the server-owned threshold into machine state as it arrives. Track
|
||||||
// only the policy value; untrack the dispatch (it reads the state signal
|
// only the policy value; untrack the dispatch (it reads the state signal
|
||||||
// internally, which would otherwise make this effect loop on its own write).
|
// internally, which would otherwise make this effect loop on its own write).
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
|
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
|
||||||
|
import { ReviewSectionComponent } from '@shared/ui/molecules/review-section/review-section.component';
|
||||||
|
import { Answers, lageUren } from '@herregistratie/domain/intake.machine';
|
||||||
|
|
||||||
|
/** Step: the intake wizard's review screen. Pure & presentational — values in via
|
||||||
|
`answers`/`scholingThreshold`, the cursor to jump back to out via `edit`. No
|
||||||
|
store, no services, no internal state; the parent maps the cursor onto its own
|
||||||
|
`GaNaarStap` message. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-intake-review-step',
|
||||||
|
imports: [AlertComponent, DataRowComponent, ReviewSectionComponent],
|
||||||
|
template: `
|
||||||
|
<app-alert type="info" i18n="@@intake.review.controleer"
|
||||||
|
>Controleer uw antwoorden en dien de aanvraag in.</app-alert
|
||||||
|
>
|
||||||
|
<app-review-section
|
||||||
|
i18n-heading="@@intake.sectie.buitenland"
|
||||||
|
heading="Buitenland"
|
||||||
|
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
|
||||||
|
editAriaLabel="Wijzigen buitenland"
|
||||||
|
(edit)="edit.emit(0)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@intake.review.buitenNl"
|
||||||
|
key="Buiten NL gewerkt"
|
||||||
|
[value]="answers().buitenlandGewerkt ?? '—'"
|
||||||
|
></div>
|
||||||
|
@if (answers().buitenlandGewerkt === 'ja') {
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@intake.review.land"
|
||||||
|
key="Land"
|
||||||
|
[value]="answers().land ?? ''"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@intake.review.buitenlandseUren"
|
||||||
|
key="Buitenlandse uren"
|
||||||
|
[value]="answers().buitenlandseUren ?? ''"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
</app-review-section>
|
||||||
|
<app-review-section
|
||||||
|
class="app-section"
|
||||||
|
i18n-heading="@@intake.sectie.werk"
|
||||||
|
heading="Werk in Nederland"
|
||||||
|
i18n-editAriaLabel="@@intake.werkWijzigenAria"
|
||||||
|
editAriaLabel="Wijzigen werk in Nederland"
|
||||||
|
(edit)="edit.emit(1)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@intake.review.urenNl"
|
||||||
|
key="Uren NL"
|
||||||
|
[value]="answers().uren ?? ''"
|
||||||
|
></div>
|
||||||
|
@if (scholingZichtbaar()) {
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@intake.review.scholing"
|
||||||
|
key="Aanvullende scholing"
|
||||||
|
[value]="answers().scholingGevolgd ?? ''"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
@if (answers().scholingGevolgd === 'ja') {
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@intake.review.punten"
|
||||||
|
key="Nascholingspunten"
|
||||||
|
[value]="answers().punten ?? ''"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
</app-review-section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ReviewStep {
|
||||||
|
answers = input.required<Answers>();
|
||||||
|
scholingThreshold = input.required<number>();
|
||||||
|
edit = output<number>();
|
||||||
|
|
||||||
|
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
|
||||||
|
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
|
||||||
|
import { RadioGroupComponent, JA_NEE } from '@shared/ui/atoms/radio-group/radio-group.component';
|
||||||
|
import { Answers, Errors, lageUren } from '@herregistratie/domain/intake.machine';
|
||||||
|
|
||||||
|
/** Step: the intake wizard's second screen (work experience in the Netherlands,
|
||||||
|
with the inline scholing follow-up). Pure & presentational — values in via
|
||||||
|
`answers`/`errors`/`scholingThreshold`, every keystroke out via `answerChange`.
|
||||||
|
No store, no services, no internal state; the parent owns the Model and
|
||||||
|
decides what a change means. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-intake-werk-step',
|
||||||
|
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent],
|
||||||
|
template: `
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@intake.q.urenNl"
|
||||||
|
label="Gewerkte uren in Nederland (afgelopen 5 jaar)"
|
||||||
|
fieldId="uren"
|
||||||
|
required
|
||||||
|
[error]="err('uren')"
|
||||||
|
>
|
||||||
|
<app-text-input
|
||||||
|
inputId="uren"
|
||||||
|
[ngModel]="answers().uren ?? ''"
|
||||||
|
(ngModelChange)="answerChange.emit({ key: 'uren', value: $event })"
|
||||||
|
name="uren"
|
||||||
|
i18n-placeholder="@@intake.q.urenNlPlaceholder"
|
||||||
|
placeholder="bijv. 4160"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
@if (scholingZichtbaar()) {
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@intake.q.scholing"
|
||||||
|
label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?"
|
||||||
|
fieldId="scholingGevolgd"
|
||||||
|
required
|
||||||
|
[error]="err('scholingGevolgd')"
|
||||||
|
>
|
||||||
|
<app-radio-group
|
||||||
|
name="scholingGevolgd"
|
||||||
|
[options]="jaNee"
|
||||||
|
[ngModel]="answers().scholingGevolgd ?? ''"
|
||||||
|
(ngModelChange)="answerChange.emit({ key: 'scholingGevolgd', value: $event })"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
@if (answers().scholingGevolgd === 'ja') {
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@intake.q.punten"
|
||||||
|
label="Behaalde nascholingspunten"
|
||||||
|
fieldId="punten"
|
||||||
|
required
|
||||||
|
[error]="err('punten')"
|
||||||
|
>
|
||||||
|
<app-text-input
|
||||||
|
inputId="punten"
|
||||||
|
[ngModel]="answers().punten ?? ''"
|
||||||
|
(ngModelChange)="answerChange.emit({ key: 'punten', value: $event })"
|
||||||
|
name="punten"
|
||||||
|
i18n-placeholder="@@intake.q.puntenPlaceholder"
|
||||||
|
placeholder="bijv. 200"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class WerkStep {
|
||||||
|
answers = input.required<Answers>();
|
||||||
|
errors = input.required<Errors>();
|
||||||
|
scholingThreshold = input.required<number>();
|
||||||
|
answerChange = output<{ key: keyof Answers; value: string }>();
|
||||||
|
|
||||||
|
readonly jaNee = JA_NEE;
|
||||||
|
protected err = (k: keyof Answers) => this.errors()[k] ?? '';
|
||||||
|
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
|
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
|
||||||
|
|
||||||
/** Page: the branching intake questionnaire. Built entirely from existing
|
/** Page: the branching intake questionnaire. Built entirely from existing
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
|
import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
|
||||||
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
|
import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
|
||||||
import { AccessStore } from '@shared/application/access.store';
|
import { AccessStore } from '@shared/application/access.store';
|
||||||
import { HEADER_ADMIN_LINKS } from '@shared/layout/site-header/nav-config';
|
import { HEADER_ADMIN_LINKS } from '@shared/layout/site-header/nav-config';
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { MijnAanvragenSection } from '@registratie/ui/dashboard/mijn-aanvragen.section';
|
import { MijnAanvragenSection } from '@registratie/ui/overzicht-secties/mijn-aanvragen.section';
|
||||||
import { WatMoetIkRegelenSection } from '@registratie/ui/dashboard/wat-moet-ik-regelen.section';
|
import { WatMoetIkRegelenSection } from '@registratie/ui/overzicht-secties/wat-moet-ik-regelen.section';
|
||||||
import { MijnRegistratieSection } from '@registratie/ui/dashboard/mijn-registratie.section';
|
import { MijnRegistratieSection } from '@registratie/ui/overzicht-secties/mijn-registratie.section';
|
||||||
import { SpecialismenSection } from '@registratie/ui/dashboard/specialismen.section';
|
import { SpecialismenSection } from '@registratie/ui/overzicht-secties/specialismen.section';
|
||||||
import { WatWiltUDoenSection } from './wat-wilt-u-doen.section';
|
import { WatWiltUDoenSection } from './wat-wilt-u-doen.section';
|
||||||
import { BeheerLinksSection } from './beheer-links.section';
|
import { BeheerLinksSection } from './beheer-links.section';
|
||||||
|
|
||||||
/** Page: "Mijn overzicht" — the portal home, following the NL Design System "Mijn
|
/** Page: "Mijn overzicht" — the portal home, following the NL Design System "Mijn
|
||||||
omgeving" pattern. Composition only: each section below answers its own data
|
omgeving" pattern. Composition only: each section below answers its own data
|
||||||
question (own store, own async state) — four sections stay in `registratie/ui/dashboard/`
|
question (own store, own async state) — four sections stay in `registratie/ui/overzicht-secties/`
|
||||||
(they render registratie data), two live here (cross-context navigation). */
|
(they render registratie data), two live here (cross-context navigation). */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-overzicht-page',
|
selector: 'app-overzicht-page',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
|
import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
|
||||||
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
|
import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
|
||||||
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
|
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
|
||||||
import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag';
|
import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag';
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
reduceUpload,
|
reduceUpload,
|
||||||
requiredCategoriesSatisfied,
|
requiredCategoriesSatisfied,
|
||||||
deliveryRefs,
|
deliveryRefs,
|
||||||
|
digitalDocumentIds,
|
||||||
} from '@shared/domain/upload.machine';
|
} from '@shared/domain/upload.machine';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,7 +110,7 @@ export function hasProgress(s: Extract<RegistratieState, { tag: 'Invullen' }>):
|
|||||||
!!d.email ||
|
!!d.email ||
|
||||||
!!d.diplomaId ||
|
!!d.diplomaId ||
|
||||||
!!d.beroep ||
|
!!d.beroep ||
|
||||||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)
|
digitalDocumentIds(s.upload).length > 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, computed, input, output } from '@angular/core';
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
import { formatDatumNl } from '@shared/kernel/datum';
|
import { formatDatumNl } from '@shared/kernel/datum';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { Aanvraag } from '@registratie/domain/aanvraag';
|
import { Aanvraag } from '@registratie/domain/aanvraag';
|
||||||
import { TYPE_LABELS } from '@registratie/domain/aanvraag-view';
|
import { TYPE_LABELS } from '@registratie/domain/aanvraag-view';
|
||||||
import { blockActions } from '@registratie/domain/block-actions';
|
import { blockActions } from '@registratie/domain/block-actions';
|
||||||
|
|||||||
@@ -17,57 +17,17 @@ const meta: Meta<AanvraagBlockComponent> = {
|
|||||||
title: 'Domein/Registratie/Aanvraag Block',
|
title: 'Domein/Registratie/Aanvraag Block',
|
||||||
component: AanvraagBlockComponent,
|
component: AanvraagBlockComponent,
|
||||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||||
render: (args) => ({
|
// A Concept renders as a CIBG melding (block element), not a keuzelijst <li> — no <ul>
|
||||||
props: args,
|
// wrapper. Production agrees: mijn-aanvragen.section.ts renders this block for concepten
|
||||||
// A row is an <li> — the keuzelijst styling needs the real list context.
|
// only, outside any list.
|
||||||
template: `<ul class="keuzelijst__list"><app-aanvraag-block [aanvraag]="aanvraag" /></ul>`,
|
render: (args) => ({ props: args, template: `<app-aanvraag-block [aanvraag]="aanvraag" />` }),
|
||||||
}),
|
|
||||||
parameters: {
|
|
||||||
// Structural: app-aanvraag-block's host sits between the keuzelijst <ul> and its <li>
|
|
||||||
// — axe's list/listitem rule needs them adjacent regardless of `display:contents`.
|
|
||||||
// WP-11 (CIBG markup fidelity) reworks this markup; see docs/project/backlog/WP-11-markup-fidelity.md.
|
|
||||||
a11y: { disable: true },
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<AanvraagBlockComponent>;
|
type Story = StoryObj<AanvraagBlockComponent>;
|
||||||
|
|
||||||
// One story per status variant; the block renders its own body + actions.
|
// The whole template sits inside `@if (aanvraag().status.tag === 'Concept')`, so this is
|
||||||
// A Concept renders as a CIBG melding (block element), not a keuzelijst <li> — no <ul> wrapper.
|
// the only status that renders anything. Submitted/resolved aanvragen render through
|
||||||
|
// application-link, which has its own stories.
|
||||||
export const Concept: Story = {
|
export const Concept: Story = {
|
||||||
args: { aanvraag: { ...base, status: { tag: 'Concept', stepIndex: 1, stepCount: 3 } } },
|
args: { aanvraag: { ...base, status: { tag: 'Concept', stepIndex: 1, stepCount: 3 } } },
|
||||||
render: (args) => ({ props: args, template: `<app-aanvraag-block [aanvraag]="aanvraag" />` }),
|
|
||||||
};
|
|
||||||
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.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { Component, computed, inject } from '@angular/core';
|
|||||||
import { successOf } from '@shared/application/remote-data';
|
import { successOf } from '@shared/application/remote-data';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
|
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
|
||||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||||
import { AanvragenStore } from '@registratie/application/aanvragen.store';
|
import { AanvragenStore } from '@registratie/application/aanvragen.store';
|
||||||
import { Aanvraag } from '@registratie/domain/aanvraag';
|
import { Aanvraag } from '@registratie/domain/aanvraag';
|
||||||
import { detailRows } from '@registratie/domain/aanvraag-view';
|
import { detailRows } from '@registratie/domain/aanvraag-view';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, input, output } from '@angular/core';
|
import { Component, input, output } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
|
||||||
|
|
||||||
export interface AdresValue {
|
export interface AdresValue {
|
||||||
straat: string;
|
straat: string;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Component, computed, effect, inject } from '@angular/core';
|
import { Component, computed, effect, inject } from '@angular/core';
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
|
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
|
||||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||||
import { AccessStore } from '@shared/application/access.store';
|
import { AccessStore } from '@shared/application/access.store';
|
||||||
import { successOr } from '@shared/application/remote-data';
|
import { successOr } from '@shared/application/remote-data';
|
||||||
import { formatDatumNl } from '@shared/kernel/datum';
|
import { formatDatumNl } from '@shared/kernel/datum';
|
||||||
|
|||||||
+7
-7
@@ -1,12 +1,12 @@
|
|||||||
import { Component, computed, input } from '@angular/core';
|
import { Component, computed, input } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
|
||||||
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
|
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
|
||||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
|
||||||
import { Adres } from '@registratie/domain/person';
|
import { Adres } from '@registratie/domain/person';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
import { whenTag } from '@shared/kernel/fp';
|
import { whenTag } from '@shared/kernel/fp';
|
||||||
|
|||||||
+6
-6
@@ -1,11 +1,11 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
|
import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
|
||||||
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
|
import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||||
import { successOr } from '@shared/application/remote-data';
|
import { successOr } from '@shared/application/remote-data';
|
||||||
import { AanvragenStore } from '@registratie/application/aanvragen.store';
|
import { AanvragenStore } from '@registratie/application/aanvragen.store';
|
||||||
import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag';
|
import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag';
|
||||||
+5
-5
@@ -1,10 +1,10 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { successOf } from '@shared/application/remote-data';
|
import { successOf } from '@shared/application/remote-data';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
|
||||||
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
|
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
|
||||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||||
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
||||||
|
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { successOf } from '@shared/application/remote-data';
|
import { successOf } from '@shared/application/remote-data';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||||
import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';
|
import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';
|
||||||
|
|
||||||
-8
@@ -66,14 +66,6 @@ export const MetTaken: Story = {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
parameters: {
|
|
||||||
// Structural: app-choice-link's host sits between the keuzelijst <ul> and its <li>
|
|
||||||
// — axe's list/listitem rule needs them adjacent regardless of `display:contents`.
|
|
||||||
// Same pre-existing gap as task-list.stories.ts and choice-list.stories.ts. WP-11
|
|
||||||
// (CIBG markup fidelity) reworks this markup; see
|
|
||||||
// docs/project/backlog/WP-11-markup-fidelity.md.
|
|
||||||
a11y: { disable: true },
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
export const NietsOpenstaand: Story = {
|
export const NietsOpenstaand: Story = {
|
||||||
decorators: [
|
decorators: [
|
||||||
+5
-5
@@ -1,10 +1,10 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { successOf } from '@shared/application/remote-data';
|
import { successOf } from '@shared/application/remote-data';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
|
||||||
import { TaskListComponent } from '@shared/ui/task-list/task-list.component';
|
import { TaskListComponent } from '@shared/ui/molecules/task-list/task-list.component';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||||
import { tasksFromProfile } from '@registratie/domain/tasks';
|
import { tasksFromProfile } from '@registratie/domain/tasks';
|
||||||
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Component, inject, input, output } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
|
||||||
|
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
|
||||||
|
import { RadioGroupComponent } from '@shared/ui/atoms/radio-group/radio-group.component';
|
||||||
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
|
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
|
||||||
|
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
|
||||||
|
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
|
||||||
|
import { Draft, DraftField, Errors } from '@registratie/domain/registratie-wizard.machine';
|
||||||
|
|
||||||
|
const KANALEN = [
|
||||||
|
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
|
||||||
|
{ value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Step: the registratie wizard's first screen (adres + correspondentievoorkeur).
|
||||||
|
Injects RegistratieLookupStore directly for the BRP lookup banner — the
|
||||||
|
sanctioned exception (it is `providedIn: 'root'`, so every injection is the
|
||||||
|
same instance): the step owns its own async presentation rather than making
|
||||||
|
the parent a pass-through for it. Values otherwise in via `draft`/`errors`,
|
||||||
|
every change out via `fieldChange`/`kanaalChange`. No internal state; the
|
||||||
|
parent owns the Model and decides what a change means. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-reg-adres-step',
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
FormFieldComponent,
|
||||||
|
TextInputComponent,
|
||||||
|
RadioGroupComponent,
|
||||||
|
AlertComponent,
|
||||||
|
SkeletonComponent,
|
||||||
|
AddressFieldsComponent,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
@if (adresStatus() === 'laden') {
|
||||||
|
<app-skeleton height="2.5rem" [count]="4" />
|
||||||
|
} @else {
|
||||||
|
@switch (adresStatus()) {
|
||||||
|
@case ('gevonden') {
|
||||||
|
<app-alert type="info" i18n="@@regWizard.brpGevonden"
|
||||||
|
>Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert
|
||||||
|
>
|
||||||
|
}
|
||||||
|
@case ('geen') {
|
||||||
|
<app-alert type="warning" i18n="@@regWizard.brpGeen"
|
||||||
|
>We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert
|
||||||
|
>
|
||||||
|
}
|
||||||
|
@case ('fout') {
|
||||||
|
<app-alert type="warning" i18n="@@regWizard.brpFout"
|
||||||
|
>We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in.</app-alert
|
||||||
|
>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<app-address-fields
|
||||||
|
[value]="{
|
||||||
|
straat: draft().straat ?? '',
|
||||||
|
postcode: draft().postcode ?? '',
|
||||||
|
woonplaats: draft().woonplaats ?? '',
|
||||||
|
}"
|
||||||
|
[errors]="{
|
||||||
|
straat: err('straat'),
|
||||||
|
postcode: err('postcode'),
|
||||||
|
woonplaats: err('woonplaats'),
|
||||||
|
}"
|
||||||
|
(fieldChange)="fieldChange.emit($event)"
|
||||||
|
/>
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@regWizard.correspondentieLabel"
|
||||||
|
label="Hoe wilt u correspondentie ontvangen?"
|
||||||
|
fieldId="correspondentie"
|
||||||
|
required
|
||||||
|
[error]="err('correspondentie')"
|
||||||
|
>
|
||||||
|
<app-radio-group
|
||||||
|
name="correspondentie"
|
||||||
|
[options]="kanalen"
|
||||||
|
[invalid]="!!err('correspondentie')"
|
||||||
|
[ngModel]="draft().correspondentie ?? ''"
|
||||||
|
(ngModelChange)="kanaalChange.emit($event)"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
@if (draft().correspondentie === 'email') {
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@regWizard.emailLabel"
|
||||||
|
label="E-mailadres"
|
||||||
|
fieldId="email"
|
||||||
|
required
|
||||||
|
[error]="err('email')"
|
||||||
|
>
|
||||||
|
<app-text-input
|
||||||
|
inputId="email"
|
||||||
|
type="email"
|
||||||
|
[invalid]="!!err('email')"
|
||||||
|
[ngModel]="draft().email ?? ''"
|
||||||
|
(ngModelChange)="fieldChange.emit({ key: 'email', value: $event })"
|
||||||
|
name="email"
|
||||||
|
i18n-placeholder="@@regWizard.emailPlaceholder"
|
||||||
|
placeholder="naam@voorbeeld.nl"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class AdresStep {
|
||||||
|
private lookup = inject(RegistratieLookupStore);
|
||||||
|
|
||||||
|
draft = input.required<Draft>();
|
||||||
|
errors = input.required<Errors>();
|
||||||
|
fieldChange = output<{ key: DraftField; value: string }>();
|
||||||
|
kanaalChange = output<string>();
|
||||||
|
|
||||||
|
protected adresStatus = this.lookup.adresStatus;
|
||||||
|
readonly kanalen = KANALEN;
|
||||||
|
protected err = (k: DraftField | 'correspondentie') => this.errors()[k] ?? '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { Component, computed, inject, input, output } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
|
||||||
|
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
|
||||||
|
import { RadioGroupComponent, JA_NEE } from '@shared/ui/atoms/radio-group/radio-group.component';
|
||||||
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
|
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
|
||||||
|
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
|
||||||
|
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
|
||||||
|
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||||
|
import { DocumentUploadComponent } from '@shared/ui/organisms/upload/document-upload/document-upload.component';
|
||||||
|
import { createUploadController } from '@shared/application/upload-controller';
|
||||||
|
import { UploadMsg, UploadState } from '@shared/domain/upload.machine';
|
||||||
|
import { RemoteData, successOr } from '@shared/application/remote-data';
|
||||||
|
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
|
||||||
|
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
|
||||||
|
import { Draft, Errors } from '@registratie/domain/registratie-wizard.machine';
|
||||||
|
|
||||||
|
/** The server-owned geldigheidsvraag whose "ja" answer requires a Dutch-taalvaardigheid
|
||||||
|
upload (proof of the confirmed B2 level). Stable id shared with the backend. */
|
||||||
|
const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
|
||||||
|
/** Sentinel option: "my diploma isn't listed". Exported so the parent's
|
||||||
|
`onDiplomaKeuze` (registratie-wizard.component.ts) can recognize it too. */
|
||||||
|
export const HANDMATIG = '__handmatig__';
|
||||||
|
|
||||||
|
/** Step: the registratie wizard's second screen (beroep op basis van diploma).
|
||||||
|
Injects RegistratieLookupStore directly for the DUO lookup — the sanctioned
|
||||||
|
exception (it is `providedIn: 'root'`, so every injection is the same
|
||||||
|
instance) — and owns its own `<app-async>` over it. Also owns the upload
|
||||||
|
controller, moved here from the parent: this is what gets the parent under
|
||||||
|
the line limit. Values in via `draft`/`errors`/`upload`; every user intent
|
||||||
|
leaves as one of four outputs. No store beyond the lookup, and no machine
|
||||||
|
message built here; the parent maps each output onto its own message. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-reg-beroep-step',
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
FormFieldComponent,
|
||||||
|
TextInputComponent,
|
||||||
|
RadioGroupComponent,
|
||||||
|
AlertComponent,
|
||||||
|
SkeletonComponent,
|
||||||
|
DataRowComponent,
|
||||||
|
DataBlockComponent,
|
||||||
|
DocumentUploadComponent,
|
||||||
|
...ASYNC,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<app-async [data]="lookupRd()">
|
||||||
|
<ng-template appAsyncLoaded>
|
||||||
|
@if (duoData(); as data) {
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@regWizard.diplomaLabel"
|
||||||
|
label="Kies het diploma waarmee u zich wilt registreren"
|
||||||
|
fieldId="diploma"
|
||||||
|
required
|
||||||
|
[error]="err('diploma')"
|
||||||
|
>
|
||||||
|
<app-radio-group
|
||||||
|
name="diploma"
|
||||||
|
[options]="diplomaOptions(data)"
|
||||||
|
[invalid]="!!err('diploma')"
|
||||||
|
[ngModel]="diplomaKeuze()"
|
||||||
|
(ngModelChange)="diplomaChosen.emit($event)"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
@if (handmatigActief()) {
|
||||||
|
<app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing"
|
||||||
|
>Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw
|
||||||
|
beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig
|
||||||
|
beoordeeld.</app-alert
|
||||||
|
>
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@regWizard.beroepLabel"
|
||||||
|
label="Voor welk beroep wilt u zich registreren?"
|
||||||
|
fieldId="hm-beroep"
|
||||||
|
[error]="err('diploma')"
|
||||||
|
>
|
||||||
|
<app-radio-group
|
||||||
|
name="hm-beroep"
|
||||||
|
[options]="beroepOptions(data)"
|
||||||
|
[invalid]="!!err('diploma')"
|
||||||
|
[ngModel]="draft().beroep ?? ''"
|
||||||
|
(ngModelChange)="beroepDeclared.emit($event)"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
} @else if (draft().beroep) {
|
||||||
|
<app-data-block class="app-section">
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@regWizard.beroepAfgeleid"
|
||||||
|
key="Beroep (afgeleid uit diploma)"
|
||||||
|
[value]="draft().beroep ?? ''"
|
||||||
|
></div>
|
||||||
|
</app-data-block>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (actieveVragen(data).length) {
|
||||||
|
<fieldset>
|
||||||
|
@for (q of actieveVragen(data); track q.id) {
|
||||||
|
<app-form-field
|
||||||
|
[label]="q.vraag"
|
||||||
|
[fieldId]="'vraag-' + q.id"
|
||||||
|
[error]="vraagErr(q.id)"
|
||||||
|
>
|
||||||
|
@if (q.type === 'ja-nee') {
|
||||||
|
<app-radio-group
|
||||||
|
[name]="'vraag-' + q.id"
|
||||||
|
[options]="jaNee"
|
||||||
|
[invalid]="!!vraagErr(q.id)"
|
||||||
|
[ngModel]="antwoord(q.id)"
|
||||||
|
(ngModelChange)="antwoordChange.emit({ vraagId: q.id, value: $event })"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<app-text-input
|
||||||
|
[inputId]="'vraag-' + q.id"
|
||||||
|
[invalid]="!!vraagErr(q.id)"
|
||||||
|
[ngModel]="antwoord(q.id)"
|
||||||
|
(ngModelChange)="antwoordChange.emit({ vraagId: q.id, value: $event })"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</app-form-field>
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
<ng-template appAsyncLoading>
|
||||||
|
<app-skeleton height="2.5rem" [count]="3" />
|
||||||
|
</ng-template>
|
||||||
|
</app-async>
|
||||||
|
|
||||||
|
<app-document-upload
|
||||||
|
class="app-section"
|
||||||
|
[state]="upload()"
|
||||||
|
[previewUrlFor]="previewUrlFor"
|
||||||
|
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
|
||||||
|
(removeUpload)="uploadCtl.onRemove($event)"
|
||||||
|
(retryUpload)="uploadCtl.onRetry($event)"
|
||||||
|
(deleteUpload)="uploadCtl.onDelete($event)"
|
||||||
|
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)"
|
||||||
|
/>
|
||||||
|
@if (err('documenten')) {
|
||||||
|
<app-alert type="warning">{{ err('documenten') }}</app-alert>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class BeroepStep {
|
||||||
|
private lookup = inject(RegistratieLookupStore);
|
||||||
|
|
||||||
|
draft = input.required<Draft>();
|
||||||
|
errors = input.required<Errors>();
|
||||||
|
upload = input.required<UploadState>();
|
||||||
|
|
||||||
|
uploadMsg = output<UploadMsg>();
|
||||||
|
antwoordChange = output<{ vraagId: string; value: string }>();
|
||||||
|
diplomaChosen = output<string>();
|
||||||
|
beroepDeclared = output<string>();
|
||||||
|
|
||||||
|
/** Preview/download link for a completed upload; delegates to the upload
|
||||||
|
controller (application layer), which knows the dev-simulation `demo-*`
|
||||||
|
ids have no stored bytes and returns no link for them. */
|
||||||
|
protected previewUrlFor = (documentId: string): string | undefined =>
|
||||||
|
this.uploadCtl.previewUrlFor(documentId);
|
||||||
|
|
||||||
|
protected uploadCtl = createUploadController({
|
||||||
|
wizardId: 'registratie',
|
||||||
|
getUpload: () => this.upload(),
|
||||||
|
dispatch: (msg) => this.uploadMsg.emit(msg),
|
||||||
|
// Required documents depend on answers (server decides): a diploma upload only for a
|
||||||
|
// manual diploma; a Dutch-taalvaardigheid upload only once the applicant confirms
|
||||||
|
// ("ja") the B2 language requirement.
|
||||||
|
getCategoryParams: () => ({
|
||||||
|
diplomaHerkomst: this.draft().diplomaHerkomst,
|
||||||
|
taalvaardigheid: this.draft().antwoorden[NL_TAALVAARDIGHEID_VRAAG],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Parsed DUO lookup (validated at the trust boundary by the application
|
||||||
|
facade — the step renders, it does not fetch/parse). */
|
||||||
|
protected lookupRd: () => RemoteData<Error | undefined, DuoLookupDto> = this.lookup.duoLookup;
|
||||||
|
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookupRd(), null));
|
||||||
|
|
||||||
|
readonly jaNee = JA_NEE;
|
||||||
|
|
||||||
|
protected err = (k: 'diploma' | 'documenten') => this.errors()[k] ?? '';
|
||||||
|
protected vraagErr = (id: string) => this.errors().antwoorden?.[id] ?? '';
|
||||||
|
protected antwoord = (id: string) => this.draft().antwoorden[id] ?? ''; // runtime guard: missing key → undefined
|
||||||
|
|
||||||
|
/** True while the user is entering a diploma manually (not in the DUO list). */
|
||||||
|
protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig');
|
||||||
|
/** The radio selection: a diploma id, or the "not listed" sentinel in manual mode. */
|
||||||
|
protected diplomaKeuze = computed(() =>
|
||||||
|
this.handmatigActief() ? HANDMATIG : (this.draft().diplomaId ?? ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected diplomaOptions = (data: DuoLookupDto) => [
|
||||||
|
...data.diplomas.map((d) => ({
|
||||||
|
value: d.id,
|
||||||
|
label: `${d.naam} — ${d.instelling} (${d.jaar})`,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
value: HANDMATIG,
|
||||||
|
label: $localize`:@@regWizard.diplomaNietBij:Mijn diploma staat er niet bij`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
protected beroepOptions = (data: DuoLookupDto) =>
|
||||||
|
data.handmatig.beroepen.map((b) => ({ value: b, label: b }));
|
||||||
|
|
||||||
|
/** The policy questions that apply to the current choice (server-decided). */
|
||||||
|
protected actieveVragen = (data: DuoLookupDto): PolicyQuestionDto[] => {
|
||||||
|
if (this.handmatigActief()) return data.handmatig.policyQuestions;
|
||||||
|
return data.diplomas.find((d) => d.id === this.draft().diplomaId)?.policyQuestions ?? [];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { Component, computed, inject, input, output } from '@angular/core';
|
||||||
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
|
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
|
||||||
|
import { ReviewSectionComponent } from '@shared/ui/molecules/review-section/review-section.component';
|
||||||
|
import { successOr } from '@shared/application/remote-data';
|
||||||
|
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
|
||||||
|
import { DuoLookupDto } from '@registratie/contracts/duo-diplomas.dto';
|
||||||
|
import { Draft } from '@registratie/domain/registratie-wizard.machine';
|
||||||
|
|
||||||
|
/** Step: the registratie wizard's review screen (controle & indienen). Injects
|
||||||
|
RegistratieLookupStore directly to build `samenvattingVragen` — the
|
||||||
|
sanctioned exception (it is `providedIn: 'root'`, so every injection is the
|
||||||
|
same instance). Values otherwise in via `draft`, the cursor to jump back to
|
||||||
|
out via `edit`. No internal state; the parent maps the cursor onto its own
|
||||||
|
`GaNaarStap` message. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-reg-controle-step',
|
||||||
|
imports: [AlertComponent, DataRowComponent, ReviewSectionComponent],
|
||||||
|
template: `
|
||||||
|
<app-alert type="info" i18n="@@regWizard.controleer"
|
||||||
|
>Controleer uw gegevens en dien de registratie in.</app-alert
|
||||||
|
>
|
||||||
|
<app-review-section
|
||||||
|
i18n-heading="@@regWizard.sectie.adres"
|
||||||
|
heading="Adres en correspondentie"
|
||||||
|
i18n-editAriaLabel="@@regWizard.adresWijzigenAria"
|
||||||
|
editAriaLabel="Wijzigen adresgegevens"
|
||||||
|
(edit)="edit.emit(0)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@regWizard.summary.adres"
|
||||||
|
key="Adres"
|
||||||
|
[value]="adresSamenvatting()"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@regWizard.summary.herkomstAdres"
|
||||||
|
key="Herkomst adres"
|
||||||
|
[value]="adresHerkomstLabel()"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@regWizard.summary.correspondentie"
|
||||||
|
key="Correspondentie"
|
||||||
|
[value]="correspondentieLabel()"
|
||||||
|
></div>
|
||||||
|
@if (draft().correspondentie === 'email') {
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@regWizard.summary.email"
|
||||||
|
key="E-mailadres"
|
||||||
|
[value]="draft().email ?? ''"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
</app-review-section>
|
||||||
|
<app-review-section
|
||||||
|
class="app-section"
|
||||||
|
i18n-heading="@@regWizard.sectie.beroep"
|
||||||
|
heading="Beroep en diploma"
|
||||||
|
i18n-editAriaLabel="@@regWizard.diplomaWijzigenAria"
|
||||||
|
editAriaLabel="Wijzigen beroep en diploma"
|
||||||
|
(edit)="edit.emit(1)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@regWizard.summary.beroep"
|
||||||
|
key="Beroep"
|
||||||
|
[value]="draft().beroep ?? ''"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@regWizard.summary.herkomstDiploma"
|
||||||
|
key="Herkomst diploma"
|
||||||
|
[value]="diplomaHerkomstLabel()"
|
||||||
|
></div>
|
||||||
|
@for (item of samenvattingVragen(); track item.vraag) {
|
||||||
|
<div app-data-row [key]="item.vraag" [value]="item.antwoord"></div>
|
||||||
|
}
|
||||||
|
</app-review-section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ControleStep {
|
||||||
|
private lookup = inject(RegistratieLookupStore);
|
||||||
|
|
||||||
|
draft = input.required<Draft>();
|
||||||
|
edit = output<number>();
|
||||||
|
|
||||||
|
/** Parsed DUO lookup as a plain value (or null), needed here only to resolve
|
||||||
|
the answered policy questions' text for the summary. */
|
||||||
|
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookup.duoLookup(), null));
|
||||||
|
|
||||||
|
protected adresSamenvatting = computed(() => {
|
||||||
|
const d = this.draft();
|
||||||
|
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
});
|
||||||
|
// Readable labels for the controle summary (instead of raw enum values).
|
||||||
|
protected adresHerkomstLabel = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
brp: $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'],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Answered policy questions for the controle summary (question text + answer). */
|
||||||
|
protected samenvattingVragen = computed(() => {
|
||||||
|
const data = this.duoData();
|
||||||
|
const d = this.draft();
|
||||||
|
if (!data) return [] as { vraag: string; antwoord: string }[];
|
||||||
|
const alle = [
|
||||||
|
...data.diplomas.flatMap((x) => x.policyQuestions),
|
||||||
|
...data.handmatig.policyQuestions,
|
||||||
|
];
|
||||||
|
return (d.vraagIds ?? []).map((id) => ({
|
||||||
|
vraag: alle.find((q) => q.id === id)?.vraag ?? id,
|
||||||
|
antwoord: d.antwoorden[id] ?? '',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DuoLookupDto } from '@registratie/contracts/duo-diplomas.dto';
|
||||||
|
import { diplomaMsg } from './diploma-msg';
|
||||||
|
import { HANDMATIG } from './beroep.step';
|
||||||
|
|
||||||
|
const data: DuoLookupDto = {
|
||||||
|
diplomas: [
|
||||||
|
{
|
||||||
|
id: 'd1',
|
||||||
|
naam: 'Verpleegkunde',
|
||||||
|
instelling: 'Hogeschool Utrecht',
|
||||||
|
jaar: 2019,
|
||||||
|
beroep: 'Verpleegkundige',
|
||||||
|
policyQuestions: [
|
||||||
|
{ id: 'q1', vraag: 'Vraag 1', type: 'ja-nee' },
|
||||||
|
{ id: 'q2', vraag: 'Vraag 2', type: 'tekst' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
handmatig: {
|
||||||
|
beroepen: ['Verpleegkundige', 'Arts'],
|
||||||
|
policyQuestions: [{ id: 'm1', vraag: 'Handmatige vraag', type: 'ja-nee' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('diplomaMsg', () => {
|
||||||
|
it('resolves a known diploma into KiesDiploma with the server-derived beroep', () => {
|
||||||
|
expect(diplomaMsg(data, 'd1')).toEqual({
|
||||||
|
tag: 'KiesDiploma',
|
||||||
|
diplomaId: 'd1',
|
||||||
|
beroep: 'Verpleegkundige',
|
||||||
|
vraagIds: ['q1', 'q2'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the manual sentinel into KiesHandmatig with the maximal question set', () => {
|
||||||
|
expect(diplomaMsg(data, HANDMATIG)).toEqual({ tag: 'KiesHandmatig', vraagIds: ['m1'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for an unknown diploma id', () => {
|
||||||
|
expect(diplomaMsg(data, 'onbekend')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { DuoLookupDto } from '@registratie/contracts/duo-diplomas.dto';
|
||||||
|
import { RegistratieMsg } from '@registratie/domain/registratie-wizard.machine';
|
||||||
|
import { HANDMATIG } from './beroep.step';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the diploma that the user picked into the machine message it implies.
|
||||||
|
*
|
||||||
|
* The DUO payload maps a diploma id onto a server-derived beroep and the policy
|
||||||
|
* questions that apply to it. Reading that map is message construction, so it
|
||||||
|
* belongs beside the container, not in the beroep step. The backend stays the
|
||||||
|
* authority on both values (ADR-0001) — this function only selects them.
|
||||||
|
*
|
||||||
|
* Returns null when the id matches no known diploma. The caller then dispatches
|
||||||
|
* nothing and the wizard keeps its current state.
|
||||||
|
*/
|
||||||
|
export function diplomaMsg(data: DuoLookupDto, id: string): RegistratieMsg | null {
|
||||||
|
if (id === HANDMATIG) {
|
||||||
|
return { tag: 'KiesHandmatig', vraagIds: data.handmatig.policyQuestions.map((q) => q.id) };
|
||||||
|
}
|
||||||
|
const diploma = data.diplomas.find((d) => d.id === id);
|
||||||
|
if (!diploma) return null;
|
||||||
|
return {
|
||||||
|
tag: 'KiesDiploma',
|
||||||
|
diplomaId: diploma.id,
|
||||||
|
beroep: diploma.beroep,
|
||||||
|
vraagIds: diploma.policyQuestions.map((q) => q.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
+85
-437
@@ -1,16 +1,6 @@
|
|||||||
/* eslint-disable max-lines */ // one wizard shell for 3 steps + upload — removed by RD-23
|
|
||||||
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
|
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
|
||||||
import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component';
|
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
|
||||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
|
||||||
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
|
|
||||||
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
|
|
||||||
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
|
||||||
import {
|
import {
|
||||||
WizardShellComponent,
|
WizardShellComponent,
|
||||||
WizardError,
|
WizardError,
|
||||||
@@ -18,19 +8,17 @@ import {
|
|||||||
naarStapLabel,
|
naarStapLabel,
|
||||||
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||||
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
|
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
|
||||||
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
|
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
import { whenTag } from '@shared/kernel/fp';
|
import { whenTag } from '@shared/kernel/fp';
|
||||||
import { RemoteData, successOr } from '@shared/application/remote-data';
|
import { successOr } from '@shared/application/remote-data';
|
||||||
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
|
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
|
||||||
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
|
import { DuoLookupDto } from '@registratie/contracts/duo-diplomas.dto';
|
||||||
import {
|
import {
|
||||||
RegistratieState,
|
RegistratieState,
|
||||||
RegistratieMsg,
|
RegistratieMsg,
|
||||||
Draft,
|
Draft,
|
||||||
DraftField,
|
|
||||||
Correspondentie,
|
Correspondentie,
|
||||||
|
Errors,
|
||||||
StepId,
|
StepId,
|
||||||
initial,
|
initial,
|
||||||
reduce,
|
reduce,
|
||||||
@@ -38,44 +26,28 @@ import {
|
|||||||
STEPS,
|
STEPS,
|
||||||
} from '@registratie/domain/registratie-wizard.machine';
|
} from '@registratie/domain/registratie-wizard.machine';
|
||||||
import { createDraftSync } from '@registratie/application/draft-sync';
|
import { createDraftSync } from '@registratie/application/draft-sync';
|
||||||
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
|
import { UploadState, initialUpload, digitalDocumentIds } from '@shared/domain/upload.machine';
|
||||||
import { createUploadController } from '@shared/application/upload-controller';
|
import { AdresStep } from './adres.step';
|
||||||
import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine';
|
import { BeroepStep } from './beroep.step';
|
||||||
|
import { ControleStep } from './controle.step';
|
||||||
const KANALEN = [
|
import { diplomaMsg } from './diploma-msg';
|
||||||
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
|
|
||||||
{ value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },
|
|
||||||
];
|
|
||||||
const HANDMATIG = '__handmatig__'; // sentinel option:"my diploma isn't listed"
|
|
||||||
/** The server-owned geldigheidsvraag whose"ja" answer requires a Dutch-taalvaardigheid
|
|
||||||
upload (proof of the confirmed B2 level). Stable id shared with the backend. */
|
|
||||||
const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
|
|
||||||
|
|
||||||
/** Organism: the BIG-registration wizard. All state lives in one signal driven by
|
/** Organism: the BIG-registration wizard. All state lives in one signal driven by
|
||||||
the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the
|
the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the
|
||||||
draft via an effect; the DUO diploma list renders through <app-async>; choosing
|
draft via an effect; the DUO diploma list renders through <app-async>; choosing
|
||||||
a diploma reveals its server-derived beroep. The draft is persisted to the
|
a diploma reveals its server-derived beroep. The draft persists to the backend
|
||||||
backend as a Concept aanvraag (createDraftSync) so a reload — or a"Verder gaan"
|
as a Concept aanvraag (createDraftSync), so a reload — or a "Verder gaan" from
|
||||||
from the dashboard via `?aanvraag=<id>` — resumes progress. Built from existing
|
the dashboard via `?aanvraag=<id>` — resumes progress. Built from existing
|
||||||
atoms/molecules. */
|
atoms/molecules. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-registratie-wizard',
|
selector: 'app-registratie-wizard',
|
||||||
imports: [
|
imports: [
|
||||||
FormsModule,
|
|
||||||
FormFieldComponent,
|
|
||||||
TextInputComponent,
|
|
||||||
RadioGroupComponent,
|
|
||||||
ButtonComponent,
|
ButtonComponent,
|
||||||
AlertComponent,
|
|
||||||
SkeletonComponent,
|
|
||||||
DataRowComponent,
|
|
||||||
DataBlockComponent,
|
|
||||||
ReviewSectionComponent,
|
|
||||||
ConfirmationComponent,
|
ConfirmationComponent,
|
||||||
WizardShellComponent,
|
WizardShellComponent,
|
||||||
AddressFieldsComponent,
|
AdresStep,
|
||||||
DocumentUploadComponent,
|
BeroepStep,
|
||||||
...ASYNC,
|
ControleStep,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<app-wizard-shell
|
<app-wizard-shell
|
||||||
@@ -98,253 +70,31 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
|
|||||||
>
|
>
|
||||||
@switch (step()) {
|
@switch (step()) {
|
||||||
@case ('adres') {
|
@case ('adres') {
|
||||||
@if (adresStatus() === 'laden') {
|
<app-reg-adres-step
|
||||||
<app-skeleton height="2.5rem" [count]="4" />
|
[draft]="draft()"
|
||||||
} @else {
|
[errors]="errors()"
|
||||||
@switch (adresStatus()) {
|
(fieldChange)="dispatch({ tag: 'SetField', key: $event.key, value: $event.value })"
|
||||||
@case ('gevonden') {
|
(kanaalChange)="onKanaalChange($event)"
|
||||||
<app-alert type="info" i18n="@@regWizard.brpGevonden"
|
/>
|
||||||
>Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert
|
|
||||||
>
|
|
||||||
}
|
|
||||||
@case ('geen') {
|
|
||||||
<app-alert type="warning" i18n="@@regWizard.brpGeen"
|
|
||||||
>We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert
|
|
||||||
>
|
|
||||||
}
|
|
||||||
@case ('fout') {
|
|
||||||
<app-alert type="warning" i18n="@@regWizard.brpFout"
|
|
||||||
>We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig
|
|
||||||
in.</app-alert
|
|
||||||
>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
<app-address-fields
|
|
||||||
[value]="{
|
|
||||||
straat: draft().straat ?? '',
|
|
||||||
postcode: draft().postcode ?? '',
|
|
||||||
woonplaats: draft().woonplaats ?? '',
|
|
||||||
}"
|
|
||||||
[errors]="{
|
|
||||||
straat: err('straat'),
|
|
||||||
postcode: err('postcode'),
|
|
||||||
woonplaats: err('woonplaats'),
|
|
||||||
}"
|
|
||||||
(fieldChange)="set($event.key, $event.value)"
|
|
||||||
/>
|
|
||||||
<fieldset>
|
|
||||||
<app-form-field
|
|
||||||
i18n-label="@@regWizard.correspondentieLabel"
|
|
||||||
label="Hoe wilt u correspondentie ontvangen?"
|
|
||||||
fieldId="correspondentie"
|
|
||||||
required
|
|
||||||
[error]="err('correspondentie')"
|
|
||||||
>
|
|
||||||
<app-radio-group
|
|
||||||
name="correspondentie"
|
|
||||||
[options]="kanalen"
|
|
||||||
[invalid]="!!err('correspondentie')"
|
|
||||||
[ngModel]="draft().correspondentie ?? ''"
|
|
||||||
(ngModelChange)="setKanaal($event)"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
@if (draft().correspondentie === 'email') {
|
|
||||||
<fieldset>
|
|
||||||
<app-form-field
|
|
||||||
i18n-label="@@regWizard.emailLabel"
|
|
||||||
label="E-mailadres"
|
|
||||||
fieldId="email"
|
|
||||||
required
|
|
||||||
[error]="err('email')"
|
|
||||||
>
|
|
||||||
<app-text-input
|
|
||||||
inputId="email"
|
|
||||||
type="email"
|
|
||||||
[invalid]="!!err('email')"
|
|
||||||
[ngModel]="draft().email ?? ''"
|
|
||||||
(ngModelChange)="set('email', $event)"
|
|
||||||
name="email"
|
|
||||||
i18n-placeholder="@@regWizard.emailPlaceholder"
|
|
||||||
placeholder="naam@voorbeeld.nl"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@case ('beroep') {
|
@case ('beroep') {
|
||||||
<app-async [data]="lookupRd()">
|
<app-reg-beroep-step
|
||||||
<ng-template appAsyncLoaded>
|
[draft]="draft()"
|
||||||
@if (duoData(); as data) {
|
[errors]="errors()"
|
||||||
<fieldset>
|
[upload]="upload()"
|
||||||
<app-form-field
|
(uploadMsg)="dispatch({ tag: 'Upload', msg: $event })"
|
||||||
i18n-label="@@regWizard.diplomaLabel"
|
(antwoordChange)="
|
||||||
label="Kies het diploma waarmee u zich wilt registreren"
|
dispatch({ tag: 'SetAntwoord', vraagId: $event.vraagId, value: $event.value })
|
||||||
fieldId="diploma"
|
"
|
||||||
required
|
(diplomaChosen)="onDiplomaKeuze($event)"
|
||||||
[error]="err('diploma')"
|
(beroepDeclared)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })"
|
||||||
>
|
|
||||||
<app-radio-group
|
|
||||||
name="diploma"
|
|
||||||
[options]="diplomaOptions(data)"
|
|
||||||
[invalid]="!!err('diploma')"
|
|
||||||
[ngModel]="diplomaKeuze()"
|
|
||||||
(ngModelChange)="onDiplomaKeuze(data, $event)"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
@if (handmatigActief()) {
|
|
||||||
<app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing"
|
|
||||||
>Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies
|
|
||||||
uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna
|
|
||||||
handmatig beoordeeld.</app-alert
|
|
||||||
>
|
|
||||||
<fieldset>
|
|
||||||
<app-form-field
|
|
||||||
i18n-label="@@regWizard.beroepLabel"
|
|
||||||
label="Voor welk beroep wilt u zich registreren?"
|
|
||||||
fieldId="hm-beroep"
|
|
||||||
[error]="err('diploma')"
|
|
||||||
>
|
|
||||||
<app-radio-group
|
|
||||||
name="hm-beroep"
|
|
||||||
[options]="beroepOptions(data)"
|
|
||||||
[invalid]="!!err('diploma')"
|
|
||||||
[ngModel]="draft().beroep ?? ''"
|
|
||||||
(ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
} @else if (draft().beroep) {
|
|
||||||
<app-data-block class="app-section">
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@regWizard.beroepAfgeleid"
|
|
||||||
key="Beroep (afgeleid uit diploma)"
|
|
||||||
[value]="draft().beroep ?? ''"
|
|
||||||
></div>
|
|
||||||
</app-data-block>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (actieveVragen(data).length) {
|
|
||||||
<fieldset>
|
|
||||||
@for (q of actieveVragen(data); track q.id) {
|
|
||||||
<app-form-field
|
|
||||||
[label]="q.vraag"
|
|
||||||
[fieldId]="'vraag-' + q.id"
|
|
||||||
[error]="vraagErr(q.id)"
|
|
||||||
>
|
|
||||||
@if (q.type === 'ja-nee') {
|
|
||||||
<app-radio-group
|
|
||||||
[name]="'vraag-' + q.id"
|
|
||||||
[options]="jaNee"
|
|
||||||
[invalid]="!!vraagErr(q.id)"
|
|
||||||
[ngModel]="antwoord(q.id)"
|
|
||||||
(ngModelChange)="
|
|
||||||
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
|
|
||||||
"
|
|
||||||
[ngModelOptions]="{ standalone: true }"
|
|
||||||
/>
|
|
||||||
} @else {
|
|
||||||
<app-text-input
|
|
||||||
[inputId]="'vraag-' + q.id"
|
|
||||||
[invalid]="!!vraagErr(q.id)"
|
|
||||||
[ngModel]="antwoord(q.id)"
|
|
||||||
(ngModelChange)="
|
|
||||||
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
|
|
||||||
"
|
|
||||||
[ngModelOptions]="{ standalone: true }"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</app-form-field>
|
|
||||||
}
|
|
||||||
</fieldset>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</ng-template>
|
|
||||||
<ng-template appAsyncLoading>
|
|
||||||
<app-skeleton height="2.5rem" [count]="3" />
|
|
||||||
</ng-template>
|
|
||||||
</app-async>
|
|
||||||
|
|
||||||
<app-document-upload
|
|
||||||
class="app-section"
|
|
||||||
[state]="upload()"
|
|
||||||
[previewUrlFor]="previewUrlFor"
|
|
||||||
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
|
|
||||||
(removeUpload)="uploadCtl.onRemove($event)"
|
|
||||||
(retryUpload)="uploadCtl.onRetry($event)"
|
|
||||||
(deleteUpload)="uploadCtl.onDelete($event)"
|
|
||||||
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)"
|
|
||||||
/>
|
/>
|
||||||
@if (err('documenten')) {
|
|
||||||
<app-alert type="warning">{{ err('documenten') }}</app-alert>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@case ('controle') {
|
@case ('controle') {
|
||||||
<app-alert type="info" i18n="@@regWizard.controleer"
|
<app-reg-controle-step
|
||||||
>Controleer uw gegevens en dien de registratie in.</app-alert
|
[draft]="draft()"
|
||||||
>
|
(edit)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
|
||||||
<app-review-section
|
/>
|
||||||
i18n-heading="@@regWizard.sectie.adres"
|
|
||||||
heading="Adres en correspondentie"
|
|
||||||
i18n-editAriaLabel="@@regWizard.adresWijzigenAria"
|
|
||||||
editAriaLabel="Wijzigen adresgegevens"
|
|
||||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@regWizard.summary.adres"
|
|
||||||
key="Adres"
|
|
||||||
[value]="adresSamenvatting()"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@regWizard.summary.herkomstAdres"
|
|
||||||
key="Herkomst adres"
|
|
||||||
[value]="adresHerkomstLabel()"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@regWizard.summary.correspondentie"
|
|
||||||
key="Correspondentie"
|
|
||||||
[value]="correspondentieLabel()"
|
|
||||||
></div>
|
|
||||||
@if (draft().correspondentie === 'email') {
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@regWizard.summary.email"
|
|
||||||
key="E-mailadres"
|
|
||||||
[value]="draft().email ?? ''"
|
|
||||||
></div>
|
|
||||||
}
|
|
||||||
</app-review-section>
|
|
||||||
<app-review-section
|
|
||||||
class="app-section"
|
|
||||||
i18n-heading="@@regWizard.sectie.beroep"
|
|
||||||
heading="Beroep en diploma"
|
|
||||||
i18n-editAriaLabel="@@regWizard.diplomaWijzigenAria"
|
|
||||||
editAriaLabel="Wijzigen beroep en diploma"
|
|
||||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@regWizard.summary.beroep"
|
|
||||||
key="Beroep"
|
|
||||||
[value]="draft().beroep ?? ''"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@regWizard.summary.herkomstDiploma"
|
|
||||||
key="Herkomst diploma"
|
|
||||||
[value]="diplomaHerkomstLabel()"
|
|
||||||
></div>
|
|
||||||
@for (item of samenvattingVragen(); track item.vraag) {
|
|
||||||
<div app-data-row [key]="item.vraag" [value]="item.antwoord"></div>
|
|
||||||
}
|
|
||||||
</app-review-section>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,8 +118,14 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
|
|||||||
})
|
})
|
||||||
export class RegistratieWizardComponent {
|
export class RegistratieWizardComponent {
|
||||||
private lookup = inject(RegistratieLookupStore);
|
private lookup = inject(RegistratieLookupStore);
|
||||||
// Effect fires once, on Invullen -> Indienen (RD-05's tag-transition rule; `Seed` is
|
|
||||||
// exempt, so a story mounting straight into `Indienen` does not call the network).
|
/** Optional seed so Storybook / tests can mount any state directly. */
|
||||||
|
seed = input<RegistratieState>(initial);
|
||||||
|
|
||||||
|
// --- The store: all state in one signal, changed only by a pure reduce -----
|
||||||
|
// The effect fires once, on the `Invullen -> Indienen` transition. `Seed` is exempt,
|
||||||
|
// so a story that mounts straight into `Indienen` does not call the network.
|
||||||
|
// `draftSync` is declared below (both callbacks are deferred, so the cycle is safe).
|
||||||
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce, {
|
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce, {
|
||||||
Indienen: async (s, store) => {
|
Indienen: async (s, store) => {
|
||||||
const r = await this.draftSync.submit({
|
const r = await this.draftSync.submit({
|
||||||
@@ -380,68 +136,56 @@ export class RegistratieWizardComponent {
|
|||||||
else store.dispatch({ tag: 'SubmitFailed', error: r.error });
|
else store.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
readonly state = this.store.model;
|
||||||
|
readonly dispatch = this.store.dispatch;
|
||||||
|
|
||||||
/** Preview/download link for a completed upload; delegates to the upload
|
// --- Static copy: stepper labels and per-step headings ---------------------
|
||||||
controller (application layer), which knows the dev-simulation `demo-*` ids
|
|
||||||
have no stored bytes and returns no link for them. */
|
|
||||||
protected previewUrlFor = (documentId: string): string | undefined =>
|
|
||||||
this.uploadCtl.previewUrlFor(documentId);
|
|
||||||
|
|
||||||
/** Optional seed so Storybook / tests can mount any state directly. */
|
|
||||||
seed = input<RegistratieState>(initial);
|
|
||||||
|
|
||||||
readonly kanalen = KANALEN;
|
|
||||||
readonly stepLabels = [
|
readonly stepLabels = [
|
||||||
$localize`:@@regWizard.step.adres:Adres`,
|
$localize`:@@regWizard.step.adres:Adres`,
|
||||||
$localize`:@@regWizard.step.beroep:Beroep`,
|
$localize`:@@regWizard.step.beroep:Beroep`,
|
||||||
$localize`:@@regWizard.step.controle:Controle`,
|
$localize`:@@regWizard.step.controle:Controle`,
|
||||||
]; // short labels for the stepper
|
];
|
||||||
private stepTitles = [
|
private stepTitles = [
|
||||||
$localize`:@@regWizard.title.adres:Adres en correspondentievoorkeur`,
|
$localize`:@@regWizard.title.adres:Adres en correspondentievoorkeur`,
|
||||||
$localize`:@@regWizard.title.beroep:Beroep op basis van uw diploma`,
|
$localize`:@@regWizard.title.beroep:Beroep op basis van uw diploma`,
|
||||||
$localize`:@@regWizard.title.controle:Controleren en indienen`,
|
$localize`:@@regWizard.title.controle:Controleren en indienen`,
|
||||||
];
|
];
|
||||||
readonly state = this.store.model;
|
|
||||||
readonly dispatch = this.store.dispatch;
|
|
||||||
|
|
||||||
|
// --- State projections: one narrow, then read-only views of it -------------
|
||||||
private invullen = computed(() => whenTag(this.state(), 'Invullen'));
|
private invullen = computed(() => whenTag(this.state(), 'Invullen'));
|
||||||
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
|
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
|
||||||
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
|
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
|
||||||
|
protected errors = computed<Errors>(() => this.invullen()?.errors ?? {});
|
||||||
protected upload = computed<UploadState>(() => this.invullen()?.upload ?? initialUpload);
|
protected upload = computed<UploadState>(() => this.invullen()?.upload ?? initialUpload);
|
||||||
protected uploadCtl = createUploadController({
|
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
|
||||||
wizardId: 'registratie',
|
protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
|
||||||
getUpload: () => this.upload(),
|
/** From the lookup store, not the machine: the beroep step renders it, and
|
||||||
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
|
`onDiplomaKeuze` reads it to resolve the picked id into a message. */
|
||||||
// Required documents depend on answers (server decides): a diploma upload only for a
|
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookup.duoLookup(), null));
|
||||||
// manual diploma; a Dutch-taalvaardigheid upload only once the applicant confirms
|
|
||||||
// ("ja") the B2 language requirement.
|
// --- Controllers: persistence and uploads ----------------------------------
|
||||||
getCategoryParams: () => ({
|
// Create a Concept once the user has made progress, then debounced-sync the whole
|
||||||
diplomaHerkomst: this.draft().diplomaHerkomst,
|
// machine snapshot. `?aanvraag=<id>` resumes it.
|
||||||
taalvaardigheid: this.draft().antwoorden[NL_TAALVAARDIGHEID_VRAAG],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
// Backend draft-sync (replaces sessionStorage): create a Concept once the user has
|
|
||||||
// made progress, then debounced-sync the whole machine snapshot; resume by `?aanvraag`.
|
|
||||||
private draftSync = createDraftSync({
|
private draftSync = createDraftSync({
|
||||||
type: 'registratie',
|
type: 'registratie',
|
||||||
snapshot: () => {
|
snapshot: () => {
|
||||||
const s = this.state();
|
const s = this.state();
|
||||||
if (s.tag !== 'Invullen' || !hasProgress(s)) return null;
|
if (s.tag !== 'Invullen' || !hasProgress(s)) return null;
|
||||||
const documentIds = deliveryRefs(s.upload)
|
return {
|
||||||
.filter((r) => r.channel === 'digital' && r.documentId)
|
draft: s,
|
||||||
.map((r) => r.documentId!);
|
stepIndex: s.cursor,
|
||||||
return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds };
|
stepCount: STEPS.length,
|
||||||
|
documentIds: digitalDocumentIds(s.upload),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as RegistratieState }),
|
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as RegistratieState }),
|
||||||
enabled: () => this.seed() === initial,
|
enabled: () => this.seed() === initial,
|
||||||
});
|
});
|
||||||
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
|
|
||||||
|
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||||
protected stepTitle = computed(
|
protected stepTitle = computed(
|
||||||
() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)],
|
() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)],
|
||||||
);
|
);
|
||||||
protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
|
|
||||||
|
|
||||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
|
||||||
protected primaryLabel = computed(() => {
|
protected primaryLabel = computed(() => {
|
||||||
if (this.step() === 'controle') return $localize`:@@regWizard.indienen:Registratie indienen`;
|
if (this.step() === 'controle') return $localize`:@@regWizard.indienen:Registratie indienen`;
|
||||||
const next = this.cursor() + 1;
|
const next = this.cursor() + 1;
|
||||||
@@ -471,125 +215,31 @@ export class RegistratieWizardComponent {
|
|||||||
const e = this.invullen()?.errors ?? {};
|
const e = this.invullen()?.errors ?? {};
|
||||||
return [...toWizardErrors(e), ...toWizardErrors(e.antwoorden ?? {}, 'vraag-')];
|
return [...toWizardErrors(e), ...toWizardErrors(e.antwoorden ?? {}, 'vraag-')];
|
||||||
});
|
});
|
||||||
protected adresSamenvatting = computed(() => {
|
|
||||||
const d = this.draft();
|
|
||||||
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(', ');
|
|
||||||
});
|
|
||||||
// Readable labels for the controle summary (instead of raw enum values).
|
|
||||||
protected adresHerkomstLabel = computed(
|
|
||||||
() =>
|
|
||||||
({
|
|
||||||
brp: $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
|
// --- Event handlers: narrow a child event into a message -------------------
|
||||||
served by the application facade — the wizard renders, it does not fetch/parse. */
|
protected onDiplomaKeuze(id: string) {
|
||||||
protected adresStatus = this.lookup.adresStatus;
|
|
||||||
protected lookupRd: () => RemoteData<Error | undefined, DuoLookupDto> = this.lookup.duoLookup;
|
|
||||||
|
|
||||||
/** Parsed lookup as a plain value (or null) — used outside the beroep step (the
|
|
||||||
controle summary) where the <app-async> template variable isn't in scope, and
|
|
||||||
inside it too: `<ng-template appAsyncLoaded>`'s own context can't inherit a
|
|
||||||
generic from the sibling [data] input (Angular only infers a structural
|
|
||||||
directive's type parameter from an input on that same node). */
|
|
||||||
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookupRd(), null));
|
|
||||||
|
|
||||||
readonly jaNee = JA_NEE;
|
|
||||||
|
|
||||||
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 });
|
|
||||||
|
|
||||||
/** True while the user is entering a diploma manually (not in the DUO list). */
|
|
||||||
protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig');
|
|
||||||
/** The radio selection: a diploma id, or the"not listed" sentinel in manual mode. */
|
|
||||||
protected diplomaKeuze = computed(() =>
|
|
||||||
this.handmatigActief() ? HANDMATIG : (this.draft().diplomaId ?? ''),
|
|
||||||
);
|
|
||||||
|
|
||||||
protected diplomaOptions = (data: DuoLookupDto) => [
|
|
||||||
...data.diplomas.map((d) => ({
|
|
||||||
value: d.id,
|
|
||||||
label: `${d.naam} — ${d.instelling} (${d.jaar})`,
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
value: HANDMATIG,
|
|
||||||
label: $localize`:@@regWizard.diplomaNietBij:Mijn diploma staat er niet bij`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
protected beroepOptions = (data: DuoLookupDto) =>
|
|
||||||
data.handmatig.beroepen.map((b) => ({ value: b, label: b }));
|
|
||||||
|
|
||||||
/** The policy questions that apply to the current choice (server-decided). */
|
|
||||||
protected actieveVragen = (data: DuoLookupDto): PolicyQuestionDto[] => {
|
|
||||||
if (this.handmatigActief()) return data.handmatig.policyQuestions;
|
|
||||||
return data.diplomas.find((d) => d.id === this.draft().diplomaId)?.policyQuestions ?? [];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Answered policy questions for the controle summary (question text + answer). */
|
|
||||||
protected samenvattingVragen = computed(() => {
|
|
||||||
const data = this.duoData();
|
const data = this.duoData();
|
||||||
const d = this.draft();
|
if (!data) return;
|
||||||
if (!data) return [] as { vraag: string; antwoord: string }[];
|
const msg = diplomaMsg(data, id);
|
||||||
const alle = [
|
if (msg) this.dispatch(msg);
|
||||||
...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) {
|
/** Narrows the beroep step's plain-string `kanaalChange` into the machine's
|
||||||
if (id === HANDMATIG) {
|
`Correspondentie` union before dispatching. */
|
||||||
this.dispatch({
|
protected onKanaalChange(value: string) {
|
||||||
tag: 'KiesHandmatig',
|
this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
|
||||||
vraagIds: data.handmatig.policyQuestions.map((q) => q.id),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const d = data.diplomas.find((x) => x.id === id);
|
|
||||||
if (d)
|
|
||||||
this.dispatch({
|
|
||||||
tag: 'KiesDiploma',
|
|
||||||
diplomaId: d.id,
|
|
||||||
beroep: d.beroep,
|
|
||||||
vraagIds: d.policyQuestions.map((q) => q.id),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// An explicit seed (stories/tests) wins; otherwise resume from the backend draft
|
// An explicit seed (stories/tests) wins; otherwise resume from the backend draft
|
||||||
// (`?aanvraag=<id>`), or start fresh. Persistence is the draftSync controller's job.
|
// (`?aanvraag=<id>`), or start fresh. Persistence is the draftSync controller's job.
|
||||||
const seeded = this.seed();
|
// Read `seed()` INSIDE the microtask: Angular binds inputs after the constructor
|
||||||
queueMicrotask(() =>
|
// runs, so an eager read here always returns the `initial` default.
|
||||||
seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume(),
|
queueMicrotask(() => {
|
||||||
);
|
const seeded = this.seed();
|
||||||
|
if (seeded !== initial) this.dispatch({ tag: 'Seed', state: seeded });
|
||||||
|
else void this.draftSync.resume();
|
||||||
|
});
|
||||||
// Prefill the address from the BRP lookup as it arrives. Track only the facade's
|
// Prefill the address from the BRP lookup as it arrives. Track only the facade's
|
||||||
// parsed prefill signal; untrack the dispatch (it reads the state signal, which
|
// parsed prefill signal; untrack the dispatch (it reads the state signal, which
|
||||||
// would otherwise make this effect loop on its own write). Don't clobber
|
// would otherwise make this effect loop on its own write). Don't clobber
|
||||||
@@ -608,12 +258,10 @@ export class RegistratieWizardComponent {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// A11y: focus management (step heading on step change, error summary on a
|
|
||||||
// failed submit) now lives in the shared WizardShellComponent.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset the wizard to a fresh start. Reload the BRP lookup so the address
|
/** Reset the wizard to a fresh start. Reload the BRP lookup so the address
|
||||||
re-prefills, keeping the form and the"vooraf ingevuld" note consistent. */
|
re-prefills, keeping the form and the "vooraf ingevuld" note consistent. */
|
||||||
restart() {
|
restart() {
|
||||||
this.draftSync.reset(); // discard the current Concept; a fresh one starts on next progress
|
this.draftSync.reset(); // discard the current Concept; a fresh one starts on next progress
|
||||||
this.dispatch({ tag: 'Seed', state: initial });
|
this.dispatch({ tag: 'Seed', state: initial });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
import { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/registratie-wizard.component';
|
import { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/registratie-wizard.component';
|
||||||
|
|
||||||
/** Page: register in the BIG-register. Built entirely from existing building
|
/** Page: register in the BIG-register. Built entirely from existing building
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { successOf } from '@shared/application/remote-data';
|
import { successOf } from '@shared/application/remote-data';
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||||
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
||||||
import { ChangeRequestFormComponent } from '@registratie/ui/change-request-form/change-request-form.component';
|
import { ChangeRequestFormComponent } from '@registratie/ui/change-request-form/change-request-form.component';
|
||||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||||
|
|||||||
+3
-3
@@ -2,9 +2,9 @@ import { Component, input } from '@angular/core';
|
|||||||
import { DatePipe } from '@angular/common';
|
import { DatePipe } from '@angular/common';
|
||||||
import { Registration } from '@registratie/domain/registration';
|
import { Registration } from '@registratie/domain/registration';
|
||||||
import { statusColor, statusLabel } from '@registratie/domain/registration.policy';
|
import { statusColor, statusLabel } from '@registratie/domain/registration.policy';
|
||||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
|
||||||
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
|
import { StatusBadgeComponent } from '@shared/ui/atoms/status-badge/status-badge.component';
|
||||||
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
|
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
|
||||||
|
|
||||||
/** Organism: registration summary in a CIBG Datablock. Composes data-row rows +
|
/** Organism: registration summary in a CIBG Datablock. Composes data-row rows +
|
||||||
status-badge atom (no card wrapper — the datablock carries its own surface). */
|
status-badge atom (no card wrapper — the datablock carries its own surface). */
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
|
||||||
|
/** Molecule-shaped teaching card: the showcase's "before/after" box. Renders its own
|
||||||
|
tag pill and, when `code` is set, the highlighted snippet — everything else comes in
|
||||||
|
via `<ng-content />`. Angular does not style projected content from the receiving
|
||||||
|
component, so any other card-shaped block inside a section (a sub-label, a nested
|
||||||
|
ok/err result) nests another `<app-concept-card>` rather than writing raw `.tag`
|
||||||
|
markup — that markup would carry the SECTION's scope, not this component's, and the
|
||||||
|
rule here would never match it. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-concept-card',
|
||||||
|
imports: [],
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--rhc-color-grijs-200);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--rhc-color-wit);
|
||||||
|
}
|
||||||
|
.card--bad {
|
||||||
|
border-color: var(--rhc-color-rood-300);
|
||||||
|
}
|
||||||
|
.card--good {
|
||||||
|
border-color: var(--rhc-color-groen-300);
|
||||||
|
}
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
.tag.bad::before {
|
||||||
|
background: var(--rhc-color-rood-500);
|
||||||
|
}
|
||||||
|
.tag.good {
|
||||||
|
color: var(--rhc-color-groen-700);
|
||||||
|
}
|
||||||
|
.tag.good::before {
|
||||||
|
background: var(--rhc-color-groen-500);
|
||||||
|
}
|
||||||
|
.tag.plain {
|
||||||
|
color: var(--rhc-color-grijs-700);
|
||||||
|
}
|
||||||
|
.tag.plain::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.linked {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
.linked .src {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--rhc-color-grijs-700);
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div
|
||||||
|
class="card"
|
||||||
|
[class.card--good]="variant() === 'good'"
|
||||||
|
[class.card--bad]="variant() === 'bad'"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="tag"
|
||||||
|
[class.good]="variant() === 'good'"
|
||||||
|
[class.bad]="variant() === 'bad'"
|
||||||
|
[class.plain]="variant() === 'plain'"
|
||||||
|
>
|
||||||
|
{{ tag() }}
|
||||||
|
</p>
|
||||||
|
@if (code(); as c) {
|
||||||
|
@if (src(); as s) {
|
||||||
|
<figure class="linked">
|
||||||
|
<pre class="app-code" [innerHTML]="c"></pre>
|
||||||
|
<figcaption class="src">↳ {{ s }}</figcaption>
|
||||||
|
</figure>
|
||||||
|
} @else {
|
||||||
|
<pre class="app-code" [innerHTML]="c"></pre>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<ng-content />
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ConceptCardComponent {
|
||||||
|
variant = input<'good' | 'bad' | 'plain'>('plain');
|
||||||
|
tag = input.required<string>();
|
||||||
|
code = input<string | undefined>();
|
||||||
|
src = input<string | undefined>();
|
||||||
|
}
|
||||||
@@ -1,497 +1,38 @@
|
|||||||
/* eslint-disable max-lines */ // teaching page covering every concept — removed by RD-24
|
import { Component } from '@angular/core';
|
||||||
import { Component, computed, signal } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import type { Resource } from '@angular/core';
|
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { UnionsSection } from './unions.section';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
import { RemoteDataSection } from './remote-data.section';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ParseSection } from './parse.section';
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
import { FormMachineSection } from './form-machine.section';
|
||||||
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
import { VragenlijstSection } from './vragenlijst.section';
|
||||||
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
|
import { PiiSection } from './pii.section';
|
||||||
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
|
|
||||||
import { Registration } from '@registratie/domain/registration';
|
|
||||||
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
|
|
||||||
import { parseBsn } from '@shared/kernel/bsn';
|
|
||||||
import { maskBsn } from '@shared/kernel/pii';
|
|
||||||
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
|
|
||||||
import { SNIPPETS } from './snippets.generated';
|
|
||||||
import { highlightTs } from './highlight-ts';
|
|
||||||
|
|
||||||
/** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */
|
/** Teaching showcase: each section pairs the impossible-state-permitting "before"
|
||||||
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
|
with the "after" where the type system rules it out. Composition-only. */
|
||||||
return {
|
|
||||||
value: () => value as T,
|
|
||||||
status: () => status,
|
|
||||||
error: () => error,
|
|
||||||
hasValue: () => value !== undefined,
|
|
||||||
reload: () => {},
|
|
||||||
} as unknown as Resource<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Teaching showcase: each section pairs the impossible-state-permitting"before"
|
|
||||||
with the"after" where the type system rules it out. Composition-only. */
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-concepts-page',
|
selector: 'app-concepts-page',
|
||||||
imports: [
|
imports: [
|
||||||
FormsModule,
|
|
||||||
PageShellComponent,
|
PageShellComponent,
|
||||||
HeadingComponent,
|
UnionsSection,
|
||||||
TextInputComponent,
|
RemoteDataSection,
|
||||||
...ASYNC,
|
ParseSection,
|
||||||
SkeletonComponent,
|
FormMachineSection,
|
||||||
RegistrationSummaryComponent,
|
VragenlijstSection,
|
||||||
HerregistratieWizardComponent,
|
PiiSection,
|
||||||
IntakeWizardComponent,
|
|
||||||
MaskedValueComponent,
|
|
||||||
],
|
|
||||||
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;
|
|
||||||
/* teaching motion: the active state pops as the wizard transitions (the .node
|
|
||||||
transition above animates it; reduced-motion is handled globally). */
|
|
||||||
transform: scale(1.06);
|
|
||||||
}
|
|
||||||
.linked {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
.linked .src {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--rhc-color-grijs-700);
|
|
||||||
margin: 0.35rem 0 0;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
.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: `
|
template: `
|
||||||
<app-page-shell heading="Onmogelijke toestanden onmogelijk maken" backLink="/dashboard">
|
<app-page-shell heading="Onmogelijke toestanden onmogelijk maken" backLink="/dashboard">
|
||||||
<p class="lead">
|
<p class="app-lead">
|
||||||
Vijf functionele patronen die atomic design makkelijker maakt om te tonen — telkens "fout"
|
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).
|
(de oude vorm liet het toe) naast"goed" (het type maakt het onmogelijk).
|
||||||
</p>
|
</p>
|
||||||
|
<app-concepts-unions-section />
|
||||||
<!-- 1. Discriminated unions -->
|
<app-concepts-remote-data-section />
|
||||||
<section class="section">
|
<app-concepts-parse-section />
|
||||||
<app-heading [level]="2">1 · Discriminated unions</app-heading>
|
<app-concepts-form-machine-section />
|
||||||
<p class="lead">Laat elke variant precies de gegevens dragen die kloppen — niets meer.</p>
|
<app-concepts-vragenlijst-section />
|
||||||
<div class="cols">
|
<app-concepts-pii-section />
|
||||||
<div class="card card--bad">
|
|
||||||
<p class="tag bad">Fout — vlakke interface</p>
|
|
||||||
<pre [innerHTML]="code['unionBad']"></pre>
|
|
||||||
<p class="note">
|
|
||||||
Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="card card--good">
|
|
||||||
<p class="tag good">Goed — sum type</p>
|
|
||||||
<figure class="linked">
|
|
||||||
<pre [innerHTML]="code['union']"></pre>
|
|
||||||
<figcaption class="src">↳ {{ src['union'] }}</figcaption>
|
|
||||||
</figure>
|
|
||||||
<app-registration-summary [reg]="doorgehaald" />
|
|
||||||
<p class="note">
|
|
||||||
De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat
|
|
||||||
simpelweg niet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 2. RemoteData fold -->
|
|
||||||
<section class="section">
|
|
||||||
<app-heading [level]="2">2 · RemoteData fold</app-heading>
|
|
||||||
<p class="lead">
|
|
||||||
Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.
|
|
||||||
</p>
|
|
||||||
<div class="cols">
|
|
||||||
<div class="card card--good">
|
|
||||||
<p class="tag good">Vier toestanden, één molecuul</p>
|
|
||||||
<p class="tag plain">Loading</p>
|
|
||||||
<app-async [resource]="loadingRes"
|
|
||||||
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template
|
|
||||||
><ng-template appAsyncLoading
|
|
||||||
><app-skeleton [count]="2" height="1.2rem" [delay]="0" /></ng-template
|
|
||||||
></app-async>
|
|
||||||
<p class="tag plain">Empty</p>
|
|
||||||
<app-async [resource]="emptyRes" [isEmpty]="isEmpty"
|
|
||||||
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
|
|
||||||
>
|
|
||||||
<p class="tag plain">Failure</p>
|
|
||||||
<app-async [resource]="errorRes"
|
|
||||||
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
|
|
||||||
>
|
|
||||||
<p class="tag plain">Success</p>
|
|
||||||
<app-async [resource]="successRes" [isEmpty]="isEmpty"
|
|
||||||
><ng-template appAsyncLoaded
|
|
||||||
><ul>
|
|
||||||
@for (i of successRes.value(); track i) {
|
|
||||||
<li>{{ i }}</li>
|
|
||||||
}
|
|
||||||
</ul></ng-template
|
|
||||||
></app-async
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="card card--good">
|
|
||||||
<p class="tag good">De exhaustieve fold</p>
|
|
||||||
<figure class="linked">
|
|
||||||
<pre [innerHTML]="code['fold']"></pre>
|
|
||||||
<figcaption class="src">↳ {{ src['fold'] }}</figcaption>
|
|
||||||
</figure>
|
|
||||||
<p class="note">
|
|
||||||
Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem
|
|
||||||
afhandelt.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 3. Parse, don't validate -->
|
|
||||||
<section class="section">
|
|
||||||
<app-heading [level]="2">3 · Parse, don't validate</app-heading>
|
|
||||||
<p class="lead">Na het parsen onthoudt het <em>type</em> dat de waarde geldig is.</p>
|
|
||||||
<div class="cols">
|
|
||||||
<div class="card">
|
|
||||||
<p class="tag good">Smart constructor → Result</p>
|
|
||||||
<figure class="linked">
|
|
||||||
<pre [innerHTML]="code['parse']"></pre>
|
|
||||||
<figcaption class="src">↳ {{ src['parse'] }}</figcaption>
|
|
||||||
</figure>
|
|
||||||
<app-text-input
|
|
||||||
inputId="pc"
|
|
||||||
[ngModel]="raw()"
|
|
||||||
(ngModelChange)="raw.set($event)"
|
|
||||||
name="pc"
|
|
||||||
placeholder="Typ een postcode, bijv. 1234 AB"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
@let r = parsed();
|
|
||||||
<div class="card" [class.card--good]="r.ok" [class.card--bad]="!r.ok">
|
|
||||||
@if (r.ok) {
|
|
||||||
<div animate.enter="app-item-enter">
|
|
||||||
<p class="tag good">ok</p>
|
|
||||||
<pre>Postcode ="{{ r.value }}"</pre>
|
|
||||||
<p class="note">
|
|
||||||
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div animate.enter="app-item-enter">
|
|
||||||
<p class="tag bad">err</p>
|
|
||||||
<pre>{{ r.error }}</pre>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 4. State machine / wizard (live state diagram) -->
|
|
||||||
<section class="section">
|
|
||||||
<app-heading [level]="2">4 · Form als state machine</app-heading>
|
|
||||||
<p class="lead">
|
|
||||||
Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de
|
|
||||||
huidige.
|
|
||||||
</p>
|
|
||||||
<div class="cols">
|
|
||||||
<div class="card card--bad">
|
|
||||||
<p class="tag bad">Fout — losse booleans</p>
|
|
||||||
<pre [innerHTML]="code['machineBad']"></pre>
|
|
||||||
<p class="note">
|
|
||||||
Niets verhindert"submitting" mét validatiefouten of een successcherm met errors.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="card card--good">
|
|
||||||
<p class="tag good">Goed — één tagged union</p>
|
|
||||||
<figure class="linked">
|
|
||||||
<pre [innerHTML]="code['machine']"></pre>
|
|
||||||
<figcaption class="src">↳ {{ src['machine'] }}</figcaption>
|
|
||||||
</figure>
|
|
||||||
<div class="machine">
|
|
||||||
@for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) {
|
|
||||||
<span class="node" [class.on]="w.state().tag === n">{{ n }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<app-herregistratie-wizard #w />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 5. Fixed steps, questions revealed inline -->
|
|
||||||
<section class="section">
|
|
||||||
<app-heading [level]="2"
|
|
||||||
>5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen"</app-heading
|
|
||||||
>
|
|
||||||
<p class="lead">
|
|
||||||
Het aantal stappen ligt vast (<code>STEPS</code>); vervolgvragen verschijnen
|
|
||||||
<em>binnen</em> 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.
|
|
||||||
</p>
|
|
||||||
<div class="cols">
|
|
||||||
<div class="card card--good">
|
|
||||||
<p class="tag good">Vaste stappen</p>
|
|
||||||
<figure class="linked">
|
|
||||||
<pre [innerHTML]="code['steps']"></pre>
|
|
||||||
<figcaption class="src">↳ {{ src['steps'] }}</figcaption>
|
|
||||||
</figure>
|
|
||||||
<div class="steplist">
|
|
||||||
@for (s of iw.steps; track s; let last = $last) {
|
|
||||||
<span class="pill">{{ s }}</span>
|
|
||||||
@if (!last) {
|
|
||||||
<span class="arrow">→</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<p class="note">
|
|
||||||
De stappen zijn altijd dezelfde; alleen de vragen <em>binnen</em> een stap verschijnen
|
|
||||||
of verdwijnen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="card card--good">
|
|
||||||
<p class="tag good">De wizard</p>
|
|
||||||
<app-intake-wizard #iw />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 6. PII: mask + parse -->
|
|
||||||
<section class="section">
|
|
||||||
<app-heading [level]="2">6 · PII — maskeren & parsen</app-heading>
|
|
||||||
<p class="lead">
|
|
||||||
Een BSN is bijzondere persoonsgegevens (AVG art. 9). Dataminimalisatie: standaard
|
|
||||||
gemaskeerd tonen, alleen tonen na een vastgelegde handeling; en "parse, don't validate" op
|
|
||||||
het gevoeligste veld — een pure functie die de <em>elfproef</em> afdwingt.
|
|
||||||
</p>
|
|
||||||
<div class="cols">
|
|
||||||
<div class="card card--good">
|
|
||||||
<p class="tag good">Maskeren — atom</p>
|
|
||||||
<p>
|
|
||||||
BSN:
|
|
||||||
<app-masked-value
|
|
||||||
[value]="bsnShown()"
|
|
||||||
[canReveal]="true"
|
|
||||||
revealLabel="Toon BSN"
|
|
||||||
(reveal)="bsnRevealed.set(true)"
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
<figure class="linked">
|
|
||||||
<pre [innerHTML]="code['mask']"></pre>
|
|
||||||
<figcaption class="src">↳ {{ src['mask'] }}</figcaption>
|
|
||||||
</figure>
|
|
||||||
<p class="note">
|
|
||||||
Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het
|
|
||||||
behandelscherm). De atom bevat de maskeer-detectie — geen los <code>*</code>-gesnuffel
|
|
||||||
bij elke gebruiker.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<p class="tag good">Parse (elfproef) → Result</p>
|
|
||||||
<app-text-input
|
|
||||||
inputId="bsn"
|
|
||||||
[ngModel]="bsnRaw()"
|
|
||||||
(ngModelChange)="bsnRaw.set($event)"
|
|
||||||
name="bsn"
|
|
||||||
placeholder="Typ een BSN, bijv. 123456782"
|
|
||||||
/>
|
|
||||||
@let b = bsnParsed();
|
|
||||||
@if (bsnRaw()) {
|
|
||||||
<div animate.enter="app-item-enter">
|
|
||||||
@if (b.ok) {
|
|
||||||
<p class="tag good">ok</p>
|
|
||||||
<pre>Bsn ="{{ b.value }}"</pre>
|
|
||||||
} @else {
|
|
||||||
<p class="tag bad">err</p>
|
|
||||||
<pre>{{ b.error }}</pre>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<figure class="linked">
|
|
||||||
<pre [innerHTML]="code['parseBsn']"></pre>
|
|
||||||
<figcaption class="src">↳ {{ src['parseBsn'] }}</figcaption>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</app-page-shell>
|
</app-page-shell>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class ConceptsPage {
|
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',
|
|
||||||
status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' },
|
|
||||||
};
|
|
||||||
|
|
||||||
loadingRes = fakeResource<string[]>('loading');
|
|
||||||
emptyRes = fakeResource<string[]>('resolved', []);
|
|
||||||
errorRes = fakeResource<string[]>('error', undefined, new Error('Demo'));
|
|
||||||
successRes = fakeResource<string[]>('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']);
|
|
||||||
|
|
||||||
raw = signal('');
|
|
||||||
parsed = computed(() => parsePostcode(this.raw()));
|
|
||||||
|
|
||||||
// 6 · PII demo. Masked-by-default value that reveals locally (the real reveal is
|
|
||||||
// step-up-gated + audited elsewhere); plus a live elfproef parse mirroring the postcode demo.
|
|
||||||
demoBsn = '123456782';
|
|
||||||
bsnRevealed = signal(false);
|
|
||||||
bsnShown = computed(() => (this.bsnRevealed() ? this.demoBsn : maskBsn(this.demoBsn)));
|
|
||||||
bsnRaw = signal('');
|
|
||||||
bsnParsed = computed(() => parseBsn(this.bsnRaw()));
|
|
||||||
|
|
||||||
// Deliberately-wrong illustrations (no real source to link — they show the anti-pattern).
|
|
||||||
private readonly illustrations: Record<string, string> = {
|
|
||||||
unionBad: `interface Registration {
|
|
||||||
status: 'Geregistreerd' | 'Doorgehaald';
|
|
||||||
herregistratieDatum: string; // altijd aanwezig 😬
|
|
||||||
}`,
|
|
||||||
machineBad: `submitting = signal(false);
|
|
||||||
submitted = signal(false);
|
|
||||||
errors = signal<...>({});
|
|
||||||
// submitting === true && errors.size > 0 ? 🤷`,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Highlighted HTML per snippet: the real ones come from SNIPPETS (extracted from source
|
|
||||||
by gen:snippets — they can't drift), the illustrations are authored above. */
|
|
||||||
protected readonly code: Record<string, string> = Object.fromEntries(
|
|
||||||
Object.entries({ ...SNIPPETS, ...this.illustrations }).map(([k, v]) => [k, highlightTs(v)]),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** The real file each linked snippet is extracted from (shown as a caption). */
|
|
||||||
protected readonly src: Record<string, string> = {
|
|
||||||
union: 'registratie/domain/registration.ts',
|
|
||||||
fold: 'shared/application/remote-data.ts',
|
|
||||||
parse: 'registratie/domain/value-objects/postcode.ts',
|
|
||||||
machine: 'registratie/domain/change-request.machine.ts',
|
|
||||||
steps: 'herregistratie/domain/intake.machine.ts',
|
|
||||||
parseBsn: 'shared/kernel/bsn.ts',
|
|
||||||
mask: 'shared/kernel/pii.ts',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
|
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
|
||||||
|
import { ConceptCardComponent } from './concept-card.component';
|
||||||
|
import { SNIPPETS } from './snippets.generated';
|
||||||
|
import { highlightTs } from './highlight-ts';
|
||||||
|
|
||||||
|
/** Section 4: form as a state machine, shown as a live state diagram. One tagged union
|
||||||
|
drives the UI — the marked state below is the wizard's current one. Composition-only. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-concepts-form-machine-section',
|
||||||
|
imports: [HeadingComponent, HerregistratieWizardComponent, ConceptCardComponent],
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.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);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--rhc-color-grijs-700);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.node.on {
|
||||||
|
background: var(--rhc-color-hemelblauw-100);
|
||||||
|
border-color: var(--rhc-color-hemelblauw-500);
|
||||||
|
color: var(--rhc-color-hemelblauw-700);
|
||||||
|
font-weight: 700;
|
||||||
|
/* teaching motion: the active state pops as the wizard transitions (the .node
|
||||||
|
transition above animates it; reduced-motion is handled globally). */
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<section class="app-section">
|
||||||
|
<app-heading [level]="2">4 · Form als state machine</app-heading>
|
||||||
|
<p class="app-lead">
|
||||||
|
Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de huidige.
|
||||||
|
</p>
|
||||||
|
<div class="app-cols">
|
||||||
|
<app-concept-card variant="bad" tag="Fout — losse booleans" [code]="code['machineBad']">
|
||||||
|
<p class="app-note">
|
||||||
|
Niets verhindert"submitting" mét validatiefouten of een successcherm met errors.
|
||||||
|
</p>
|
||||||
|
</app-concept-card>
|
||||||
|
<app-concept-card
|
||||||
|
variant="good"
|
||||||
|
tag="Goed — één tagged union"
|
||||||
|
[code]="code['machine']"
|
||||||
|
[src]="src['machine']"
|
||||||
|
>
|
||||||
|
<div class="machine">
|
||||||
|
@for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) {
|
||||||
|
<span class="node" [class.on]="w.state().tag === n">{{ n }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<app-herregistratie-wizard #w />
|
||||||
|
</app-concept-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class FormMachineSection {
|
||||||
|
// Deliberately-wrong illustration (no real source to link — it shows the anti-pattern).
|
||||||
|
private readonly machineBad = `submitting = signal(false);
|
||||||
|
submitted = signal(false);
|
||||||
|
errors = signal<...>({});
|
||||||
|
// submitting === true && errors.size > 0 ? 🤷`;
|
||||||
|
|
||||||
|
/** Highlighted HTML per snippet: `machine` comes from SNIPPETS (extracted from source by
|
||||||
|
gen:snippets — it can't drift), `machineBad` is authored above. */
|
||||||
|
protected readonly code: Record<string, string> = {
|
||||||
|
machineBad: highlightTs(this.machineBad),
|
||||||
|
machine: highlightTs(SNIPPETS['machine']),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The real file the linked snippet is extracted from (shown as a caption). */
|
||||||
|
protected readonly src: Record<string, string> = {
|
||||||
|
machine: 'registratie/domain/change-request.machine.ts',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Component, computed, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
|
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
|
||||||
|
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
|
||||||
|
import { ConceptCardComponent } from './concept-card.component';
|
||||||
|
import { SNIPPETS } from './snippets.generated';
|
||||||
|
import { highlightTs } from './highlight-ts';
|
||||||
|
|
||||||
|
/** Section 3: parse, don't validate. After parsing, the TYPE remembers the value is valid.
|
||||||
|
Composition-only; owns its own postcode demo. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-concepts-parse-section',
|
||||||
|
imports: [FormsModule, HeadingComponent, TextInputComponent, ConceptCardComponent],
|
||||||
|
template: `
|
||||||
|
<section class="app-section">
|
||||||
|
<app-heading [level]="2">3 · Parse, don't validate</app-heading>
|
||||||
|
<p class="app-lead">Na het parsen onthoudt het <em>type</em> dat de waarde geldig is.</p>
|
||||||
|
<div class="app-cols">
|
||||||
|
<app-concept-card
|
||||||
|
tag="Smart constructor → Result"
|
||||||
|
[code]="code['parse']"
|
||||||
|
[src]="src['parse']"
|
||||||
|
>
|
||||||
|
<app-text-input
|
||||||
|
inputId="pc"
|
||||||
|
[ngModel]="raw()"
|
||||||
|
(ngModelChange)="raw.set($event)"
|
||||||
|
name="pc"
|
||||||
|
placeholder="Typ een postcode, bijv. 1234 AB"
|
||||||
|
/>
|
||||||
|
</app-concept-card>
|
||||||
|
@let r = parsed();
|
||||||
|
<app-concept-card [variant]="r.ok ? 'good' : 'bad'" [tag]="r.ok ? 'ok' : 'err'">
|
||||||
|
@if (r.ok) {
|
||||||
|
<div animate.enter="app-item-enter">
|
||||||
|
<pre class="app-code">Postcode ="{{ r.value }}"</pre>
|
||||||
|
<p class="app-note">
|
||||||
|
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div animate.enter="app-item-enter">
|
||||||
|
<pre class="app-code">{{ r.error }}</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</app-concept-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ParseSection {
|
||||||
|
raw = signal('');
|
||||||
|
parsed = computed(() => parsePostcode(this.raw()));
|
||||||
|
|
||||||
|
protected readonly code: Record<string, string> = { parse: highlightTs(SNIPPETS['parse']) };
|
||||||
|
protected readonly src: Record<string, string> = {
|
||||||
|
parse: 'registratie/domain/value-objects/postcode.ts',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Component, computed, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
|
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
|
||||||
|
import { MaskedValueComponent } from '@shared/ui/atoms/masked-value/masked-value.component';
|
||||||
|
import { parseBsn } from '@shared/kernel/bsn';
|
||||||
|
import { maskBsn } from '@shared/kernel/pii';
|
||||||
|
import { ConceptCardComponent } from './concept-card.component';
|
||||||
|
import { SNIPPETS } from './snippets.generated';
|
||||||
|
import { highlightTs } from './highlight-ts';
|
||||||
|
|
||||||
|
/** Section 6: PII — masking and parsing. A BSN (Dutch citizen service number) is
|
||||||
|
special-category personal data (GDPR art. 9): masked by default, revealed only after
|
||||||
|
a logged action; "parse, don't validate" on the most sensitive field — a pure function
|
||||||
|
enforces the checksum. Composition-only. The ok/err result nests its own
|
||||||
|
`<app-concept-card>` for its label, for the same reason section 2 does. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-concepts-pii-section',
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
HeadingComponent,
|
||||||
|
TextInputComponent,
|
||||||
|
MaskedValueComponent,
|
||||||
|
ConceptCardComponent,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<section class="app-section">
|
||||||
|
<app-heading [level]="2">6 · PII — maskeren & parsen</app-heading>
|
||||||
|
<p class="app-lead">
|
||||||
|
Een BSN is bijzondere persoonsgegevens (AVG art. 9). Dataminimalisatie: standaard gemaskeerd
|
||||||
|
tonen, alleen tonen na een vastgelegde handeling; en"parse, don't validate" op het
|
||||||
|
gevoeligste veld — een pure functie die de <em>elfproef</em> afdwingt.
|
||||||
|
</p>
|
||||||
|
<div class="app-cols">
|
||||||
|
<app-concept-card
|
||||||
|
variant="good"
|
||||||
|
tag="Maskeren — atom"
|
||||||
|
[code]="code['mask']"
|
||||||
|
[src]="src['mask']"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
BSN:
|
||||||
|
<app-masked-value
|
||||||
|
[value]="bsnShown()"
|
||||||
|
[canReveal]="true"
|
||||||
|
revealLabel="Toon BSN"
|
||||||
|
(reveal)="bsnRevealed.set(true)"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<p class="app-note">
|
||||||
|
Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het
|
||||||
|
behandelscherm). De atom bevat de maskeer-detectie — geen los <code>*</code>-gesnuffel
|
||||||
|
bij elke gebruiker.
|
||||||
|
</p>
|
||||||
|
</app-concept-card>
|
||||||
|
<app-concept-card
|
||||||
|
tag="Parse (elfproef) → Result"
|
||||||
|
[code]="code['parseBsn']"
|
||||||
|
[src]="src['parseBsn']"
|
||||||
|
>
|
||||||
|
<app-text-input
|
||||||
|
inputId="bsn"
|
||||||
|
[ngModel]="bsnRaw()"
|
||||||
|
(ngModelChange)="bsnRaw.set($event)"
|
||||||
|
name="bsn"
|
||||||
|
placeholder="Typ een BSN, bijv. 123456782"
|
||||||
|
/>
|
||||||
|
@let b = bsnParsed();
|
||||||
|
@if (bsnRaw()) {
|
||||||
|
<div animate.enter="app-item-enter">
|
||||||
|
<app-concept-card [variant]="b.ok ? 'good' : 'bad'" [tag]="b.ok ? 'ok' : 'err'">
|
||||||
|
@if (b.ok) {
|
||||||
|
<pre class="app-code">Bsn ="{{ b.value }}"</pre>
|
||||||
|
} @else {
|
||||||
|
<pre class="app-code">{{ b.error }}</pre>
|
||||||
|
}
|
||||||
|
</app-concept-card>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</app-concept-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class PiiSection {
|
||||||
|
// Masked-by-default value that reveals locally (the real reveal is step-up-gated +
|
||||||
|
// audited elsewhere); plus a live elfproef parse mirroring the postcode demo.
|
||||||
|
demoBsn = '123456782';
|
||||||
|
bsnRevealed = signal(false);
|
||||||
|
bsnShown = computed(() => (this.bsnRevealed() ? this.demoBsn : maskBsn(this.demoBsn)));
|
||||||
|
bsnRaw = signal('');
|
||||||
|
bsnParsed = computed(() => parseBsn(this.bsnRaw()));
|
||||||
|
|
||||||
|
protected readonly code: Record<string, string> = {
|
||||||
|
mask: highlightTs(SNIPPETS['mask']),
|
||||||
|
parseBsn: highlightTs(SNIPPETS['parseBsn']),
|
||||||
|
};
|
||||||
|
protected readonly src: Record<string, string> = {
|
||||||
|
mask: 'shared/kernel/pii.ts',
|
||||||
|
parseBsn: 'shared/kernel/bsn.ts',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import type { Resource } from '@angular/core';
|
||||||
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
|
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||||
|
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
|
||||||
|
import { ConceptCardComponent } from './concept-card.component';
|
||||||
|
import { SNIPPETS } from './snippets.generated';
|
||||||
|
import { highlightTs } from './highlight-ts';
|
||||||
|
|
||||||
|
/** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */
|
||||||
|
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
|
||||||
|
return {
|
||||||
|
value: () => value as T,
|
||||||
|
status: () => status,
|
||||||
|
error: () => error,
|
||||||
|
hasValue: () => value !== undefined,
|
||||||
|
reload: () => {},
|
||||||
|
} as unknown as Resource<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Section 2: RemoteData fold. One value with four mutually exclusive states, instead of
|
||||||
|
three loose booleans. Composition-only; owns its own fake resources. Each of the four
|
||||||
|
demo states nests its own `<app-concept-card>` for its label — a plain `.tag` element
|
||||||
|
written here would carry this section's scope, not the card's, and stay unstyled. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-concepts-remote-data-section',
|
||||||
|
imports: [HeadingComponent, ...ASYNC, SkeletonComponent, ConceptCardComponent],
|
||||||
|
template: `
|
||||||
|
<section class="app-section">
|
||||||
|
<app-heading [level]="2">2 · RemoteData fold</app-heading>
|
||||||
|
<p class="app-lead">
|
||||||
|
Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.
|
||||||
|
</p>
|
||||||
|
<div class="app-cols">
|
||||||
|
<app-concept-card variant="good" tag="Vier toestanden, één molecuul">
|
||||||
|
<div class="app-stack">
|
||||||
|
<app-concept-card variant="plain" tag="Loading">
|
||||||
|
<app-async [resource]="loadingRes"
|
||||||
|
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template
|
||||||
|
><ng-template appAsyncLoading
|
||||||
|
><app-skeleton [count]="2" height="1.2rem" [delay]="0" /></ng-template
|
||||||
|
></app-async>
|
||||||
|
</app-concept-card>
|
||||||
|
<app-concept-card variant="plain" tag="Empty">
|
||||||
|
<app-async [resource]="emptyRes" [isEmpty]="isEmpty"
|
||||||
|
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
|
||||||
|
>
|
||||||
|
</app-concept-card>
|
||||||
|
<app-concept-card variant="plain" tag="Failure">
|
||||||
|
<app-async [resource]="errorRes"
|
||||||
|
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
|
||||||
|
>
|
||||||
|
</app-concept-card>
|
||||||
|
<app-concept-card variant="plain" tag="Success">
|
||||||
|
<app-async [resource]="successRes" [isEmpty]="isEmpty"
|
||||||
|
><ng-template appAsyncLoaded
|
||||||
|
><ul>
|
||||||
|
@for (i of successRes.value(); track i) {
|
||||||
|
<li>{{ i }}</li>
|
||||||
|
}
|
||||||
|
</ul></ng-template
|
||||||
|
></app-async
|
||||||
|
>
|
||||||
|
</app-concept-card>
|
||||||
|
</div>
|
||||||
|
</app-concept-card>
|
||||||
|
<app-concept-card
|
||||||
|
variant="good"
|
||||||
|
tag="De exhaustieve fold"
|
||||||
|
[code]="code['fold']"
|
||||||
|
[src]="src['fold']"
|
||||||
|
>
|
||||||
|
<p class="app-note">
|
||||||
|
Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem
|
||||||
|
afhandelt.
|
||||||
|
</p>
|
||||||
|
</app-concept-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class RemoteDataSection {
|
||||||
|
isEmpty = (v: string[]) => !v || v.length === 0;
|
||||||
|
|
||||||
|
loadingRes = fakeResource<string[]>('loading');
|
||||||
|
emptyRes = fakeResource<string[]>('resolved', []);
|
||||||
|
errorRes = fakeResource<string[]>('error', undefined, new Error('Demo'));
|
||||||
|
successRes = fakeResource<string[]>('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']);
|
||||||
|
|
||||||
|
protected readonly code: Record<string, string> = { fold: highlightTs(SNIPPETS['fold']) };
|
||||||
|
protected readonly src: Record<string, string> = { fold: 'shared/application/remote-data.ts' };
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
|
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
||||||
|
import { Registration } from '@registratie/domain/registration';
|
||||||
|
import { ConceptCardComponent } from './concept-card.component';
|
||||||
|
import { SNIPPETS } from './snippets.generated';
|
||||||
|
import { highlightTs } from './highlight-ts';
|
||||||
|
|
||||||
|
/** Section 1: discriminated unions. Each variant carries exactly the data that fits it —
|
||||||
|
nothing more. Composition-only; owns its own demo data. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-concepts-unions-section',
|
||||||
|
imports: [HeadingComponent, RegistrationSummaryComponent, ConceptCardComponent],
|
||||||
|
template: `
|
||||||
|
<section class="app-section">
|
||||||
|
<app-heading [level]="2">1 · Discriminated unions</app-heading>
|
||||||
|
<p class="app-lead">Laat elke variant precies de gegevens dragen die kloppen — niets meer.</p>
|
||||||
|
<div class="app-cols">
|
||||||
|
<app-concept-card variant="bad" tag="Fout — vlakke interface" [code]="code['unionBad']">
|
||||||
|
<p class="app-note">
|
||||||
|
Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.
|
||||||
|
</p>
|
||||||
|
</app-concept-card>
|
||||||
|
<app-concept-card
|
||||||
|
variant="good"
|
||||||
|
tag="Goed — sum type"
|
||||||
|
[code]="code['union']"
|
||||||
|
[src]="src['union']"
|
||||||
|
>
|
||||||
|
<app-registration-summary [reg]="doorgehaald" />
|
||||||
|
<p class="app-note">
|
||||||
|
De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat
|
||||||
|
simpelweg niet.
|
||||||
|
</p>
|
||||||
|
</app-concept-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class UnionsSection {
|
||||||
|
doorgehaald: Registration = {
|
||||||
|
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' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deliberately-wrong illustration (no real source to link — it shows the anti-pattern).
|
||||||
|
private readonly unionBad = `interface Registration {
|
||||||
|
status: 'Geregistreerd' | 'Doorgehaald';
|
||||||
|
herregistratieDatum: string; // altijd aanwezig 😬
|
||||||
|
}`;
|
||||||
|
|
||||||
|
/** Highlighted HTML per snippet: `union` comes from SNIPPETS (extracted from source by
|
||||||
|
gen:snippets — it can't drift), `unionBad` is authored above. */
|
||||||
|
protected readonly code: Record<string, string> = {
|
||||||
|
unionBad: highlightTs(this.unionBad),
|
||||||
|
union: highlightTs(SNIPPETS['union']),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The real file the linked snippet is extracted from (shown as a caption). */
|
||||||
|
protected readonly src: Record<string, string> = {
|
||||||
|
union: 'registratie/domain/registration.ts',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
|
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
|
||||||
|
import { ConceptCardComponent } from './concept-card.component';
|
||||||
|
import { SNIPPETS } from './snippets.generated';
|
||||||
|
import { highlightTs } from './highlight-ts';
|
||||||
|
|
||||||
|
/** Section 5: a questionnaire with a fixed step count — "show questions, don't add
|
||||||
|
steps". The step count never changes; follow-up questions reveal inline based on
|
||||||
|
earlier answers. Composition-only. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-concepts-vragenlijst-section',
|
||||||
|
imports: [HeadingComponent, IntakeWizardComponent, ConceptCardComponent],
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.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);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.pill.extra {
|
||||||
|
background: var(--rhc-color-geel-100);
|
||||||
|
border: 1px dashed var(--rhc-color-geel-600);
|
||||||
|
}
|
||||||
|
.arrow {
|
||||||
|
color: var(--rhc-color-grijs-400);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<section class="app-section">
|
||||||
|
<app-heading [level]="2"
|
||||||
|
>5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen"</app-heading
|
||||||
|
>
|
||||||
|
<p class="app-lead">
|
||||||
|
Het aantal stappen ligt vast (<code>STEPS</code>); vervolgvragen verschijnen
|
||||||
|
<em>binnen</em> 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.
|
||||||
|
</p>
|
||||||
|
<div class="app-cols">
|
||||||
|
<app-concept-card
|
||||||
|
variant="good"
|
||||||
|
tag="Vaste stappen"
|
||||||
|
[code]="code['steps']"
|
||||||
|
[src]="src['steps']"
|
||||||
|
>
|
||||||
|
<div class="steplist">
|
||||||
|
@for (s of iw.steps; track s; let last = $last) {
|
||||||
|
<span class="pill">{{ s }}</span>
|
||||||
|
@if (!last) {
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="app-note">
|
||||||
|
De stappen zijn altijd dezelfde; alleen de vragen <em>binnen</em> een stap verschijnen
|
||||||
|
of verdwijnen.
|
||||||
|
</p>
|
||||||
|
</app-concept-card>
|
||||||
|
<app-concept-card variant="good" tag="De wizard">
|
||||||
|
<app-intake-wizard #iw />
|
||||||
|
</app-concept-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class VragenlijstSection {
|
||||||
|
protected readonly code: Record<string, string> = { steps: highlightTs(SNIPPETS['steps']) };
|
||||||
|
protected readonly src: Record<string, string> = {
|
||||||
|
steps: 'herregistratie/domain/intake.machine.ts',
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -15,7 +15,7 @@ status codes and error envelope are production-shaped.
|
|||||||
covers it, see `docker-compose.yml`) does **not** lose data. Delete the file to
|
covers it, see `docker-compose.yml`) does **not** lose data. Delete the file to
|
||||||
reset demo data back to empty, the same state a fresh clone starts from. This is
|
reset demo data back to empty, the same state a fresh clone starts from. This is
|
||||||
a deliberate, right-sized choice for a POC (SQLite, no external DB service) — see
|
a deliberate, right-sized choice for a POC (SQLite, no external DB service) — see
|
||||||
`docs/project/backlog/WP-22-durable-persistence.md`.
|
`docs/project/archive/backlog/WP-22-durable-persistence.md`.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ public static class LetterHtml
|
|||||||
|
|
||||||
// Walks up from the running assembly's own directory (NOT the process cwd, which
|
// Walks up from the running assembly's own directory (NOT the process cwd, which
|
||||||
// varies by how `dotnet run`/docker/tests invoke it — see
|
// varies by how `dotnet run`/docker/tests invoke it — see
|
||||||
// docs/project/backlog/WP-25-letter-preview-html.md) until it finds `public/letter.css`. docker-compose.yml bind-mounts `./public` under the
|
// docs/project/archive/backlog/WP-25-letter-preview-html.md) until it finds `public/letter.css`. docker-compose.yml bind-mounts `./public` under the
|
||||||
// api container's `/src` for exactly this walk to resolve there too.
|
// api container's `/src` for exactly this walk to resolve there too.
|
||||||
private static string FindLetterCss()
|
private static string FindLetterCss()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ services:
|
|||||||
command: dotnet run --project src/BigRegister.Api --urls http://+:5000
|
command: dotnet run --project src/BigRegister.Api --urls http://+:5000
|
||||||
environment:
|
environment:
|
||||||
- ASPNETCORE_ENVIRONMENT=Development
|
- ASPNETCORE_ENVIRONMENT=Development
|
||||||
|
# ponytail: Server GC makes one heap per CPU (22 here); workstation GC makes one.
|
||||||
|
- DOTNET_gcServer=0
|
||||||
volumes:
|
volumes:
|
||||||
# ':z' relabels for SELinux (Fedora/RHEL); harmless on other hosts.
|
# ':z' relabels for SELinux (Fedora/RHEL); harmless on other hosts.
|
||||||
# WP-22: no separate volume needed for the SQLite file — `dotnet run` sets
|
# WP-22: no separate volume needed for the SQLite file — `dotnet run` sets
|
||||||
@@ -41,6 +43,8 @@ services:
|
|||||||
# switcher actually switches. ponytail: `--no-fund --loglevel=error` silences npm 11 noise.
|
# switcher actually switches. ponytail: `--no-fund --loglevel=error` silences npm 11 noise.
|
||||||
command: sh -c "npm ci --no-fund --loglevel=error && npx ng build ssp --configuration development --localize && node scripts/serve-i18n.mjs"
|
command: sh -c "npm ci --no-fund --loglevel=error && npx ng build ssp --configuration development --localize && node scripts/serve-i18n.mjs"
|
||||||
environment:
|
environment:
|
||||||
|
# ponytail: without a cgroup limit Node sizes its heap from host RAM (16 GB).
|
||||||
|
- NODE_OPTIONS=--max-old-space-size=2048
|
||||||
- PORT=4200
|
- PORT=4200
|
||||||
- API_PROXY_TARGET=http://api:5000
|
- API_PROXY_TARGET=http://api:5000
|
||||||
- APP_DIST_ROOT=dist/ssp/browser
|
- APP_DIST_ROOT=dist/ssp/browser
|
||||||
@@ -57,6 +61,8 @@ services:
|
|||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: sh -c "npm ci --no-fund --loglevel=error && npx ng build behandelportal --configuration development --localize && node scripts/serve-i18n.mjs"
|
command: sh -c "npm ci --no-fund --loglevel=error && npx ng build behandelportal --configuration development --localize && node scripts/serve-i18n.mjs"
|
||||||
environment:
|
environment:
|
||||||
|
# ponytail: without a cgroup limit Node sizes its heap from host RAM (16 GB).
|
||||||
|
- NODE_OPTIONS=--max-old-space-size=2048
|
||||||
- PORT=4201
|
- PORT=4201
|
||||||
- API_PROXY_TARGET=http://api:5000
|
- API_PROXY_TARGET=http://api:5000
|
||||||
- APP_DIST_ROOT=dist/behandelportal/browser
|
- APP_DIST_ROOT=dist/behandelportal/browser
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Archive — finished backlogs
|
||||||
|
|
||||||
|
The trees in this directory are historical. Every ticket in `backlog/` and
|
||||||
|
`refactor-backlog-setup/` carries `Status: done`. `SHOWCASE-ROADMAP.md` was superseded before
|
||||||
|
either arc started.
|
||||||
|
|
||||||
|
Git holds the rest — `git log --follow` on any file here shows its full history, including the
|
||||||
|
work that produced it.
|
||||||
|
|
||||||
|
The live plan is `docs/project/readable-codebase/`. Product reference material is
|
||||||
|
`docs/project/prd/`.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Showcase roadmap — superseded
|
# Showcase roadmap — superseded
|
||||||
|
|
||||||
**This roadmap is superseded by [`docs/project/backlog/`](backlog/README.md)** (2026-07-02).
|
**This roadmap is superseded by [`docs/project/archive/backlog/`](backlog/README.md)** (2026-07-02).
|
||||||
|
|
||||||
The backlog absorbs and corrects this document: its Storybook-as-curriculum track became
|
The backlog absorbs and corrects this document: its Storybook-as-curriculum track became
|
||||||
WP-14/15 (+ per-invariant MDX pages in WP-05/07/08/13/17), its enforcement track became
|
WP-14/15 (+ per-invariant MDX pages in WP-05/07/08/13/17), its enforcement track became
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user