Compare commits
37
Commits
c36d9e3ff0
...
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 | ||
|
|
dff5f96bb3 | ||
|
|
831940f1b9 | ||
|
|
a196a380ce | ||
|
|
8560746d15 | ||
|
|
dd11eafe50 | ||
|
|
3895588b9a | ||
|
|
fb2e58ab87 | ||
|
|
e221834f6e |
@@ -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
|
||||||
@@ -20,7 +20,7 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'aanvraag/:id',
|
path: 'aanvraag/:id',
|
||||||
// Same capability the werkvoorraad list itself is gated by (WP-64/65) — the
|
// Same capability the werkvoorraad list itself is gated by — the
|
||||||
// detail page is reachable only from a row already filtered to that capability.
|
// detail page is reachable only from a row already filtered to that capability.
|
||||||
canActivate: [capabilityGuard('aanvraag:beoordelen')],
|
canActivate: [capabilityGuard('aanvraag:beoordelen')],
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
@@ -36,14 +36,14 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'beheer/audit',
|
path: 'beheer/audit',
|
||||||
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default
|
// Admin-only authz/PII-reveal audit trail. capabilityGuard denies-by-default
|
||||||
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
|
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
|
||||||
canActivate: [capabilityGuard('cases:manage')],
|
canActivate: [capabilityGuard('cases:manage')],
|
||||||
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
|
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'beheer/functies',
|
path: 'beheer/functies',
|
||||||
// Admin-only feature-flag toggles (WP-47), gated by `flags:manage`.
|
// Admin-only feature-flag toggles, gated by `flags:manage`.
|
||||||
canActivate: [capabilityGuard('flags:manage')],
|
canActivate: [capabilityGuard('flags:manage')],
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
|
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { MEDEWERKER_ID, currentRollen } from './medewerker';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Infrastructure: resolves the current medewerker identity into a `Principal`
|
* Infrastructure: resolves the current medewerker identity into a `Principal`
|
||||||
* (ADR-C-004/RB-13). Stands in for a real employee-SSO redirect flow (ADR-0002 §3,
|
* (ADR-C-004). Stands in for a real employee-SSO redirect flow (ADR-0002 §3,
|
||||||
* "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s
|
* "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s
|
||||||
* BSN check, no format to reject, so `authenticate()` takes no input and returns the
|
* BSN check, no format to reject, so `authenticate()` takes no input and returns the
|
||||||
* `Principal` directly rather than a `Result` with an error variant that can never
|
* `Principal` directly rather than a `Result` with an error variant that can never
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
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/RB-13). No real auth — and,
|
* Organism: employee-SSO-style mock login (ADR-C-004). No real auth — and,
|
||||||
* unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no
|
* unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no
|
||||||
* BSN, and this app has no password of its own to check either way. There is
|
* BSN, and this app has no password of its own to check either way. There is
|
||||||
* nothing to compose beyond one button, which is itself evidence for the ADR — the
|
* nothing to compose beyond one button, which is itself evidence for the ADR — the
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
|
|
||||||
type Err = Error | undefined;
|
type Err = Error | undefined;
|
||||||
|
|
||||||
/** One aanvraag's beoordeling detail (WP-65) — a root singleton like `WerkvoorraadStore`.
|
/** One aanvraag's beoordeling detail — a root singleton like `WerkvoorraadStore`.
|
||||||
Keyed by id: navigating to a different case resets to Loading. */
|
Keyed by id: navigating to a different case resets to Loading. */
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class BeoordelingStore {
|
export class BeoordelingStore {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
|
|
||||||
type Err = Error | undefined;
|
type Err = Error | undefined;
|
||||||
|
|
||||||
/** The behandelaar's queue (WP-64) — a root singleton like `AdminCasesStore`'s ssp
|
/** The behandelaar's queue — a root singleton like `AdminCasesStore`'s ssp
|
||||||
counterpart. Fetch + parse at the trust boundary, publish as RemoteData. */
|
counterpart. Fetch + parse at the trust boundary, publish as RemoteData. */
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class WerkvoorraadStore {
|
export class WerkvoorraadStore {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { formatDatumNl } from '@shared/kernel/datum';
|
|||||||
import { AanvraagType } from './werkvoorraad-item';
|
import { AanvraagType } from './werkvoorraad-item';
|
||||||
import { BeoordelingStatus, BeoordelingView } from './beoordeling';
|
import { BeoordelingStatus, BeoordelingView } from './beoordeling';
|
||||||
|
|
||||||
/** View-model mapping shared by the werkvoorraad list (WP-64) and the beoordeling
|
/** View-model mapping shared by the werkvoorraad list and the beoordeling
|
||||||
detail screen (WP-65): type/status → labels. Pure, no Angular. Lives here (not in
|
detail screen: type/status → labels. Pure, no Angular. Lives here (not in
|
||||||
`werkvoorraad-item-view.ts`) because `BeoordelingStatus` is the wider of the two
|
`werkvoorraad-item-view.ts`) because `BeoordelingStatus` is the wider of the two
|
||||||
status unions — `werkvoorraad-item-view.ts` re-exports these for its own use. */
|
status unions — `werkvoorraad-item-view.ts` re-exports these for its own use. */
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { AanvraagType } from './werkvoorraad-item';
|
import { AanvraagType } from './werkvoorraad-item';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A case's full status lifecycle as the beoordeling detail screen sees it (WP-65) —
|
* A case's full status lifecycle as the beoordeling detail screen sees it —
|
||||||
* wider than `WerkvoorraadStatus` (WP-64), which only ever sees the two "still open"
|
* wider than `WerkvoorraadStatus`, which only ever sees the two "still open"
|
||||||
* tags. This is the same five-tag union ssp's `AanvraagStatus` models (minus `Concept`
|
* tags. This is the same five-tag union ssp's `AanvraagStatus` models (minus `Concept`
|
||||||
* — the detail endpoint 404s a Concept, it isn't a case a behandelaar can treat yet).
|
* — the detail endpoint 404s a Concept, it isn't a case a behandelaar can treat yet).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Result, assertNever } from '@shared/kernel/fp';
|
import { Result, assertNever } from '@shared/kernel/fp';
|
||||||
|
|
||||||
/** The three actions the beoordeling screen offers a behandelaar (WP-65b) — mirrors the
|
/** The three actions the beoordeling screen offers a behandelaar — mirrors the
|
||||||
backend's `Besluit` enum member names 1:1 (the wire convention: a string, not a raw
|
backend's `Besluit` enum member names 1:1 (the wire convention: a string, not a raw
|
||||||
enum — see `RecordBesluitRequest`). */
|
enum — see `RecordBesluitRequest`). */
|
||||||
const BESLUIT_TAGS = ['Goedkeuren', 'Afwijzen', 'MeerInfoOpvragen'] as const;
|
const BESLUIT_TAGS = ['Goedkeuren', 'Afwijzen', 'MeerInfoOpvragen'] as const;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* A queue entry as the behandelportal sees it (WP-64) — the parsed, domain-side view
|
* A queue entry as the behandelportal sees it — the parsed, domain-side view
|
||||||
* of the backend's cross-owner `GET /werkvoorraad`. Pure types, no Angular.
|
* of the backend's cross-owner `GET /werkvoorraad`. Pure types, no Angular.
|
||||||
*
|
*
|
||||||
* The status union is narrower than the SSP's full `AanvraagStatus` (ssp's
|
* The status union is narrower than the SSP's full `AanvraagStatus` (ssp's
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { AanvraagType } from '@behandeling/domain/werkvoorraad-item';
|
import { AanvraagType } from '@behandeling/domain/werkvoorraad-item';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infrastructure adapter for the beoordeling detail read (WP-65) — the only place its
|
* Infrastructure adapter for the beoordeling detail read — the only place its
|
||||||
* HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response is validated +
|
* HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response is validated +
|
||||||
* mapped to domain by the parse* boundary below.
|
* mapped to domain by the parse* boundary below.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ApiClient } from '@shared/infrastructure/api-client';
|
|||||||
import { Valid } from '@behandeling/domain/besluit.machine';
|
import { Valid } from '@behandeling/domain/besluit.machine';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infrastructure adapter for recording a behandelaar's decision (WP-65b) — the single
|
* Infrastructure adapter for recording a behandelaar's decision — the single
|
||||||
* place its HTTP lives. No return value: a successful call means the server accepted
|
* place its HTTP lives. No return value: a successful call means the server accepted
|
||||||
* the transition; the caller reloads `BeoordelingStore` to see the new status (the
|
* the transition; the caller reloads `BeoordelingStore` to see the new status (the
|
||||||
* server, not this adapter, re-validates and is the authority).
|
* server, not this adapter, re-validates and is the authority).
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from '@behandeling/domain/werkvoorraad-item';
|
} from '@behandeling/domain/werkvoorraad-item';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infrastructure adapter for the behandelportal's queue read (WP-64) — the only
|
* Infrastructure adapter for the behandelportal's queue read — the only
|
||||||
* place its HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response
|
* place its HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response
|
||||||
* is validated + mapped to the (narrower) queue domain shape by the parse* boundary
|
* is validated + mapped to the (narrower) queue domain shape by the parse* boundary
|
||||||
* below; a case whose status isn't `Ingediend`/`InBehandeling` is a parse error, not
|
* below; a case whose status isn't `Ingediend`/`InBehandeling` is a parse error, not
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { Component, input } from '@angular/core';
|
import { Component, input } from '@angular/core';
|
||||||
import { BeoordelingDocument } from '@behandeling/domain/beoordeling';
|
import { BeoordelingDocument } from '@behandeling/domain/beoordeling';
|
||||||
|
|
||||||
/** Organism: the documents linked to an aanvraag (WP-65) — plain links to the existing
|
/** Organism: the documents linked to an aanvraag — plain links to the existing
|
||||||
(pre-existing, unauthenticated — same as ssp's own document previews) content
|
(pre-existing, unauthenticated — same as ssp's own document previews) content
|
||||||
endpoint. No new shared atom: a context-local list, not a reusable building block. */
|
endpoint. No new shared atom: a context-local list, not a reusable building block. */
|
||||||
@Component({
|
@Component({
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
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 { 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';
|
||||||
import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.component';
|
import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page: one aanvraag's beoordeling detail (WP-65). The werkvoorraad list (WP-64) links
|
* Page: one aanvraag's beoordeling detail. The werkvoorraad list links
|
||||||
* here. `canBesluiten` (server-computed, ADR-0001) gates the decision form (WP-65b) —
|
* here. `canBesluiten` (server-computed, ADR-0001) gates the decision form —
|
||||||
* the page never recomputes the lifecycle itself. On a recorded decision the form emits
|
* the page never recomputes the lifecycle itself. On a recorded decision the form emits
|
||||||
* `decided`, and the page just reloads (the server is the authority on the new status).
|
* `decided`, and the page just reloads (the server is the authority on the new status).
|
||||||
*/
|
*/
|
||||||
@@ -73,10 +74,7 @@ export class BeoordelingPage {
|
|||||||
protected retryText = $localize`:@@beoordeling.retry:Opnieuw proberen`;
|
protected retryText = $localize`:@@beoordeling.retry:Opnieuw proberen`;
|
||||||
|
|
||||||
protected rows = detailRows;
|
protected rows = detailRows;
|
||||||
protected readonly view = computed(() => {
|
protected readonly view = computed(() => successOf(this.store.view()));
|
||||||
const rd = this.store.view();
|
|
||||||
return rd.tag === 'Success' ? rd.value : undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
void this.store.load(this.id);
|
void this.store.load(this.id);
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
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';
|
||||||
import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
|
import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Organism: the decision form (WP-65b) — goedkeuren/afwijzen/meer-info-opvragen. Same
|
* Organism: the decision form — goedkeuren/afwijzen/meer-info-opvragen. Same
|
||||||
* idiom as every other form in this house (`change-request-form`): all state in one
|
* idiom as every other form in this house (`change-request-form`): all state in one
|
||||||
* signal driven by the pure `reduce` (besluit.machine.ts), submitted via a `submit-*`
|
* signal driven by the pure `reduce` (besluit.machine.ts), submitted via a `submit-*`
|
||||||
* command returning `Result`. The server re-validates the transition and is the
|
* command returning `Result`. The server re-validates the transition and is the
|
||||||
|
|||||||
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
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';
|
||||||
|
|
||||||
/** Organism: the behandelaar's queue as CIBG "aanvragen" rows (WP-64) — composition
|
/** Organism: the behandelaar's queue as CIBG "aanvragen" rows — composition
|
||||||
of the two existing shared/ui molecules, no new atom. Each row links to the
|
of the two existing shared/ui molecules, no new atom. Each row links to the
|
||||||
beoordeling detail page (WP-65). */
|
beoordeling detail page. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-werkvoorraad-list',
|
selector: 'app-werkvoorraad-list',
|
||||||
imports: [ApplicationListComponent, ApplicationLinkComponent],
|
imports: [ApplicationListComponent, ApplicationLinkComponent],
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
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 { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store';
|
import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store';
|
||||||
import { WerkvoorraadListComponent } from '@behandeling/ui/werkvoorraad-list/werkvoorraad-list.component';
|
import { WerkvoorraadListComponent } from '@behandeling/ui/werkvoorraad-list/werkvoorraad-list.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page: the behandelaar's werkvoorraad (WP-64) — the behandelportal's landing page.
|
* Page: the behandelaar's werkvoorraad — the behandelportal's landing page.
|
||||||
* Deny-by-default capability gate (`aanvraag:beoordelen`), same idiom as ssp's
|
* Deny-by-default capability gate (`aanvraag:beoordelen`), same idiom as ssp's
|
||||||
* AdminCasesPage: a denial alert for a non-behandelaar, the queue for one. Opening
|
* AdminCasesPage: a denial alert for a non-behandelaar, the queue for one. Opening
|
||||||
* a case's detail is out of scope here (WP-65).
|
* a case's detail is out of scope here.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-werkvoorraad-page',
|
selector: 'app-werkvoorraad-page',
|
||||||
@@ -56,10 +57,7 @@ export class WerkvoorraadPage {
|
|||||||
protected access = inject(AccessStore);
|
protected access = inject(AccessStore);
|
||||||
|
|
||||||
protected canBeoordelen = computed(() => this.access.can('aanvraag:beoordelen'));
|
protected canBeoordelen = computed(() => this.access.can('aanvraag:beoordelen'));
|
||||||
protected items = computed(() => {
|
protected items = computed(() => successOr(this.store.items(), []));
|
||||||
const rd = this.store.items();
|
|
||||||
return rd.tag === 'Success' ? rd.value : [];
|
|
||||||
});
|
|
||||||
|
|
||||||
protected heading = $localize`:@@werkvoorraad.heading:Werkvoorraad`;
|
protected heading = $localize`:@@werkvoorraad.heading:Werkvoorraad`;
|
||||||
protected intro = $localize`:@@werkvoorraad.intro:Aanvragen die op beoordeling wachten.`;
|
protected intro = $localize`:@@werkvoorraad.intro:Aanvragen die op beoordeling wachten.`;
|
||||||
@@ -71,7 +69,7 @@ export class WerkvoorraadPage {
|
|||||||
private loadRequested = false;
|
private loadRequested = false;
|
||||||
constructor() {
|
constructor() {
|
||||||
// Load once the capability resolves to allowed (a 403 GET would be wasted otherwise) —
|
// Load once the capability resolves to allowed (a 403 GET would be wasted otherwise) —
|
||||||
// same guard-against-the-loop idiom as AdminCasesPage (WP-26 lesson).
|
// same guard-against-the-loop idiom as AdminCasesPage.
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (this.canBeoordelen() && !this.loadRequested) {
|
if (this.canBeoordelen() && !this.loadRequested) {
|
||||||
this.loadRequested = true;
|
this.loadRequested = true;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const NAV_ITEMS: readonly HeaderNavItem[] = [
|
|||||||
|
|
||||||
/** This app's admin pages — provided to the shared site header via HEADER_ADMIN_LINKS.
|
/** This app's admin pages — provided to the shared site header via HEADER_ADMIN_LINKS.
|
||||||
No huisstijl (that's the SSP's brief context) or zaken entry — inherited as-is from
|
No huisstijl (that's the SSP's brief context) or zaken entry — inherited as-is from
|
||||||
WP-61's bootstrap trim, not revisited by this migration. */
|
the bootstrap trim, not revisited by this migration. */
|
||||||
export const ADMIN_LINKS: readonly AdminLink[] = [
|
export const ADMIN_LINKS: readonly AdminLink[] = [
|
||||||
{
|
{
|
||||||
label: $localize`:@@header.nav.stamdata:Stamdata`,
|
label: $localize`:@@header.nav.stamdata:Stamdata`,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'brief/huisstijl',
|
path: 'brief/huisstijl',
|
||||||
// Admin-only org-template editor (WP-26): capabilityGuard denies-by-default
|
// Admin-only org-template editor: capabilityGuard denies-by-default
|
||||||
// unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces
|
// unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces
|
||||||
// via the OrgAdmin gate — the guard just avoids loading a page that would 403.
|
// via the OrgAdmin gate — the guard just avoids loading a page that would 403.
|
||||||
canActivate: [capabilityGuard('orgtemplate:edit')],
|
canActivate: [capabilityGuard('orgtemplate:edit')],
|
||||||
@@ -78,7 +78,7 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'beheer/zaken',
|
path: 'beheer/zaken',
|
||||||
// Admin-only cases overview + delete (WP-36): capabilityGuard denies-by-default
|
// Admin-only cases overview + delete: capabilityGuard denies-by-default
|
||||||
// unless GET /me resolved `cases:manage` (Admin role). Backend re-enforces via the
|
// unless GET /me resolved `cases:manage` (Admin role). Backend re-enforces via the
|
||||||
// CasesAdmin gate — the guard just avoids loading a page that would 403. The page
|
// CasesAdmin gate — the guard just avoids loading a page that would 403. The page
|
||||||
// lives in registratie/ui (which owns the Aanvraag aggregate); routed under /beheer.
|
// lives in registratie/ui (which owns the Aanvraag aggregate); routed under /beheer.
|
||||||
@@ -88,14 +88,14 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'beheer/audit',
|
path: 'beheer/audit',
|
||||||
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default
|
// Admin-only authz/PII-reveal audit trail. capabilityGuard denies-by-default
|
||||||
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
|
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
|
||||||
canActivate: [capabilityGuard('cases:manage')],
|
canActivate: [capabilityGuard('cases:manage')],
|
||||||
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
|
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'beheer/functies',
|
path: 'beheer/functies',
|
||||||
// Admin-only feature-flag toggles (WP-47), gated by `flags:manage`.
|
// Admin-only feature-flag toggles, gated by `flags:manage`.
|
||||||
canActivate: [capabilityGuard('flags:manage')],
|
canActivate: [capabilityGuard('flags:manage')],
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
|
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Principal } from '../domain/principal';
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class DigidAdapter {
|
export class DigidAdapter {
|
||||||
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
|
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
|
||||||
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
|
// Real BSN validation (parseBsn) is the trust boundary; swap the fixed identity
|
||||||
// for a real OIDC redirect flow when there's an IdP.
|
// for a real OIDC redirect flow when there's an IdP.
|
||||||
async authenticate(bsn: string): Promise<Result<string, Principal>> {
|
async authenticate(bsn: string): Promise<Result<string, Principal>> {
|
||||||
const r = parseBsn(bsn);
|
const r = parseBsn(bsn);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const caseContext: CaseContext = {
|
|||||||
|
|
||||||
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
|
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
|
||||||
|
|
||||||
/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of
|
/** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of
|
||||||
touching the DOM, so a spec can assert a command's success path directly. */
|
touching the DOM, so a spec can assert a command's success path directly. */
|
||||||
function fakeBlobPresenter() {
|
function fakeBlobPresenter() {
|
||||||
const opened: Blob[] = [];
|
const opened: Blob[] = [];
|
||||||
@@ -158,7 +158,7 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- WP-27: undo/redo history + rejection diff ---
|
// --- Undo/redo history + rejection diff ---
|
||||||
|
|
||||||
function block(id: string, text: string): LetterBlock {
|
function block(id: string, text: string): LetterBlock {
|
||||||
return {
|
return {
|
||||||
@@ -308,7 +308,7 @@ describe('BriefStore rejection diff', () => {
|
|||||||
describe('BriefStore.previewLetter', () => {
|
describe('BriefStore.previewLetter', () => {
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
it('opens the composed letter via BLOB_PRESENTER on success (RB-28)', async () => {
|
it('opens the composed letter via BLOB_PRESENTER on success', async () => {
|
||||||
const { presenter, opened } = fakeBlobPresenter();
|
const { presenter, opened } = fakeBlobPresenter();
|
||||||
const store = setup(
|
const store = setup(
|
||||||
{
|
{
|
||||||
@@ -412,11 +412,11 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- RB-22 (CQ-007 expand half): a 404 from GET /brief tolerates by calling the
|
// --- CQ-007's expand half: a 404 from GET /brief tolerates by calling the
|
||||||
// existing reset() command, exactly once. Today's backend never 404s (RB-23 adds
|
// existing reset() command, exactly once. Today's backend never 404s yet;
|
||||||
// that); this fake adapter is what exercises the branch until then. ---
|
// this fake adapter is what exercises the branch until then. ---
|
||||||
|
|
||||||
describe('BriefStore.load — 404 tolerance (RB-22)', () => {
|
describe('BriefStore.load — 404 tolerance', () => {
|
||||||
const notFound: Result<BriefLoadFailure, BriefView> = { ok: false, error: { tag: 'notFound' } };
|
const notFound: Result<BriefLoadFailure, BriefView> = { ok: false, error: { tag: 'notFound' } };
|
||||||
const resetOk: Result<string, BriefView> = { ok: true, value: view };
|
const resetOk: Result<string, BriefView> = { ok: true, value: view };
|
||||||
|
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ export class BriefStore implements PendingSave {
|
|||||||
/** Surfaced autosave state for the indicator + aria-live region. */
|
/** Surfaced autosave state for the indicator + aria-live region. */
|
||||||
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
||||||
|
|
||||||
/** Undo/redo is SHELL state, not machine state (WP-27): a `createHistory` stack of
|
/** Undo/redo is SHELL state, not machine state: a `createHistory` stack of
|
||||||
`Brief` snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded
|
`Brief` snapshots (the mechanics live in a shared helper). Only CONTENT edits are recorded
|
||||||
(they flow through `edit()`); status transitions never enter history, or undo would
|
(they flow through `edit()`); status transitions never enter history, or undo would
|
||||||
replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine
|
replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine
|
||||||
changes. */
|
changes. */
|
||||||
@@ -64,7 +64,7 @@ export class BriefStore implements PendingSave {
|
|||||||
readonly canUndo = this.history.canUndo;
|
readonly canUndo = this.history.canUndo;
|
||||||
readonly canRedo = this.history.canRedo;
|
readonly canRedo = this.history.canRedo;
|
||||||
|
|
||||||
/** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The
|
/** The letter as it stood when it was REJECTED, captured shell-side. The
|
||||||
approver diffs it against the resubmitted letter. POC limit: in-memory only, so a
|
approver diffs it against the resubmitted letter. POC limit: in-memory only, so a
|
||||||
full page reload loses it — a real system would persist the rejected revision. */
|
full page reload loses it — a real system would persist the rejected revision. */
|
||||||
private rejectionSnapshot = signal<Brief | null>(null);
|
private rejectionSnapshot = signal<Brief | null>(null);
|
||||||
@@ -81,7 +81,7 @@ export class BriefStore implements PendingSave {
|
|||||||
);
|
);
|
||||||
readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
|
readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
|
||||||
|
|
||||||
/** The org template the letter renders with (WP-24). Server-owned appearance data,
|
/** The org template the letter renders with. Server-owned appearance data,
|
||||||
not letter state — held beside the machine, never inside it (`brief.machine.ts`
|
not letter state — held beside the machine, never inside it (`brief.machine.ts`
|
||||||
stays untouched by design). Set from every server view that carries it. */
|
stays untouched by design). Set from every server view that carries it. */
|
||||||
readonly orgTemplate = signal<OrgTemplate | null>(null);
|
readonly orgTemplate = signal<OrgTemplate | null>(null);
|
||||||
@@ -125,7 +125,7 @@ export class BriefStore implements PendingSave {
|
|||||||
return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());
|
return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());
|
||||||
});
|
});
|
||||||
|
|
||||||
/** True once a 404-triggered recovery has been attempted (RB-22, CQ-007's expand
|
/** True once a 404-triggered recovery has been attempted (CQ-007's expand
|
||||||
half — see `recoverFromMissingBrief`). This is the structural once-only bound:
|
half — see `recoverFromMissingBrief`). This is the structural once-only bound:
|
||||||
a repeated 404 falls straight to the `error` branch below and can never reach
|
a repeated 404 falls straight to the `error` branch below and can never reach
|
||||||
`adapter.reset()` a second time, regardless of how many times `load()` runs. */
|
`adapter.reset()` a second time, regardless of how many times `load()` runs. */
|
||||||
@@ -200,7 +200,7 @@ export class BriefStore implements PendingSave {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 600ms debounced autosave (the server is the store of record). Timer mechanics live in
|
// 600ms debounced autosave (the server is the store of record). Timer mechanics live in
|
||||||
// the shared helper; `flushSave` below is the store-specific write + save-state (WP-31).
|
// the shared helper; `flushSave` below is the store-specific write + save-state.
|
||||||
private debouncedSave = createDebouncedSave({
|
private debouncedSave = createDebouncedSave({
|
||||||
canSave: () => this.canEdit(),
|
canSave: () => this.canEdit(),
|
||||||
flush: () => this.flushSave(),
|
flush: () => this.flushSave(),
|
||||||
@@ -223,7 +223,7 @@ export class BriefStore implements PendingSave {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Retry a failed autosave — reuses the existing flush path, no new state (WP-27). */
|
/** Retry a failed autosave — reuses the existing flush path, no new state. */
|
||||||
retrySave() {
|
retrySave() {
|
||||||
void this.flushSave();
|
void this.flushSave();
|
||||||
}
|
}
|
||||||
@@ -311,7 +311,7 @@ export class BriefStore implements PendingSave {
|
|||||||
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
|
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
|
||||||
break;
|
break;
|
||||||
case 'rejected':
|
case 'rejected':
|
||||||
// Capture the letter as-rejected for the resubmission diff (WP-27). This is the
|
// Capture the letter as-rejected for the resubmission diff. This is the
|
||||||
// "before" snapshot the approver later compares against.
|
// "before" snapshot the approver later compares against.
|
||||||
this.rejectionSnapshot.set(brief);
|
this.rejectionSnapshot.set(brief);
|
||||||
this.store.dispatch({
|
this.store.dispatch({
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const subOrgs: SubOrgSummary[] = [
|
|||||||
{ subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 },
|
{ subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of
|
/** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of
|
||||||
touching the DOM, so a spec can assert a command's success path directly. */
|
touching the DOM, so a spec can assert a command's success path directly. */
|
||||||
function fakeBlobPresenter() {
|
function fakeBlobPresenter() {
|
||||||
const opened: Blob[] = [];
|
const opened: Blob[] = [];
|
||||||
@@ -70,10 +70,10 @@ function setup(
|
|||||||
return TestBed.inject(OrgTemplateStore);
|
return TestBed.inject(OrgTemplateStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- RB-28 (TE-006): proefbrief() ends in BLOB_PRESENTER.open, not a raw
|
// --- TE-006: proefbrief() ends in BLOB_PRESENTER.open, not a raw
|
||||||
// window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. ---
|
// window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. ---
|
||||||
|
|
||||||
describe('OrgTemplateStore.proefbrief (RB-28)', () => {
|
describe('OrgTemplateStore.proefbrief', () => {
|
||||||
it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => {
|
it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => {
|
||||||
// Given a loaded sub-org template.
|
// Given a loaded sub-org template.
|
||||||
const { presenter, opened } = fakeBlobPresenter();
|
const { presenter, opened } = fakeBlobPresenter();
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const LOGO_CATEGORY = 'org-logo';
|
|||||||
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;
|
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root singleton for the admin org-template editor (WP-26). The Elm machine owns the
|
* Root singleton for the admin org-template editor. The Elm machine owns the
|
||||||
* editable draft; commands here do the debounced save, publish (impact-confirm),
|
* editable draft; commands here do the debounced save, publish (impact-confirm),
|
||||||
* rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The
|
* rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The
|
||||||
* logo upload reuses the shared upload transport; its completion mutates the draft
|
* logo upload reuses the shared upload transport; its completion mutates the draft
|
||||||
@@ -151,7 +151,7 @@ export class OrgTemplateStore implements PendingSave {
|
|||||||
this.debouncedSave.schedule();
|
this.debouncedSave.schedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 600ms debounced autosave (same idiom as BriefStore, WP-31). Timer mechanics live in the
|
// 600ms debounced autosave (same idiom as BriefStore). Timer mechanics live in the
|
||||||
// shared helper; `flushSave` below is the store-specific write + save-state.
|
// shared helper; `flushSave` below is the store-specific write + save-state.
|
||||||
private debouncedSave = createDebouncedSave({
|
private debouncedSave = createDebouncedSave({
|
||||||
canSave: () => this.loaded() !== null,
|
canSave: () => this.loaded() !== null,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Brief, LetterBlock, allBlocks } from './brief';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The rejection diff as a PURE function over two immutable `Brief` values — the whole
|
* The rejection diff as a PURE function over two immutable `Brief` values — the whole
|
||||||
* teaching payload of WP-27: because state is one value, "what changed since the letter
|
* teaching payload here: because state is one value, "what changed since the letter
|
||||||
* was rejected" is just a fold over two snapshots, no change-tracking bookkeeping.
|
* was rejected" is just a fold over two snapshots, no change-tracking bookkeeping.
|
||||||
*
|
*
|
||||||
* Blocks are matched by `blockId` (stable `local-N`/seed ids):
|
* Blocks are matched by `blockId` (stable `local-N`/seed ids):
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from '
|
|||||||
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine';
|
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) —
|
* The admin org-template editor as one Elm-style machine (PRD Brief v2 §5) —
|
||||||
* the same idiom as the wizards. The DRAFT org template is form state (edited in
|
* the same idiom as the wizards. The DRAFT org template is form state (edited in
|
||||||
* place on the canvas); publish/rollback are effects that come back as `DraftLoaded`.
|
* place on the canvas); publish/rollback are effects that come back as `DraftLoaded`.
|
||||||
* `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is
|
* `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* The organization template (Brief v2 PRD §3, WP-23/24): the SECOND template axis —
|
* The organization template (Brief v2 PRD §3): the SECOND template axis —
|
||||||
* appearance/identity per sub-organization (letterhead, footer, signature, margins).
|
* appearance/identity per sub-organization (letterhead, footer, signature, margins).
|
||||||
* Orthogonal to the case-type template (sections + placeholders); the two only meet
|
* Orthogonal to the case-type template (sections + placeholders); the two only meet
|
||||||
* at render time, on the letter canvas. Server-owned: the FE renders it verbatim,
|
* at render time, on the letter canvas. Server-owned: the FE renders it verbatim,
|
||||||
* never edits it here (the admin editor is WP-26).
|
* never edits it here (the admin editor does).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface Margins {
|
export interface Margins {
|
||||||
@@ -30,7 +30,7 @@ export interface OrgTemplate {
|
|||||||
readonly version: number;
|
readonly version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- admin editor (WP-26) ---
|
// --- admin editor ---
|
||||||
|
|
||||||
/** A published snapshot in the version history: who is faked, `publishedAt` is real. */
|
/** A published snapshot in the version history: who is faked, `publishedAt` is real. */
|
||||||
export interface OrgTemplateVersion {
|
export interface OrgTemplateVersion {
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -38,9 +38,9 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric
|
|||||||
* (ProblemDetails → error string, plus the Idempotency-Key mint), then parses the
|
* (ProblemDetails → error string, plus the Idempotency-Key mint), then parses the
|
||||||
* returned brief. `load` (the only read) does its own try/catch instead of the
|
* returned brief. `load` (the only read) does its own try/catch instead of the
|
||||||
* shared `runResult` fold, because it needs one extra bit `runResult` throws away:
|
* shared `runResult` fold, because it needs one extra bit `runResult` throws away:
|
||||||
* whether the failure was an HTTP 404 (see `BriefLoadFailure` — RB-22, CQ-007's
|
* whether the failure was an HTTP 404 (see `BriefLoadFailure` — CQ-007's
|
||||||
* expand half). Today's backend never 404s `GET /brief` (RB-23 adds that), so the
|
* expand half). Today's backend never 404s `GET /brief`, so the
|
||||||
* `notFound` branch is unreached until RB-23 ships; this adapter is ready in advance.
|
* `notFound` branch is unreached until it does; this adapter is ready in advance.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface BriefView {
|
export interface BriefView {
|
||||||
@@ -66,7 +66,7 @@ export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is
|
|||||||
/** True when the thrown value carries an HTTP 404 status — matches both the
|
/** True when the thrown value carries an HTTP 404 status — matches both the
|
||||||
generic `SwaggerException` (today's shape, since `GET /brief` declares no 404
|
generic `SwaggerException` (today's shape, since `GET /brief` declares no 404
|
||||||
response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once
|
response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once
|
||||||
RB-23 gives the endpoint a documented 404 response). */
|
the endpoint gets a documented 404 response). */
|
||||||
function isHttpNotFound(e: unknown): boolean {
|
function isHttpNotFound(e: unknown): boolean {
|
||||||
return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404;
|
return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,19 +14,19 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
|
|||||||
* to keep the NSwag-generated client JSON-only (same seam as uploads) — so this is a
|
* to keep the NSwag-generated client JSON-only (same seam as uploads) — so this is a
|
||||||
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
|
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
|
||||||
* `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set
|
* `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set
|
||||||
* here explicitly (WP-74 — without `X-Subject` this always previewed
|
* here explicitly (without `X-Subject` this always previewed
|
||||||
* `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are
|
* `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are
|
||||||
* dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under
|
* dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under
|
||||||
* `isDevMode()`, mirroring how the interceptors themselves are only registered in dev
|
* `isDevMode()`, mirroring how the interceptors themselves are only registered in dev
|
||||||
* (`app.config.ts`) — a production build sends neither header from this call (BIO-012).
|
* (`app.config.ts`) — a production build sends neither header from this call (BIO-012).
|
||||||
*
|
*
|
||||||
* `cache: 'no-store'` (WP-74): the endpoint has no `Cache-Control`, only a CORS-driven
|
* `cache: 'no-store'`: the endpoint has no `Cache-Control`, only a CORS-driven
|
||||||
* `Vary: Origin`, and its content changes at the SAME URL as the letter moves
|
* `Vary: Origin`, and its content changes at the SAME URL as the letter moves
|
||||||
* draft → sent. Explicitly bypassing the HTTP cache is the correct default for any
|
* draft → sent. Explicitly bypassing the HTTP cache is the correct default for any
|
||||||
* mutable resource served under one unversioned URL — independent of WP-74's
|
* mutable resource served under one unversioned URL — independent of the
|
||||||
* identity work, and not a complete fix by itself: see the KNOWN GAP note below.
|
* identity work above, and not a complete fix by itself: see the KNOWN GAP note below.
|
||||||
*
|
*
|
||||||
* KNOWN GAP (WP-74, not fixed here): under a non-`DocumentStore.DemoOwner` `X-Subject`,
|
* KNOWN GAP (not fixed here): under a non-`DocumentStore.DemoOwner` `X-Subject`,
|
||||||
* this repo's own e2e run against a real backend observed this endpoint's SENT
|
* this repo's own e2e run against a real backend observed this endpoint's SENT
|
||||||
* response still carrying the draft watermark, even though (a) the outgoing request
|
* response still carrying the draft watermark, even though (a) the outgoing request
|
||||||
* carried the correct `X-Subject`, and (b) `curl` against the same backend at the
|
* carried the correct `X-Subject`, and (b) `curl` against the same backend at the
|
||||||
@@ -34,7 +34,7 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
|
|||||||
* did not change the outcome, so it is very unlikely a client-side caching artifact —
|
* did not change the outcome, so it is very unlikely a client-side caching artifact —
|
||||||
* it looks like a genuine backend-side staleness/race in `BriefStore`'s SQLite-backed
|
* it looks like a genuine backend-side staleness/race in `BriefStore`'s SQLite-backed
|
||||||
* read path, reproducible for MULTIPLE distinct owners and NOT reproducible for
|
* read path, reproducible for MULTIPLE distinct owners and NOT reproducible for
|
||||||
* `DemoOwner`, which needs backend-side investigation (out of WP-74's file scope —
|
* `DemoOwner`, which needs backend-side investigation (out of this file's scope —
|
||||||
* see `e2e/brief-v2.spec.ts`'s header comment, which keeps that spec on the shared
|
* see `e2e/brief-v2.spec.ts`'s header comment, which keeps that spec on the shared
|
||||||
* `zorgverlener` identity until this is root-caused).
|
* `zorgverlener` identity until this is root-caused).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -167,7 +167,7 @@ export class BriefPage {
|
|||||||
void this.store.resetDemo();
|
void this.store.resetDemo();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Typed narrowing for the `<app-async>` loaded slot — see WP-06: a structural
|
/** Typed narrowing for the `<app-async>` loaded slot: a structural
|
||||||
directive's context can't inherit a generic from a sibling host input, so the
|
directive's context can't inherit a generic from a sibling host input, so the
|
||||||
Success value is unwrapped here instead of through `let-`. */
|
Success value is unwrapped here instead of through `let-`. */
|
||||||
protected readonly loaded = computed(() => {
|
protected readonly loaded = computed(() => {
|
||||||
@@ -179,7 +179,7 @@ export class BriefPage {
|
|||||||
void this.store.load();
|
void this.store.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo (WP-27). Ignored while focus is in the
|
/** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo. Ignored while focus is in the
|
||||||
rich-text editor or a form control, so the browser's own text undo keeps working
|
rich-text editor or a form control, so the browser's own text undo keeps working
|
||||||
there — our shell-level undo is for structural edits (add/remove/reorder blocks). */
|
there — our shell-level undo is for structural edits (add/remove/reorder blocks). */
|
||||||
protected onKey(e: KeyboardEvent) {
|
protected onKey(e: KeyboardEvent) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -53,12 +48,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
footer around the case-type template's sections. `editableRegions` picks who edits
|
footer around the case-type template's sections. `editableRegions` picks who edits
|
||||||
what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`
|
what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`
|
||||||
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 (WP-26).
|
`'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 (WP-25). */
|
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 {
|
||||||
@@ -82,7 +79,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
color: var(--rhc-color-foreground-subtle);
|
color: var(--rhc-color-foreground-subtle);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
/* Rejection-diff badge (WP-27): a small pill above a changed/added block. */
|
/* Rejection-diff badge: a small pill above a changed/added block. */
|
||||||
.diff-block.diff-changed {
|
.diff-block.diff-changed {
|
||||||
border-inline-start: 3px solid var(--rhc-color-oranje-500);
|
border-inline-start: 3px solid var(--rhc-color-oranje-500);
|
||||||
padding-inline-start: var(--rhc-space-max-sm);
|
padding-inline-start: var(--rhc-space-max-sm);
|
||||||
@@ -98,7 +95,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
background: var(--rhc-color-oranje-500);
|
background: var(--rhc-color-oranje-500);
|
||||||
}
|
}
|
||||||
.diff-badge.added {
|
.diff-badge.added {
|
||||||
/* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (WP-29 axe). */
|
/* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (axe). */
|
||||||
color: var(--rhc-color-wit);
|
color: var(--rhc-color-wit);
|
||||||
background: var(--rhc-color-groen-700);
|
background: var(--rhc-color-groen-700);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
@@ -345,12 +358,12 @@ export class LetterCanvasComponent {
|
|||||||
brief = input.required<Brief>();
|
brief = input.required<Brief>();
|
||||||
orgTemplate = input.required<OrgTemplate>();
|
orgTemplate = input.required<OrgTemplate>();
|
||||||
/** Who edits what on the surface: read-only ('none', the drafter preview + approver
|
/** Who edits what on the surface: read-only ('none', the drafter preview + approver
|
||||||
view) or admin editor ('template', WP-26). Authoring moved to letter-editor. */
|
view) or admin editor ('template'). Authoring moved to letter-editor. */
|
||||||
editableRegions = input<'template' | 'none'>('none');
|
editableRegions = input<'template' | 'none'>('none');
|
||||||
diagnostics = input<readonly Diagnostic[]>([]);
|
diagnostics = input<readonly Diagnostic[]>([]);
|
||||||
/** Initial zoom; the in-canvas controls take over from here (WP-27). */
|
/** Initial zoom; the in-canvas controls take over from here. */
|
||||||
zoom = input(1);
|
zoom = input(1);
|
||||||
/** Blocks changed/added/removed since the letter was rejected (WP-27); badged when
|
/** Blocks changed/added/removed since the letter was rejected; badged when
|
||||||
`showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer
|
`showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer
|
||||||
exist in the letter — the composer surfaces them as a count. */
|
exist in the letter — the composer surfaces them as a count. */
|
||||||
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
|
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
|
||||||
@@ -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) ---
|
||||||
|
|
||||||
@@ -445,7 +422,7 @@ export class LetterCanvasComponent {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// ponytail: whole-surface height / A4-interval — ignores that a break never truly
|
// ponytail: whole-surface height / A4-interval — ignores that a break never truly
|
||||||
// falls mid-line; the caption says "±" and WP-25's server preview is authoritative.
|
// falls mid-line; the caption says "±" and the server preview is authoritative.
|
||||||
const observer = new ResizeObserver(([entry]) => {
|
const observer = new ResizeObserver(([entry]) => {
|
||||||
// ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.
|
// ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.
|
||||||
const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);
|
const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);
|
||||||
|
|||||||
@@ -127,12 +127,12 @@ export const ReadOnlyZonderBevindingen: Story = {
|
|||||||
args: { editableRegions: 'none', diagnostics: [] },
|
args: { editableRegions: 'none', diagnostics: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Admin editor focus (consumer arrives in WP-26): body read-only, no "not yours" tint. */
|
/** Admin editor focus: body read-only, no "not yours" tint. */
|
||||||
export const TemplateMode: Story = { args: { editableRegions: 'template' } };
|
export const TemplateMode: Story = { args: { editableRegions: 'template' } };
|
||||||
|
|
||||||
export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } };
|
export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } };
|
||||||
|
|
||||||
/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged (WP-27). */
|
/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged. */
|
||||||
export const WithDiff: Story = {
|
export const WithDiff: Story = {
|
||||||
args: {
|
args: {
|
||||||
editableRegions: 'none',
|
editableRegions: 'none',
|
||||||
@@ -150,14 +150,14 @@ export const PageBreak: Story = {
|
|||||||
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
|
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inline SVG so the story needs no backend/upload round-trip (WP-26 logo upload).
|
// Inline SVG so the story needs no backend/upload round-trip (the logo upload).
|
||||||
const sampleLogo =
|
const sampleLogo =
|
||||||
'data:image/svg+xml;utf8,' +
|
'data:image/svg+xml;utf8,' +
|
||||||
encodeURIComponent(
|
encodeURIComponent(
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
|
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Published org logo (WP-26 AC2): the letterhead shows it above the org name. */
|
/** Published org logo: the letterhead shows it above the org name. */
|
||||||
export const MetLogo: Story = {
|
export const MetLogo: Story = {
|
||||||
args: { editableRegions: 'none', diagnostics: [], logoUrl: sampleLogo },
|
args: { editableRegions: 'none', diagnostics: [], logoUrl: sampleLogo },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -137,7 +137,7 @@ export class LetterComposerComponent {
|
|||||||
canReject = input(false);
|
canReject = input(false);
|
||||||
canSend = input(false);
|
canSend = input(false);
|
||||||
busy = input(false);
|
busy = input(false);
|
||||||
/** Rejection diff (WP-27): the changed/added/removed blocks and their count. The
|
/** Rejection diff: the changed/added/removed blocks and their count. The
|
||||||
"Toon wijzigingen" toggle only appears when there's something to show. */
|
"Toon wijzigingen" toggle only appears when there's something to show. */
|
||||||
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
|
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
|
||||||
removedCount = input(0);
|
removedCount = input(0);
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export const Sent: Story = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Approver's "Toon wijzigingen" (WP-27): a resubmitted letter with blocks changed,
|
/** Approver's "Toon wijzigingen": a resubmitted letter with blocks changed,
|
||||||
added and removed since the last rejection. */
|
added and removed since the last rejection. */
|
||||||
export const RejectionDiff: Story = {
|
export const RejectionDiff: Story = {
|
||||||
render: () =>
|
render: () =>
|
||||||
|
|||||||
@@ -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 (WP-26): 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.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -87,12 +87,12 @@ const sampleLogo =
|
|||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
|
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Published logo (WP-26 AC2): the letterhead canvas shows it above the org name. */
|
/** Published logo: the letterhead canvas shows it above the org name. */
|
||||||
export const MetLogo: Story = {
|
export const MetLogo: Story = {
|
||||||
args: { logoUrl: sampleLogo },
|
args: { logoUrl: sampleLogo },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Client-side upload rejection (existing `rejectReason`, WP-26 AC5) — type/size caught
|
/** Client-side upload rejection (existing `rejectReason`) — type/size caught
|
||||||
before the file ever reaches the backend. */
|
before the file ever reaches the backend. */
|
||||||
export const LogoUploadFout: Story = {
|
export const LogoUploadFout: Story = {
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@@ -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,13 +1,13 @@
|
|||||||
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';
|
||||||
|
|
||||||
/** Page: thin container for the admin org-template editor (WP-26). Deny-by-default
|
/** Page: thin container for the admin org-template editor. Deny-by-default
|
||||||
capability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor
|
capability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor
|
||||||
for admins. Loads once the capability resolves; wires store commands to the organism. */
|
for admins. Loads once the capability resolves; wires store commands to the organism. */
|
||||||
@Component({
|
@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';
|
||||||
|
|
||||||
@@ -10,8 +10,8 @@ import { LibraryPassage } from '@brief/domain/brief';
|
|||||||
inserts ALL checked passages at once (a single message upstream) — there is no
|
inserts ALL checked passages at once (a single message upstream) — there is no
|
||||||
single-insert path. Presentational: emits the chosen passages in list order.
|
single-insert path. Presentational: emits the chosen passages in list order.
|
||||||
|
|
||||||
Superseded by `besluit-panel` (WP-27's guided drafting): no consumer left in
|
Superseded by `besluit-panel`'s guided drafting: no consumer left in
|
||||||
`src/app` outside its own story (WP-28 audit). Kept for now rather than deleted
|
`src/app` outside its own story. Kept for now rather than deleted
|
||||||
in-flight of an unrelated WP; a future cleanup can remove it. */
|
in-flight of an unrelated WP; a future cleanup can remove it. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-passage-picker',
|
selector: 'app-passage-picker',
|
||||||
@@ -87,7 +87,7 @@ export class PassagePickerComponent {
|
|||||||
protected checked = signal<Record<string, boolean>>({});
|
protected checked = signal<Record<string, boolean>>({});
|
||||||
protected query = signal('');
|
protected query = signal('');
|
||||||
/** Client-side filter on label + rendered content text — the library is small, so no
|
/** Client-side filter on label + rendered content text — the library is small, so no
|
||||||
server search (WP-27). Placeholder keys are searchable too (see `textOf`). */
|
server search. Placeholder keys are searchable too (see `textOf`). */
|
||||||
protected filtered = computed(() => {
|
protected filtered = computed(() => {
|
||||||
const q = this.query().trim().toLowerCase();
|
const q = this.query().trim().toLowerCase();
|
||||||
if (!q) return this.passages();
|
if (!q) return this.passages();
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ describe('intake acceptance journeys', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('raising uren above the threshold after answering scholing drops both fields (WP-69 §6)', () => {
|
it('raising uren above the threshold after answering scholing drops both fields', () => {
|
||||||
// Given a journey that answered the scholing question while uren was low.
|
// Given a journey that answered the scholing question while uren was low.
|
||||||
const atReview = givenIntake(
|
const atReview = givenIntake(
|
||||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||||
@@ -170,7 +170,7 @@ describe('intake acceptance journeys', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Then the submission succeeds, and BOTH the stale answer and its punten are gone —
|
// Then the submission succeeds, and BOTH the stale answer and its punten are gone —
|
||||||
// exactly the crafted-POST-shaped payload WP-69's server rule rejects.
|
// exactly the crafted-POST-shaped payload the server rule rejects.
|
||||||
expect(done.tag).toBe('Submitted');
|
expect(done.tag).toBe('Submitted');
|
||||||
expect(done.tag === 'Submitted' && done.data.aanvullendeScholing).toBeUndefined();
|
expect(done.tag === 'Submitted' && done.data.aanvullendeScholing).toBeUndefined();
|
||||||
expect(done.tag === 'Submitted' && done.data.punten).toBeUndefined();
|
expect(done.tag === 'Submitted' && done.data.punten).toBeUndefined();
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ describe('submit', () => {
|
|||||||
expect(withScholing.data.punten).toBe(200);
|
expect(withScholing.data.punten).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not require punten for a hidden question (WP-69 §6)', () => {
|
it('does not require punten for a hidden question', () => {
|
||||||
// scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above
|
// scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above
|
||||||
// threshold — the template hides the question, so punten must not be required either.
|
// threshold — the template hides the question, so punten must not be required either.
|
||||||
const staleScholingNoPunten = givenIntake(
|
const staleScholingNoPunten = givenIntake(
|
||||||
@@ -183,7 +183,7 @@ describe('submit', () => {
|
|||||||
expect(good.data.aanvullendeScholing).toBeUndefined();
|
expect(good.data.aanvullendeScholing).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops punten when raising uren hides the question (WP-69 §6)', () => {
|
it('drops punten when raising uren hides the question', () => {
|
||||||
// Same stale answer, but this time punten was also filled in while uren was low.
|
// Same stale answer, but this time punten was also filled in while uren was low.
|
||||||
const staleScholingWithPunten = givenIntake(
|
const staleScholingWithPunten = givenIntake(
|
||||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||||
|
|||||||
@@ -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 =
|
||||||
| {
|
| {
|
||||||
@@ -116,7 +116,7 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
|
|||||||
// visible (lageUren) AND scholing was followed — matching the template's
|
// visible (lageUren) AND scholing was followed — matching the template's
|
||||||
// `@if (scholingZichtbaar())`. Without the `lageUren` guard, answering 'ja' and then
|
// `@if (scholingZichtbaar())`. Without the `lageUren` guard, answering 'ja' and then
|
||||||
// raising uren above the threshold left an error on a field the template no longer
|
// raising uren above the threshold left an error on a field the template no longer
|
||||||
// renders (WP-69 §6).
|
// renders.
|
||||||
if (lageUren(a, scholingThreshold) && a.scholingGevolgd === 'ja') {
|
if (lageUren(a, scholingThreshold) && a.scholingGevolgd === 'ja') {
|
||||||
const p = parseUren(a.punten ?? '');
|
const p = parseUren(a.punten ?? '');
|
||||||
if (!p.ok) errors.punten = p.error;
|
if (!p.ok) errors.punten = p.error;
|
||||||
@@ -149,8 +149,8 @@ function validateAll(a: Answers, scholingThreshold: number): Result<Errors, Vali
|
|||||||
const aanvullendeScholing = lageUren(a, scholingThreshold)
|
const aanvullendeScholing = lageUren(a, scholingThreshold)
|
||||||
? a.scholingGevolgd === 'ja'
|
? a.scholingGevolgd === 'ja'
|
||||||
: undefined;
|
: undefined;
|
||||||
// Punten are derived from aanvullendeScholing, NOT the raw scholingGevolgd answer (WP-69
|
// Punten are derived from aanvullendeScholing, NOT the raw scholingGevolgd answer —
|
||||||
// §6) — a stale 'ja' left over from when uren was low, after uren was raised above the
|
// a stale 'ja' left over from when uren was low, after uren was raised above the
|
||||||
// threshold, must not leak a punten value into the parsed, submitted ValidIntake.
|
// threshold, must not leak a punten value into the parsed, submitted ValidIntake.
|
||||||
const punten = aanvullendeScholing === true ? parseUren(a.punten ?? '') : undefined;
|
const punten = aanvullendeScholing === true ? parseUren(a.punten ?? '') : undefined;
|
||||||
return ok({
|
return ok({
|
||||||
|
|||||||
+55
-50
@@ -1,15 +1,16 @@
|
|||||||
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,
|
||||||
WizardPhase,
|
WizardPhase,
|
||||||
naarStapLabel,
|
naarStapLabel,
|
||||||
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||||
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
|
||||||
|
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';
|
||||||
@@ -22,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
|
||||||
@@ -147,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();
|
||||||
@@ -162,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`,
|
||||||
@@ -203,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>(
|
||||||
@@ -213,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]);
|
||||||
@@ -227,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>(() => {
|
||||||
@@ -251,20 +252,24 @@ 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[]>(() => {
|
protected errorList = computed<WizardError[]>(() => toWizardErrors(this.editing()?.errors ?? {}));
|
||||||
const e = this.editing()?.errors ?? {};
|
|
||||||
return (Object.keys(e) as (keyof typeof e)[])
|
// --- Event handlers: narrow a child event into a message -------------------
|
||||||
.filter((k) => e[k])
|
/** Stepper emits a 0-based index for an earlier (visited) step. */
|
||||||
.map((k) => ({ id: k, message: e[k]! }));
|
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,20 +1,13 @@
|
|||||||
/* 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,
|
||||||
WizardPhase,
|
WizardPhase,
|
||||||
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 { 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';
|
||||||
@@ -22,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
|
||||||
@@ -71,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>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,12 +107,18 @@ 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();
|
||||||
// WP-69: the scholing answer rides along so the server can re-validate it as the
|
// The scholing answer rides along so the server can re-validate it as the
|
||||||
// authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by
|
// authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by
|
||||||
// JSON.stringify, so a wizard above the threshold sends neither field.
|
// JSON.stringify, so a wizard above the threshold sends neither field.
|
||||||
const r = await this.draftSync.submit({
|
const r = await this.draftSync.submit({
|
||||||
@@ -291,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: () => {
|
||||||
@@ -310,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`;
|
||||||
@@ -360,24 +205,20 @@ export class IntakeWizardComponent {
|
|||||||
});
|
});
|
||||||
/** Current step's field errors, flattened for the shell's error summary. The
|
/** Current step's field errors, flattened for the shell's error summary. The
|
||||||
field ids match the answer keys, so the summary anchors jump to the field. */
|
field ids match the answer keys, so the summary anchors jump to the field. */
|
||||||
protected errorList = computed<WizardError[]>(() => {
|
protected errorList = computed<WizardError[]>(() =>
|
||||||
const e = this.answering()?.errors ?? {};
|
toWizardErrors(this.answering()?.errors ?? {}),
|
||||||
return (Object.keys(e) as (keyof Answers)[])
|
);
|
||||||
.filter((k) => e[k])
|
|
||||||
.map((k) => ({ id: k, message: e[k]! }));
|
|
||||||
});
|
|
||||||
|
|
||||||
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',
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
|
import { applicationConfig } from '@storybook/angular';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { WatWiltUDoenSection } from './wat-wilt-u-doen.section';
|
||||||
|
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
|
||||||
|
|
||||||
|
const meta: Meta<WatWiltUDoenSection> = {
|
||||||
|
title: 'Domein/Overzicht/Wat Wilt U Doen',
|
||||||
|
component: WatWiltUDoenSection,
|
||||||
|
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<WatWiltUDoenSection>;
|
||||||
|
|
||||||
|
export const InschrijvingOpen: Story = {
|
||||||
|
decorators: [
|
||||||
|
applicationConfig({
|
||||||
|
providers: [{ provide: FeatureFlagStore, useValue: { enabled: () => true } }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
export const InschrijvingDicht: Story = {
|
||||||
|
decorators: [
|
||||||
|
applicationConfig({
|
||||||
|
providers: [{ provide: FeatureFlagStore, useValue: { enabled: () => false } }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ describe('AanvragenStore', () => {
|
|||||||
expect(store.lastError()).toBeNull();
|
expect(store.lastError()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
// RB-20: a failed cancel must not be silent — the row rolls back AND the store
|
// A failed cancel must not be silent — the row rolls back AND the store
|
||||||
// surfaces the error the page renders. Before RB-20 this only rolled back
|
// surfaces the error the page renders. Before this fix it only rolled back
|
||||||
// (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
|
// (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
|
||||||
it('rolls back the removal and surfaces the error when the cancel fails', async () => {
|
it('rolls back the removal and surfaces the error when the cancel fails', async () => {
|
||||||
const cancel = vi.fn().mockRejectedValue(new Error('boom'));
|
const cancel = vi.fn().mockRejectedValue(new Error('boom'));
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type Err = Error | undefined;
|
|||||||
* change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches
|
* change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches
|
||||||
* so a page revisit reflects auto-approval (Concept → In behandeling → Goedgekeurd is
|
* so a page revisit reflects auto-approval (Concept → In behandeling → Goedgekeurd is
|
||||||
* computed server-side on read). Cancel goes through `runSubmit` and rolls back plus
|
* computed server-side on read). Cancel goes through `runSubmit` and rolls back plus
|
||||||
* surfaces `lastError` on failure (RB-20).
|
* surfaces `lastError` on failure.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AanvragenStore {
|
export class AanvragenStore {
|
||||||
@@ -23,7 +23,7 @@ export class AanvragenStore {
|
|||||||
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
|
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
|
||||||
readonly aanvragen = this.state.asReadonly();
|
readonly aanvragen = this.state.asReadonly();
|
||||||
|
|
||||||
/** Set on a failed cancel (RB-20): the optimistic removal already rolled back by
|
/** Set on a failed cancel: the optimistic removal already rolled back by
|
||||||
then, this is only the message for the alert the page renders above the list. */
|
then, this is only the message for the alert the page renders above the list. */
|
||||||
private error = signal<string | null>(null);
|
private error = signal<string | null>(null);
|
||||||
readonly lastError = this.error.asReadonly();
|
readonly lastError = this.error.asReadonly();
|
||||||
@@ -55,7 +55,7 @@ export class AanvragenStore {
|
|||||||
|
|
||||||
/** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE.
|
/** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE.
|
||||||
No resync — the delete succeeded, so the optimistic removal is authoritative. On
|
No resync — the delete succeeded, so the optimistic removal is authoritative. On
|
||||||
failure, roll back AND surface the error (RB-20) — a silent reappearance leaves the
|
failure, roll back AND surface the error — a silent reappearance leaves the
|
||||||
user guessing why the block came back. */
|
user guessing why the block came back. */
|
||||||
async cancel(id: string) {
|
async cancel(id: string) {
|
||||||
const before = this.state();
|
const before = this.state();
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ describe('AdminCasesStore', () => {
|
|||||||
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']);
|
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']);
|
||||||
});
|
});
|
||||||
|
|
||||||
// RB-20: a failed delete must not be silent — the row rolls back AND the store
|
// A failed delete must not be silent — the row rolls back AND the store
|
||||||
// surfaces the error the page renders. Before RB-20 this only rolled back
|
// surfaces the error the page renders. Before this fix it only rolled back
|
||||||
// (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
|
// (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
|
||||||
it('rolls back the removal and surfaces the error when the delete fails', async () => {
|
it('rolls back the removal and surfaces the error when the delete fails', async () => {
|
||||||
const deleteAny = vi.fn().mockRejectedValue(new Error('boom'));
|
const deleteAny = vi.fn().mockRejectedValue(new Error('boom'));
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aa
|
|||||||
type Err = Error | undefined;
|
type Err = Error | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office
|
* Admin view of ALL cases across owners (`cases:manage`) — the back-office
|
||||||
* counterpart of the user-facing `AanvragenStore`. Same shape: one root singleton
|
* counterpart of the user-facing `AanvragenStore`. Same shape: one root singleton
|
||||||
* owns the list as a writable RemoteData signal, delete removes the row synchronously
|
* owns the list as a writable RemoteData signal, delete removes the row synchronously
|
||||||
* (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on
|
* (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on
|
||||||
* failure (RB-20). Admin delete removes any case (any owner, submitted or not — the
|
* failure. Admin delete removes any case (any owner, submitted or not — the
|
||||||
* server enforces the capability).
|
* server enforces the capability).
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -21,7 +21,7 @@ export class AdminCasesStore {
|
|||||||
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
|
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
|
||||||
readonly cases = this.state.asReadonly();
|
readonly cases = this.state.asReadonly();
|
||||||
|
|
||||||
/** Set on a failed delete (RB-20): the optimistic removal already rolled back by
|
/** Set on a failed delete: the optimistic removal already rolled back by
|
||||||
then, this is only the message for the alert the page renders above the list. */
|
then, this is only the message for the alert the page renders above the list. */
|
||||||
private error = signal<string | null>(null);
|
private error = signal<string | null>(null);
|
||||||
readonly lastError = this.error.asReadonly();
|
readonly lastError = this.error.asReadonly();
|
||||||
@@ -47,7 +47,7 @@ export class AdminCasesStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error
|
/** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error
|
||||||
AND surface it (RB-20) — a silent reappearance leaves the admin guessing why. */
|
AND surface it — a silent reappearance leaves the admin guessing why. */
|
||||||
async delete(id: string) {
|
async delete(id: string) {
|
||||||
const before = this.state();
|
const before = this.state();
|
||||||
if (before.tag === 'Success') {
|
if (before.tag === 'Success') {
|
||||||
|
|||||||
@@ -52,10 +52,12 @@ export class BigProfileStore {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** Specialisms/notes stay a separate stream (they have their own empty state). */
|
/** Specialisms/notes stay a separate stream (they have their own empty state). */
|
||||||
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() => {
|
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() =>
|
||||||
const rd = fromResource(this.aantekeningenRes, (v) => !v || v.length === 0);
|
map(
|
||||||
return rd.tag === 'Success' ? { tag: 'Success', value: rd.value ?? [] } : rd;
|
fromResource(this.aantekeningenRes, (v) => !v || v.length === 0),
|
||||||
});
|
(v) => v ?? [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// --- Optimistic herregistratie state, shared with the dashboard -----------
|
// --- Optimistic herregistratie state, shared with the dashboard -----------
|
||||||
private pending = signal(false);
|
private pending = signal(false);
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ describe('createDraftSync', () => {
|
|||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recovers from a create conflict by adopting the existing Concept (WP-35)', async () => {
|
it('recovers from a create conflict by adopting the existing Concept', async () => {
|
||||||
// Server enforces one Concept per type: a stale/cross-tab create is rejected (409),
|
// Server enforces one Concept per type: a stale/cross-tab create is rejected (409),
|
||||||
// and ensureId adopts the existing Concept from the list instead of erroring.
|
// and ensureId adopts the existing Concept from the list instead of erroring.
|
||||||
const create = vi.fn().mockRejectedValue({ status: 409 });
|
const create = vi.fn().mockRejectedValue({ status: 409 });
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
|||||||
if (id) return id;
|
if (id) return id;
|
||||||
ensuring ??= adapter
|
ensuring ??= adapter
|
||||||
.create(deps.type)
|
.create(deps.type)
|
||||||
// WP-35: one Concept per type is server-enforced. Within a tab the resumeGate
|
// One Concept per type is server-enforced. Within a tab the resumeGate
|
||||||
// already prevents a second create, but a cross-tab/stale race can still hit the
|
// already prevents a second create, but a cross-tab/stale race can still hit the
|
||||||
// server's guard (409) — recover by adopting the existing Concept instead of
|
// server's guard (409) — recover by adopting the existing Concept instead of
|
||||||
// erroring. Only recover when one actually exists; otherwise surface the failure.
|
// erroring. Only recover when one actually exists; otherwise surface the failure.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aa
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs
|
* Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs
|
||||||
* before it can start writing (RB-21 / CQ-001). Free functions that take the adapter
|
* before it can start writing (CQ-001). Free functions that take the adapter
|
||||||
* as a parameter, not `inject()`, so they get a direct spec without Angular TestBed.
|
* as a parameter, not `inject()`, so they get a direct spec without Angular TestBed.
|
||||||
* `createDraftSync` keeps the closure state (`id`, `resumeGate`) and the write path;
|
* `createDraftSync` keeps the closure state (`id`, `resumeGate`) and the write path;
|
||||||
* these two functions only read.
|
* these two functions only read.
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
*/
|
*/
|
||||||
export type AanvraagType = 'registratie' | 'herregistratie' | 'intake';
|
export type AanvraagType = 'registratie' | 'herregistratie' | 'intake';
|
||||||
|
|
||||||
// Ingediend/MeerInfoGevraagd (ADR-0002/WP-63) are widened into the union so the parse
|
// Ingediend/MeerInfoGevraagd (ADR-0002) are widened into the union so the parse
|
||||||
// boundary + renderers are ready, but no backend path emits them yet — that's WP-65's
|
// boundary + renderers are ready, but no backend path emits them yet — that's the
|
||||||
// behandelaar-facing transition endpoint.
|
// behandelaar-facing transition endpoint.
|
||||||
export type AanvraagStatus =
|
export type AanvraagStatus =
|
||||||
| { tag: 'Concept'; stepIndex: number; stepCount: number }
|
| { tag: 'Concept'; stepIndex: number; stepCount: number }
|
||||||
@@ -29,7 +29,7 @@ export interface Aanvraag {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
submittedAt?: string;
|
submittedAt?: string;
|
||||||
/** The case owner (a BSN). Only populated by the admin cross-owner list (WP-36);
|
/** The case owner (a BSN). Only populated by the admin cross-owner list;
|
||||||
the user's own list leaves it undefined. */
|
the user's own list leaves it undefined. */
|
||||||
owner?: string;
|
owner?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from '@registratie/domain/value-objects/telefoonnummer';
|
} from '@registratie/domain/value-objects/telefoonnummer';
|
||||||
|
|
||||||
/** What the user is typing (raw, possibly invalid). The BRP address is NOT part of
|
/** What the user is typing (raw, possibly invalid). The BRP address is NOT part of
|
||||||
the form — it is authoritative and shown read-only (WP-34); only the phone number
|
the form — it is authoritative and shown read-only; only the phone number
|
||||||
is editable here. */
|
is editable here. */
|
||||||
export interface Draft {
|
export interface Draft {
|
||||||
telefoon: string;
|
telefoon: string;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
reduceUpload,
|
reduceUpload,
|
||||||
requiredCategoriesSatisfied,
|
requiredCategoriesSatisfied,
|
||||||
deliveryRefs,
|
deliveryRefs,
|
||||||
|
digitalDocumentIds,
|
||||||
} from '@shared/domain/upload.machine';
|
} from '@shared/domain/upload.machine';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,7 +67,7 @@ export type DraftField = 'straat' | 'postcode' | 'woonplaats' | 'email';
|
|||||||
|
|
||||||
/** Per-field error map. `antwoorden` holds per-policy-question errors, keyed by
|
/** Per-field error map. `antwoorden` holds per-policy-question errors, keyed by
|
||||||
question id (a step can show several questions). */
|
question id (a step can show several questions). */
|
||||||
export interface Errors {
|
export type Errors = {
|
||||||
straat?: string;
|
straat?: string;
|
||||||
postcode?: string;
|
postcode?: string;
|
||||||
woonplaats?: string;
|
woonplaats?: string;
|
||||||
@@ -75,7 +76,7 @@ export interface Errors {
|
|||||||
diploma?: string;
|
diploma?: string;
|
||||||
documenten?: string;
|
documenten?: string;
|
||||||
antwoorden?: Record<string, string>;
|
antwoorden?: Record<string, string>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type RegistratieState =
|
export type RegistratieState =
|
||||||
| { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors; upload: UploadState }
|
| { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors; upload: UploadState }
|
||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ export class AanvragenAdapter {
|
|||||||
return this.client.aanvragenAll();
|
return this.client.aanvragenAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin: every case across all owners (WP-36; `cases:manage`). Parsed at the boundary. */
|
/** Admin: every case across all owners (`cases:manage`). Parsed at the boundary. */
|
||||||
listAll(): Promise<AanvraagSummaryDto[]> {
|
listAll(): Promise<AanvraagSummaryDto[]> {
|
||||||
return this.client.casesAll();
|
return this.client.casesAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin: delete ANY case (any owner, submitted or not — WP-36). */
|
/** Admin: delete ANY case (any owner, submitted or not). */
|
||||||
deleteAny(id: string): Promise<void> {
|
deleteAny(id: string): Promise<void> {
|
||||||
return this.client.cases(id);
|
return this.client.cases(id);
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ function parseCommon(dto: AanvraagSummaryDto): Result<string, Aanvraag> {
|
|||||||
createdAt: dto.createdAt,
|
createdAt: dto.createdAt,
|
||||||
updatedAt: dto.updatedAt,
|
updatedAt: dto.updatedAt,
|
||||||
submittedAt: dto.submittedAt,
|
submittedAt: dto.submittedAt,
|
||||||
owner: dto.owner, // only present on the admin cross-owner list (WP-36)
|
owner: dto.owner, // only present on the admin cross-owner list
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Valid } from '@registratie/domain/change-request.machine';
|
|||||||
* Infrastructure adapter for the telefoonwijziging POST (`/api/v1/change-requests`) —
|
* Infrastructure adapter for the telefoonwijziging POST (`/api/v1/change-requests`) —
|
||||||
* the single place the network client lives for contact changes, so the command
|
* the single place the network client lives for contact changes, so the command
|
||||||
* and the UI never touch `ApiClient`. The BRP address is authoritative and not
|
* and the UI never touch `ApiClient`. The BRP address is authoritative and not
|
||||||
* submitted (WP-34); only the phone number is. Returns the server reference; the
|
* submitted; only the phone number is. Returns the server reference; the
|
||||||
* server re-validates and is the authority.
|
* server re-validates and is the authority.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
|
|||||||
@@ -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,18 +1,19 @@
|
|||||||
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 { formatDatumNl } from '@shared/kernel/datum';
|
import { formatDatumNl } from '@shared/kernel/datum';
|
||||||
import { Aanvraag } from '@registratie/domain/aanvraag';
|
import { Aanvraag } from '@registratie/domain/aanvraag';
|
||||||
import { TYPE_LABELS, statusLabel, referentie } from '@registratie/domain/aanvraag-view';
|
import { TYPE_LABELS, statusLabel, referentie } from '@registratie/domain/aanvraag-view';
|
||||||
import { AdminCasesStore } from '@registratie/application/admin-cases.store';
|
import { AdminCasesStore } from '@registratie/application/admin-cases.store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin page: every case across all owners, with an admin delete (WP-36). Lives in
|
* Admin page: every case across all owners, with an admin delete. Lives in
|
||||||
* `registratie` (which owns the Aanvraag aggregate) — the back-office counterpart of the
|
* `registratie` (which owns the Aanvraag aggregate) — the back-office counterpart of the
|
||||||
* user's dashboard, reusing the same view labels + trust-boundary parse. Deny-by-default
|
* user's dashboard, reusing the same view labels + trust-boundary parse. Deny-by-default
|
||||||
* capability gate (`cases:manage`): a denial alert for non-admins, the list for admins.
|
* capability gate (`cases:manage`): a denial alert for non-admins, the list for admins.
|
||||||
@@ -78,10 +79,7 @@ export class AdminCasesPage {
|
|||||||
protected access = inject(AccessStore);
|
protected access = inject(AccessStore);
|
||||||
|
|
||||||
protected canManage = computed(() => this.access.can('cases:manage'));
|
protected canManage = computed(() => this.access.can('cases:manage'));
|
||||||
protected cases = computed(() => {
|
protected cases = computed(() => successOr(this.store.cases(), []));
|
||||||
const rd = this.store.cases();
|
|
||||||
return rd.tag === 'Success' ? rd.value : [];
|
|
||||||
});
|
|
||||||
|
|
||||||
protected heading = $localize`:@@adminCases.heading:Aanvragen beheren`;
|
protected heading = $localize`:@@adminCases.heading:Aanvragen beheren`;
|
||||||
protected intro = $localize`:@@adminCases.intro:Alle aanvragen in het register. Een aanvraag verwijderen kan niet ongedaan worden gemaakt.`;
|
protected intro = $localize`:@@adminCases.intro:Alle aanvragen in het register. Een aanvraag verwijderen kan niet ongedaan worden gemaakt.`;
|
||||||
@@ -111,7 +109,7 @@ export class AdminCasesPage {
|
|||||||
private loadRequested = false;
|
private loadRequested = false;
|
||||||
constructor() {
|
constructor() {
|
||||||
// Load once the capability resolves to allowed (a 403 GET would be wasted otherwise).
|
// Load once the capability resolves to allowed (a 403 GET would be wasted otherwise).
|
||||||
// Depends only on canManage() + a plain flag — never the store model (WP-26 loop lesson).
|
// Depends only on canManage() + a plain flag — never the store model (the loop lesson).
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (this.canManage() && !this.loadRequested) {
|
if (this.canManage() && !this.loadRequested) {
|
||||||
this.loadRequested = true;
|
this.loadRequested = true;
|
||||||
|
|||||||
+8
-8
@@ -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';
|
||||||
@@ -20,7 +20,7 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Organism: contact-change (telefoonwijziging) form. The BRP address is authoritative
|
* Organism: contact-change (telefoonwijziging) form. The BRP address is authoritative
|
||||||
* and shown READ-ONLY (WP-34) — you change your address at the gemeente, not here — so
|
* and shown READ-ONLY — you change your address at the gemeente, not here — so
|
||||||
* only the phone number is editable. Uses the SAME idiom as the wizards: all state in
|
* only the phone number is editable. Uses the SAME idiom as the wizards: all state in
|
||||||
* one signal driven by the pure `reduce` (change-request.machine.ts), submitted via a
|
* one signal driven by the pure `reduce` (change-request.machine.ts), submitted via a
|
||||||
* `submit-*` command returning `Result`. The server re-validates.
|
* `submit-*` command returning `Result`. The server re-validates.
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ function storeStub(aanvragen: RemoteData<Error | undefined, Aanvraag[]>, lastErr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const meta: Meta<MijnAanvragenSection> = {
|
const meta: Meta<MijnAanvragenSection> = {
|
||||||
title: 'Domein/Registratie/Dashboard/Mijn Aanvragen',
|
title: 'Domein/Registratie/Mijn Aanvragen',
|
||||||
component: MijnAanvragenSection,
|
component: MijnAanvragenSection,
|
||||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||||
};
|
};
|
||||||
+10
-10
@@ -1,11 +1,12 @@
|
|||||||
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 { 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';
|
||||||
import {
|
import {
|
||||||
@@ -88,10 +89,9 @@ export class MijnAanvragenSection {
|
|||||||
|
|
||||||
protected submittedRow = submittedRow;
|
protected submittedRow = submittedRow;
|
||||||
|
|
||||||
protected aanvragen = computed<Aanvraag[]>(() => {
|
protected aanvragen = computed<Aanvraag[]>(() =>
|
||||||
const rd = this.store.aanvragen();
|
sortForDashboard(successOr(this.store.aanvragen(), [])),
|
||||||
return rd.tag === 'Success' ? sortForDashboard(rd.value) : [];
|
);
|
||||||
});
|
|
||||||
protected concepten_ = computed(() => concepten(this.aanvragen()));
|
protected concepten_ = computed(() => concepten(this.aanvragen()));
|
||||||
protected ingediend_ = computed(() => ingediend(this.aanvragen()));
|
protected ingediend_ = computed(() => ingediend(this.aanvragen()));
|
||||||
|
|
||||||
+1
-1
@@ -28,7 +28,7 @@ function storeStub(profileRd: RemoteData<Error | undefined, BigProfile>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const meta: Meta<MijnRegistratieSection> = {
|
const meta: Meta<MijnRegistratieSection> = {
|
||||||
title: 'Domein/Registratie/Dashboard/Mijn Registratie',
|
title: 'Domein/Registratie/Mijn Registratie',
|
||||||
component: MijnRegistratieSection,
|
component: MijnRegistratieSection,
|
||||||
};
|
};
|
||||||
export default meta;
|
export default meta;
|
||||||
+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';
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user