Compare commits
143
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc2a3c348b | ||
|
|
551cabce5e | ||
|
|
b6bd7eea5a | ||
|
|
d4c5a9450a | ||
|
|
57fc72f254 | ||
|
|
43dc3210cd | ||
|
|
7a8eab917b | ||
|
|
bcac3789d3 | ||
|
|
cf1f641534 | ||
|
|
5aed15bb98 | ||
|
|
630d68045f | ||
|
|
d5a7a25a78 | ||
|
|
11e3191099 | ||
|
|
8e1de38c68 | ||
|
|
4b3e6a6cfd | ||
|
|
8a3e42015b | ||
|
|
dff5f96bb3 | ||
|
|
831940f1b9 | ||
|
|
a196a380ce | ||
|
|
8560746d15 | ||
|
|
dd11eafe50 | ||
|
|
3895588b9a | ||
|
|
fb2e58ab87 | ||
|
|
e221834f6e | ||
|
|
c36d9e3ff0 | ||
|
|
fb7b531fdf | ||
|
|
c45d1bc0ff | ||
|
|
a8c7a573fc | ||
|
|
c599fee8e2 | ||
|
|
8e5f48c5d2 | ||
|
|
02d41536df | ||
|
|
43f62ddfee | ||
|
|
827c655c1b | ||
|
|
11664d2efa | ||
|
|
84cbf3f7d8 | ||
|
|
d15943bb36 | ||
|
|
b8aced75e3 | ||
|
|
d0d6a3764b | ||
|
|
be1fcb4b40 | ||
|
|
d9c56b0c24 | ||
|
|
0c6fd37ed8 | ||
|
|
a71887a843 | ||
|
|
0697afb737 | ||
|
|
b9fd411283 | ||
|
|
b9d572cfdc | ||
|
|
97d9697a84 | ||
|
|
c8029d23e2 | ||
|
|
c7aed8d308 | ||
|
|
5977efe044 | ||
|
|
42e7a1e927 | ||
|
|
194cccfd02 | ||
|
|
faad772f85 | ||
|
|
637d500c96 | ||
|
|
c30d5ec5a5 | ||
|
|
3441dd4c4e | ||
|
|
dfc6c419f4 | ||
|
|
1a6652b417 | ||
|
|
dd5fd66fb8 | ||
|
|
6cfba81a39 | ||
|
|
0714b2af34 | ||
|
|
531817259e | ||
|
|
03c6e09306 | ||
|
|
c4a5d20202 | ||
|
|
a260af6120 | ||
|
|
e63db509ef | ||
|
|
6372d452a4 | ||
|
|
ce952941bb | ||
|
|
a0e2985fb3 | ||
|
|
adad4513d0 | ||
|
|
3162c755d6 | ||
|
|
95bb77395e | ||
|
|
e304211715 | ||
|
|
80b792caa9 | ||
|
|
ddd02f65bc | ||
|
|
07bb6277c0 | ||
|
|
693016445b | ||
|
|
9520d6c24e | ||
|
|
424ceb604b | ||
|
|
dc096d98e2 | ||
|
|
e270b8612f | ||
|
|
edd20c06df | ||
|
|
9f3814d8b9 | ||
|
|
d0fda08bcc | ||
|
|
05dff974bf | ||
|
|
7a29f5facc | ||
|
|
6c5c4cb249 | ||
|
|
9666790d65 | ||
|
|
7def4a7552 | ||
|
|
0adf831fb9 | ||
|
|
25a5d415a5 | ||
|
|
4631556e68 | ||
|
|
d518a1466c | ||
|
|
7fbac8fca5 | ||
|
|
bc5b2c4b2d | ||
|
|
2a28db4aac | ||
|
|
ab0ec62f6a | ||
|
|
f19185ed81 | ||
|
|
b617d2f09a | ||
|
|
2627799284 | ||
|
|
a93218e8ac | ||
|
|
ee0d449510 | ||
|
|
1c5442d797 | ||
|
|
80de261299 | ||
|
|
adfaa32a42 | ||
|
|
988612cd7e | ||
|
|
6bdfa35abb | ||
|
|
2fa96c300c | ||
|
|
c6bc6dd4c3 | ||
|
|
d089151dbd | ||
|
|
c336328cff | ||
|
|
436e18421b | ||
|
|
1fad740606 | ||
|
|
772c47ea43 | ||
|
|
b5432d2c63 | ||
|
|
4ac13f6cb5 | ||
|
|
9bff19a3e6 | ||
|
|
5118b0da95 | ||
|
|
3545023af8 | ||
|
|
de349e702e | ||
|
|
8b8b522052 | ||
|
|
494cee9d08 | ||
|
|
e89525eef6 | ||
|
|
d682f91b54 | ||
|
|
0298ecc506 | ||
|
|
5187bfa19a | ||
|
|
fbd27ed641 | ||
|
|
487818e67a | ||
|
|
6ffd3643b1 | ||
|
|
a2e935d1d8 | ||
|
|
176e5baef8 | ||
|
|
8dbfa83cd6 | ||
|
|
9440ce1345 | ||
|
|
4b94f8edb5 | ||
|
|
f2d4c900b4 | ||
|
|
4debf6614f | ||
|
|
664a43bf2d | ||
|
|
0ad02e651a | ||
|
|
ae7781efef | ||
|
|
6fa27d1c53 | ||
|
|
42f7bd651d | ||
|
|
6bc00a917c | ||
|
|
edaf1360c5 | ||
|
|
5d73ca21f6 |
@@ -100,15 +100,38 @@ module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName)
|
|||||||
{
|
{
|
||||||
name: 'apiclient-infrastructure-only',
|
name: 'apiclient-infrastructure-only',
|
||||||
comment:
|
comment:
|
||||||
'The generated ApiClient is a value only inside infrastructure/ (+ shared/upload); elsewhere type-only.',
|
'The generated ApiClient is a value only inside infrastructure/; elsewhere type-only.',
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' },
|
from: { pathNot: '/infrastructure/' },
|
||||||
to: {
|
to: {
|
||||||
path: '^libs/shared/src/infrastructure/api-client\\.ts$',
|
path: '^libs/shared/src/infrastructure/api-client\\.ts$',
|
||||||
dependencyTypesNot: ['type-only'],
|
dependencyTypesNot: ['type-only'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- 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:
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
// scaffolding one (see `gen:context`, WP-44).
|
// scaffolding one (see `gen:context`, WP-44).
|
||||||
module.exports = require('./.dependency-cruiser.base.js')(
|
module.exports = require('./.dependency-cruiser.base.js')(
|
||||||
{
|
{
|
||||||
|
// Two sanctioned cross-feature edges, both pointing at registratie: the portal home
|
||||||
|
// composes registratie's dashboard sections (RD-03), and herregistratie builds on a
|
||||||
|
// registration. Every other context imports only libs/shared and libs/beheer.
|
||||||
|
overzicht: ['registratie'],
|
||||||
auth: [],
|
auth: [],
|
||||||
registratie: [],
|
registratie: [],
|
||||||
herregistratie: ['registratie'], // the one sanctioned cross-feature edge
|
herregistratie: ['registratie'],
|
||||||
brief: [],
|
brief: [],
|
||||||
showcase: null, // unrestricted — the sanctioned teaching page; nothing imports it
|
showcase: null, // unrestricted — the sanctioned teaching page; nothing imports it
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -202,6 +202,11 @@ jobs:
|
|||||||
# run manually against backend/openzaak/ (see its README), never in CI.
|
# run manually against backend/openzaak/ (see its README), never in CI.
|
||||||
- run: dotnet test backend/BigRegister.slnx --filter "Category!=Integration"
|
- run: dotnet test backend/BigRegister.slnx --filter "Category!=Integration"
|
||||||
if: needs.changes.outputs.backend == 'true'
|
if: needs.changes.outputs.backend == 'true'
|
||||||
|
# RB-14/BIO-016: `npm audit --omit=dev` covers only the frontend; the .NET dependency
|
||||||
|
# tree was entirely unscanned. The script — not a bare `dotnet list` — is the gate,
|
||||||
|
# because `dotnet list package --vulnerable` exits 0 even on a High advisory.
|
||||||
|
- run: ./scripts/dotnet-audit.sh
|
||||||
|
if: needs.changes.outputs.backend == 'true'
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
needs: changes
|
needs: changes
|
||||||
|
|||||||
@@ -58,3 +58,7 @@ backend/openzaak/seeded.env
|
|||||||
|
|
||||||
# WP-55: render-prod-secrets.sh's output — the real client secret, never committed
|
# WP-55: render-prod-secrets.sh's output — the real client secret, never committed
|
||||||
backend/openzaak/setup_configuration/data.prod.yaml
|
backend/openzaak/setup_configuration/data.prod.yaml
|
||||||
|
|
||||||
|
# Agent git worktrees (Claude Code `isolation: "worktree"`) — full checkouts of
|
||||||
|
# this repo nested inside it; never commit one.
|
||||||
|
.claude/worktrees/
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ storybook-static*/
|
|||||||
coverage/
|
coverage/
|
||||||
.angular/
|
.angular/
|
||||||
|
|
||||||
|
# Agent git worktrees — full checkouts of this repo nested inside it, so an
|
||||||
|
# unignored `prettier --check .` walks into every one of them (and reports the
|
||||||
|
# vendored CIBG files that the top-level ignore already excludes).
|
||||||
|
.claude/worktrees/
|
||||||
|
|
||||||
# Lockfile
|
# Lockfile
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
@@ -21,3 +26,7 @@ plop-templates/
|
|||||||
|
|
||||||
# Backend is formatted by `dotnet format`, not prettier
|
# Backend is formatted by `dotnet format`, not prettier
|
||||||
backend/
|
backend/
|
||||||
|
|
||||||
|
# Agent prompts — their exact wording is the input, reflowing markdown edits the prompt
|
||||||
|
docs/project/refactor-backlog-setup/agents/
|
||||||
|
docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/
|
||||||
|
|||||||
@@ -124,8 +124,10 @@ than hardcoding one app's content — the two apps' primary nav genuinely differ
|
|||||||
should be **composition of existing blocks** — adding building blocks is the
|
should be **composition of existing blocks** — adding building blocks is the
|
||||||
exception, not the default. Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2)
|
exception, not the default. Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2)
|
||||||
CSS classes (`btn`, `form-control`, `card`, …); we own only a small typed `input()` API,
|
CSS classes (`btn`, `form-control`, `card`, …); we own only a small typed `input()` API,
|
||||||
the design system does the visuals. (Where CIBG lacks a class — e.g. `alert` — the atom is a
|
the design system does the visuals. (Where CIBG lacks a class — e.g. `skeleton`,
|
||||||
small hand-rolled surface built from the token bridge; see ADR-0003.)
|
`spinner` — the atom is a small hand-rolled surface built from the token bridge and carries a
|
||||||
|
`// CIBG-GAP EXTENSION:` marker; see ADR-0003. `alert` is **not** such a case: it wraps the
|
||||||
|
vendored `.feedback feedback-*` classes.)
|
||||||
|
|
||||||
### 3. State: make illegal states unrepresentable
|
### 3. State: make illegal states unrepresentable
|
||||||
|
|
||||||
@@ -175,17 +177,28 @@ herregistratie eligibility) or _config value_ (server sends threshold, FE applie
|
|||||||
for instant feedback, server re-validates as authority — e.g. scholing threshold).
|
for instant feedback, server re-validates as authority — e.g. scholing threshold).
|
||||||
FE keeps only **format** validation, never as authority.
|
FE keeps only **format** validation, never as authority.
|
||||||
|
|
||||||
DTO lives in `contracts/`; a hand-written `parse*`/`toDomain` in `infrastructure/`
|
The generated client
|
||||||
validates the untrusted shape and maps DTO → domain. Wiring a real .NET backend
|
(`libs/shared/src/infrastructure/api-client.ts`, `npm run gen:api`, drift-checked in CI) **is**
|
||||||
|
the wire contract — consume its types directly, as 19 of the 20 adapters do. A hand-written
|
||||||
|
`contracts/*.dto.ts` is the exception, only where codegen does not reach the endpoint or types
|
||||||
|
it too loosely (the four survivors are all the latter — the generator emits every property as
|
||||||
|
optional and flattens unions); such a file must still import nothing. Either way a hand-written
|
||||||
|
`parse*`/`toDomain` in `infrastructure/` validates the untrusted shape and maps DTO → domain —
|
||||||
|
**a generated type is a compile-time claim about the wire, not a runtime guarantee.** Wiring a real .NET backend
|
||||||
touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-owned
|
touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-owned
|
||||||
rules stay in `domain/*.policy.ts` as reference impl + unit test, marked server-owned,
|
rules live **only** on the server, with no FE mirror to drift from it — the FE may
|
||||||
but the FE doesn't call them.
|
mirror a server-supplied _value_ (a threshold, a bound) for instant feedback, but
|
||||||
|
never reimplements the _algorithm_.
|
||||||
|
|
||||||
**Business-tunable reference data ("stamdata") is config-as-code, not a DB.** Tables the
|
**Business-tunable reference data ("stamdata") is config-as-code, not a DB.** Tables the
|
||||||
business controls (profession↔diploma map, thresholds, policy-question text) live as typed
|
business controls (profession↔diploma map, thresholds, policy-question text) live as typed
|
||||||
C# in `backend/.../Stamdata/`, validated at build by `StamdataValidationTests` (a bad edit
|
C# in `backend/.../Stamdata/`, validated at build by `StamdataValidationTests` (a bad edit
|
||||||
fails CI, never prod) — never runtime-editable. Org-templates are the deliberate exception
|
fails CI, never prod) — never runtime-editable. Operational configuration is the deliberate
|
||||||
(operational per-org config in SQLite). UI copy is `$localize`. See ADR-0004.
|
exception, and ADR-0004 states it as a four-part test rather than a list: the catalog lives in
|
||||||
|
code, an unknown key fails closed, the value is operational rather than a shared business rule,
|
||||||
|
and writes are admin-capability-gated **and** audited. Two surfaces pass it today —
|
||||||
|
`OrgTemplateStore` (per-org letterhead) and `FeatureFlagStore` (rollout switches), both in
|
||||||
|
SQLite. A third surface must pass the same test, not argue by analogy. UI copy is `$localize`. See ADR-0004.
|
||||||
|
|
||||||
### 5. Testing
|
### 5. Testing
|
||||||
|
|
||||||
@@ -197,10 +210,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
|
||||||
|
|
||||||
@@ -213,6 +226,17 @@ regardless of which atomic layer it is (a context organism doesn't get its own
|
|||||||
- **Naming:** shared/reusable UI is **English** (language-agnostic: `button`,
|
- **Naming:** shared/reusable UI is **English** (language-agnostic: `button`,
|
||||||
`wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`,
|
`wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`,
|
||||||
`*.machine.ts`). Pick the language by which side of the seam the code is on.
|
`*.machine.ts`). Pick the language by which side of the seam the code is on.
|
||||||
|
- **English prose uses Simplified Technical English (STE).** This covers documentation,
|
||||||
|
code comments, commit messages, ADRs, and the backlog notes. One idea per sentence;
|
||||||
|
20 words or fewer in a procedure, 25 in a description. Active voice, present tense.
|
||||||
|
One word for one meaning — pick a term and repeat it, do not vary it for style. Keep
|
||||||
|
articles ("the test fails"). Three nouns together at most. No idioms and no humour.
|
||||||
|
Six sentences per paragraph at most. Write a procedure as numbered steps, one action
|
||||||
|
per step.
|
||||||
|
**STE governs form, not content.** Split a long sentence; never drop a caveat, a
|
||||||
|
measurement, or a precise term to make it shorter.
|
||||||
|
**STE does not apply to** Dutch identifiers, `$localize` copy, quoted output, or
|
||||||
|
existing documents you are not already editing.
|
||||||
- **User-facing copy = `$localize`.** Every user-visible string is wrapped in Angular's
|
- **User-facing copy = `$localize`.** Every user-visible string is wrapped in Angular's
|
||||||
first-party `$localize` (no third-party i18n lib), with a stable custom id
|
first-party `$localize` (no third-party i18n lib), with a stable custom id
|
||||||
(`` $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
|
||||||
|
|||||||
@@ -1,205 +1,176 @@
|
|||||||
# BIG-register Self-Service Portal — Atomic Design POC
|
# BIG-register Portals — Atomic Design POC
|
||||||
|
|
||||||
A small Angular app that shows how **atomic design** makes a frontend cheap to build,
|
A two-app Angular monorepo showing how **atomic design** plus **domain-driven boundaries** make
|
||||||
reuse and extend. The domain is the **BIG-register** self-service portal (the Dutch
|
a frontend cheap to build, reuse and extend. The domain is the **BIG-register** (the Dutch
|
||||||
register of healthcare professionals, run by CIBG). It is styled with the **CIBG
|
register of healthcare professionals, run by CIBG): a citizen self-service portal and a
|
||||||
Huisstijl** design system (a customized Bootstrap 5.2 build, vendored — see ADR-0003),
|
case-handler backoffice, sharing one design system and one backend.
|
||||||
and demonstrates a robust **async-state pattern** where the UI can never reach an
|
|
||||||
inconsistent state.
|
|
||||||
|
|
||||||
> Demo / POC — **no real login** (DigiD is faked) and synthetic seed data. The
|
It is styled with the **CIBG Huisstijl** (a customized Bootstrap 5.2 build, vendored — ADR-0003)
|
||||||
> business rules and data _are_ served by a real **ASP.NET Core backend**
|
and built around one idea: **make illegal states unrepresentable** — in the UI's async states, in
|
||||||
> (`backend/`) consumed through a generated typed client, so the BFF + DDD design
|
the domain types, and in the tests.
|
||||||
> is demonstrable, not hand-waved. A system-font stack stands in for the licensed
|
|
||||||
> Rijksoverheid font and a text wordmark for the logo.
|
> **Demo / POC** — **no real login** (DigiD is faked) and synthetic seed data. But the business
|
||||||
|
> rules and data _are_ served by a real **ASP.NET Core backend** (`backend/`) through a generated
|
||||||
|
> typed client, so the BFF + DDD design is demonstrable rather than hand-waved. A system-font
|
||||||
|
> stack stands in for the licensed Rijksoverheid font, and a text wordmark for the logo.
|
||||||
|
|
||||||
|
> **New here?** Run `npm run storybook` and open **Foundations → Learning Path** — a paced,
|
||||||
|
> hands-on three-day route through the codebase, written for a strong programmer who is new to
|
||||||
|
> frontend functional programming. For everything else, **[`docs/README.md`](docs/README.md)** is
|
||||||
|
> the documentation index.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Run it
|
## Quick start
|
||||||
|
|
||||||
|
Everything at once — API, both portals:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up # frontend + backend together → app http://localhost:4200, Swagger http://localhost:5000/swagger
|
docker compose up
|
||||||
|
# self-service portal → http://localhost:4200
|
||||||
|
# behandelportal → http://localhost:4201
|
||||||
|
# API + Swagger → http://localhost:5000/swagger
|
||||||
```
|
```
|
||||||
|
|
||||||
Or run the two halves separately:
|
This does not start OpenZaak. The BFF uses a local, in-memory case store instead. To run
|
||||||
|
against a real OpenZaak, see [backend/openzaak/README.md](backend/openzaak/README.md).
|
||||||
|
|
||||||
|
Or run the pieces yourself:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm start # app → http://localhost:4200 (proxies /api → backend, proxy.conf.json)
|
npm start # self-service portal → :4200 (proxies /api → backend)
|
||||||
# in another terminal:
|
npm run start:behandelportal # case-handler portal → :4201
|
||||||
cd backend && dotnet run --project src/BigRegister.Api # API → http://localhost:5000/swagger
|
|
||||||
|
|
||||||
npm run storybook # component library, organized by atomic layer
|
# in another terminal:
|
||||||
npm run gen:api # regenerate the typed API client from the backend OpenAPI doc
|
cd backend && dotnet run --project src/BigRegister.Api # API → :5000/swagger
|
||||||
npm run e2e # Playwright smoke tests against the running app + backend (both must be up)
|
|
||||||
|
npm run storybook # the design system + Foundations curriculum
|
||||||
|
npm run e2e # Playwright — starts the backend and app itself
|
||||||
```
|
```
|
||||||
|
|
||||||
Flow: **Login → Dashboard → Mijn gegevens (wijziging) → Herregistratie → Intake**.
|
**Self-service flow:** Login → Dashboard → Mijn gegevens → Registreren → Herregistratie → Intake
|
||||||
The backend hosts the business rules (profession derivation, policy questions,
|
→ Brief. **Behandelportal:** Login → Werkvoorraad → Beoordeling (approve / reject / ask for more).
|
||||||
eligibility, thresholds); see **[backend/README.md](backend/README.md)**.
|
Admin pages (`/beheer/*`, `/brief/huisstijl`) need the `admin` role — see
|
||||||
|
[roles and access](docs/reference/roles-and-access.md).
|
||||||
|
|
||||||
> **New here:** a **branching intake questionnaire** (`/intake`) where later questions
|
---
|
||||||
> appear based on earlier answers and progress survives a page reload, plus a visual
|
|
||||||
> walkthrough of the state-management ideas. See
|
|
||||||
> **[docs/reference/architecture/ARCHITECTURE.md](docs/reference/architecture/ARCHITECTURE.md)** for diagrams (atomic-design pyramid,
|
|
||||||
> the dispatch→reduce→view loop, RemoteData states, and "why not just signals") and a
|
|
||||||
> section on **connecting to a .NET backend**.
|
|
||||||
|
|
||||||
### See every data state (scenario toggle)
|
## Where to find things
|
||||||
|
|
||||||
Append `?scenario=` to any data page (e.g. `/dashboard`) to force an async state:
|
| I want to… | Go to |
|
||||||
|
| --------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||||
|
| learn the codebase from scratch | `npm run storybook` → **Foundations → Learning Path** |
|
||||||
|
| find any document | **[docs/README.md](docs/README.md)** — the full index |
|
||||||
|
| understand the architecture | [ARCHITECTURE.md](docs/reference/architecture/ARCHITECTURE.md) |
|
||||||
|
| 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) |
|
||||||
|
| 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) |
|
||||||
|
| 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` |
|
||||||
|
| work on this repo as an AI agent | [CLAUDE.md](CLAUDE.md) |
|
||||||
|
|
||||||
|
## Repo map
|
||||||
|
|
||||||
|
| Path | What lives there |
|
||||||
|
| ---------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| `apps/ssp/` | Zorgverlener self-service portal (:4200) |
|
||||||
|
| `apps/behandelportal/` | Behandelaar backoffice (:4201) |
|
||||||
|
| `libs/shared/` | Design system, kernel, generated API client, Storybook Foundations docs |
|
||||||
|
| `libs/beheer/` | Admin/stamdata context, used identically by both apps |
|
||||||
|
| `backend/` | ASP.NET Core BFF (.NET 10, EF Core/SQLite), OpenZaak seam |
|
||||||
|
| `e2e/` | Playwright specs |
|
||||||
|
| `docs/` | `reference/` (how + why) and `project/` (backlog, PRDs) |
|
||||||
|
| `public/` | Vendored CIBG Huisstijl + assets |
|
||||||
|
| `scripts/` | CI gate, drift checks, generators |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run ci # ← run this before pushing: the whole gate, exactly what CI runs
|
||||||
|
```
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
| -------- | ------------------------------------------------------------------------------------------- |
|
||||||
|
| Run | `npm start`, `npm run start:behandelportal`, `docker compose up` |
|
||||||
|
| Test | `npm test`, `npm run e2e`, `npm run test-storybook` (axe on every story) |
|
||||||
|
| Check | `npm run lint`, `typecheck`, `dep:check`, `check:tokens`, `check:seam` |
|
||||||
|
| Build | `npm run build`, `npm run build-storybook` |
|
||||||
|
| Generate | `npm run gen:api` (typed client), `npm run gen` (plop: value object, form machine, context) |
|
||||||
|
| Docs | `npm run storybook`, `npm run dep:graph`, `npm run gen:behaviour-spec` |
|
||||||
|
|
||||||
|
`npm run ci` chains lint, typecheck, dependency boundaries, formatting, token and seam drift
|
||||||
|
checks, all four test projects, both localized builds, the backend suite, and the generated-artifact
|
||||||
|
drift gates. The full script list is in `package.json`; `CLAUDE.md` explains the traps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where atomic design pays off
|
||||||
|
|
||||||
|
**Two orthogonal axes, and that is the point.** On disk, code is grouped by **ownership** —
|
||||||
|
bounded context, then layer (`domain/` → `application/` → `infrastructure/` → `ui/`, dependencies
|
||||||
|
pointing inward). In Storybook, the same components are grouped by **atomic level** — story titles
|
||||||
|
put them under `Design System/Atoms|Molecules|Organisms|Templates`. So `libs/shared/src/ui/` is a
|
||||||
|
flat folder of 26 components, and the atomic ladder lives in the sidebar where you actually browse
|
||||||
|
it. See [Atomic design](libs/shared/docs/atomic-design.mdx) and
|
||||||
|
[Domain-driven design](libs/shared/docs/layers.mdx) in Foundations.
|
||||||
|
|
||||||
|
**Reuse is the payoff.** `button`, `form-field`, `async`, `page-shell`, `site-header` appear on
|
||||||
|
essentially every screen across _both_ apps. Change one, every screen follows.
|
||||||
|
|
||||||
|
**A new page is composition, not new components.** The branching intake wizard — the most complex
|
||||||
|
flow in the app — needed exactly **one** new atom (`radio-group`) and one new organism
|
||||||
|
(`intake-wizard`). Everything else was already there.
|
||||||
|
|
||||||
|
**Theming is one stylesheet and a token bridge.** `libs/shared/styles.scss` maps the app's
|
||||||
|
semantic `--rhc-*` vocabulary onto CIBG/`--bs-*` values, so components reference tokens, never
|
||||||
|
colours. Re-point the bridge to re-theme both apps with no component changes (ADR-0003).
|
||||||
|
`npm run check:tokens` fails the build on a hardcoded colour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Try it: dev affordances
|
||||||
|
|
||||||
|
Append `?scenario=` to any data page to force an async state — the states are mutually exclusive
|
||||||
|
by construction, via the `<app-async>` molecule:
|
||||||
|
|
||||||
| URL | What you see |
|
| URL | What you see |
|
||||||
| ----------------------------- | -------------------------------------------- |
|
| ----------------------------- | -------------------------------------------- |
|
||||||
| `/dashboard` | real data (fast) |
|
| `/dashboard` | real data |
|
||||||
| `/dashboard?scenario=slow` | skeletons for ~2.5s, then data |
|
| `/dashboard?scenario=slow` | skeletons for ~2.5s, then data |
|
||||||
| `/dashboard?scenario=loading` | the loading state, held open |
|
| `/dashboard?scenario=loading` | the loading state, held open |
|
||||||
| `/dashboard?scenario=empty` | "geen gegevens" empty state |
|
| `/dashboard?scenario=empty` | "geen gegevens" empty state |
|
||||||
| `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) |
|
| `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) |
|
||||||
|
|
||||||
---
|
Append `?role=drafter|approver|admin` to switch the dev role stand-in and unlock the admin pages.
|
||||||
|
Both toggles are **dev-only** — neither interceptor is wired into production builds.
|
||||||
## How atomic design works here (folder = layer)
|
|
||||||
|
|
||||||
Atomic design organizes UI into five layers, each built from the one below. In this repo
|
|
||||||
the folder structure _is_ the hierarchy (`src/app/`):
|
|
||||||
|
|
||||||
| Layer | What it is | Examples here |
|
|
||||||
| -------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **atoms/** | smallest building blocks; wrap one design-system element | `button`, `text-input`, `heading`, `link`, `alert`, `status-badge`, `spinner`, `skeleton` |
|
|
||||||
| **molecules/** | a few atoms combined into a unit | `form-field` (label + input + error), `data-row`, `async` (state wrapper) |
|
|
||||||
| **organisms/** | larger, self-contained sections | `site-header`, `site-footer`, `login-form`, `registration-summary`, `registration-table`, `change-request-form` |
|
|
||||||
| **templates/** | page skeletons that define layout; content is projected in | `page-layout` (header/content/footer chrome), `page-shell` (back-link + heading + intro + content) |
|
|
||||||
| **pages/** | a template filled with real data | `login`, `dashboard`, `registration-detail`, `herregistratie` |
|
|
||||||
|
|
||||||
Each atom is a thin Angular standalone component that applies CIBG Huisstijl
|
|
||||||
(Bootstrap 5.2) CSS classes (`btn`, `form-control`, `card`, …) — so the design system
|
|
||||||
does the visual work and we only own a small, typed component API.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Where you actually notice the benefit
|
|
||||||
|
|
||||||
**1. Reuse — the same blocks appear everywhere.**
|
|
||||||
|
|
||||||
| Component | Appears in |
|
|
||||||
| ----------------------------- | ------------------------------------------------------------- |
|
|
||||||
| `button` | login, change-request, herregistratie, async retry, Storybook |
|
|
||||||
| `form-field` + `text-input` | login form _and_ change-request _and_ herregistratie |
|
|
||||||
| `status-badge` | dashboard summary, detail summary |
|
|
||||||
| `page-shell` / `page-layout` | all four pages |
|
|
||||||
| `site-header` / `site-footer` | every page |
|
|
||||||
| `async` + `skeleton` | dashboard, detail |
|
|
||||||
|
|
||||||
Change a component once and every screen that uses it updates.
|
|
||||||
|
|
||||||
**2. A whole new page = composition, no new components.**
|
|
||||||
`pages/herregistratie/herregistratie.page.ts` is a complete new flow assembled entirely
|
|
||||||
from existing atoms/molecules/templates — zero new building blocks. The branching
|
|
||||||
**intake wizard** went further: it needed only **one new atom** (`radio-group`) and **one
|
|
||||||
new organism** (`intake-wizard`); the form fields, buttons, alerts, spinner and page shell
|
|
||||||
were all reused. That's the payoff: new screens cost almost nothing.
|
|
||||||
|
|
||||||
**3. Templates remove per-page boilerplate.**
|
|
||||||
Every page used to repeat its own back-link + heading + intro markup. `page-shell`
|
|
||||||
captures that once; pages now read like `<app-page-shell heading="…" backLink="…">…`.
|
|
||||||
|
|
||||||
**4. Theming is one stylesheet + a token bridge.**
|
|
||||||
The look comes from **CIBG Huisstijl**, vendored under `public/cibg-huisstijl/` and
|
|
||||||
loaded via a `<link>` in `index.html`; `body.brand--cibg` activates CIBG's
|
|
||||||
robijn/lintblauw palette. `src/styles.scss` is a **token bridge** mapping the app's
|
|
||||||
semantic `--rhc-*` token vocabulary onto CIBG/`--bs-*` values, so components keep
|
|
||||||
referencing tokens — swap the vendored CSS and re-point the bridge to re-theme the
|
|
||||||
whole app, no component changes (ADR-0003). `npm run check:tokens` fails the build
|
|
||||||
on any hardcoded colour outside that bridge.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## State management (no impossible states)
|
|
||||||
|
|
||||||
Data fetching uses Angular's native, signal-based **`resource`** over the generated
|
|
||||||
typed client (no NgRx, no extra dependency). Each context's `infrastructure/*.adapter.ts`
|
|
||||||
exposes a resource that carries `status()`, `value()`, `error()` and `reload()` as
|
|
||||||
signals, and a `parse*` function validates the response at the trust boundary
|
|
||||||
(DTO → domain). The screen-shaped ("BFF-lite") endpoints return server-computed
|
|
||||||
decisions the FE renders rather than recomputes (see ADR-0001).
|
|
||||||
|
|
||||||
The molecule **`<app-async>`** turns those signals into UI. It renders **exactly one** of
|
|
||||||
four slots, chosen by a single `computed` — so loading, empty, error and loaded are
|
|
||||||
mutually exclusive _by construction_. You cannot render data and an error at the same
|
|
||||||
time, or show stale content during a hard failure: those states are unrepresentable.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<app-async [resource]="reg" [isEmpty]="regEmpty">
|
|
||||||
<ng-template appAsyncLoaded let-r> <app-registration-summary [reg]="r" /> </ng-template>
|
|
||||||
<ng-template appAsyncLoading> <app-skeleton [count]="6" /> </ng-template>
|
|
||||||
<!-- appAsyncEmpty / appAsyncError are optional → sensible defaults -->
|
|
||||||
</app-async>
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Loaded** — your content, with the value.
|
|
||||||
- **Loading** — your skeleton, or a default **delayed spinner** (only appears after
|
|
||||||
~250ms, so fast connections never flash a spinner; slow ones get feedback). Skeletons
|
|
||||||
are also delay-gated. → _handles slow vs fast connections._
|
|
||||||
- **Empty** — your message, or a default "Geen gegevens gevonden" (driven by an
|
|
||||||
`isEmpty` predicate).
|
|
||||||
- **Error** — your template, or a default alert + a **retry** button that calls
|
|
||||||
`resource.reload()`.
|
|
||||||
|
|
||||||
Because each data-fetching page wraps its content in `<app-async>`, correct
|
|
||||||
loading/empty/error handling is automatic and consistent across the app.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Page transitions
|
|
||||||
|
|
||||||
The chrome (`templates/shell` — header + footer) is **persistent**: it mounts once and
|
|
||||||
hosts the `<router-outlet>`, so navigating doesn't re-create it (no white flash). Only
|
|
||||||
the routed content cross-fades, via Angular's native **`withViewTransitions()`** — the
|
|
||||||
header/footer get a stable `view-transition-name` in `styles.scss` so they're excluded
|
|
||||||
from the fade. `prefers-reduced-motion` disables the animation; non-Chromium browsers
|
|
||||||
degrade to an instant navigation.
|
|
||||||
|
|
||||||
## Tech notes
|
## Tech notes
|
||||||
|
|
||||||
- Angular 22 (standalone components, signals, `httpResource`, view transitions,
|
- **Angular 22** — standalone components, signals, `resource()`, native control flow, view
|
||||||
control flow `@if/@for`).
|
transitions. No NgRx: shared state is a root singleton store with a pure reducer.
|
||||||
- Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in
|
- **Backend** — ASP.NET Core (.NET 10), EF Core/SQLite for applications, documents, the brief and
|
||||||
`public/cibg-huisstijl/`, loaded via `<link>`; `src/styles.scss` holds the
|
the audit trail; BRP/DUO reference data stays in-memory seeded. Screen-shaped ("BFF-lite")
|
||||||
`--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency.
|
endpoints return server-computed decisions the frontend renders rather than recomputes
|
||||||
- Data: ASP.NET Core backend (`backend/`, EF Core/SQLite-persisted; BRP/DUO
|
(ADR-0001). Cases can be sourced from **OpenZaak/ZGW** behind the same seam (ADR-0005).
|
||||||
reference data stays in-memory-seeded) exposed via an OpenAPI contract; the FE
|
- **Typed client** — NSwag-generated from the backend's OpenAPI doc (`npm run gen:api`); CI fails
|
||||||
consumes an **NSwag-generated** typed client (`npm run gen:api`).
|
on drift.
|
||||||
The `?scenario=` toggle (`shared/infrastructure/scenario.interceptor.ts`) is
|
- **Boundaries are enforced, not hoped for** — `dep:check` fails the build if `domain/` imports
|
||||||
**dev-only** — it is not wired into production builds.
|
Angular, a context imports upward, or an app reaches into the other app.
|
||||||
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
|
- **i18n** — every user-facing string is `$localize`-wrapped with a stable id (source locale `nl`).
|
||||||
Angular 22; the builder runs fine (build verified).
|
`ng build --localize` builds both `nl` and `en`, and `i18nMissingTranslation: "error"` fails the
|
||||||
- **i18n**: every user-facing string is `$localize`-wrapped with a stable `@@id`
|
build if a string ships untranslated.
|
||||||
(source locale `nl`). `npx ng build --localize` (CI runs this) builds both `nl` and
|
- **Dependencies** — the shipped bundle audits clean (`npm audit --omit=dev`: 0 vulnerabilities).
|
||||||
`en` — a genuine second-locale build, not just an unexercised claim — into
|
`.npmrc` sets `legacy-peer-deps=true` because Storybook's peer range lags Angular 22. Never run
|
||||||
`dist/atomic-design-poc/browser/{nl,en}/`; `ng serve --configuration=en` serves the
|
`npm audit fix --force` — it downgrades Angular 22 → 21.
|
||||||
English build locally. `src/locale/messages.en.xlf` is real (if demo-quality)
|
|
||||||
English, not machine-untranslated placeholders; `angular.json`'s
|
|
||||||
`i18nMissingTranslation: "error"` fails the build if a new `$localize` string ships
|
|
||||||
without a translation. `npm run extract-i18n` regenerates the `nl` reference file.
|
|
||||||
|
|
||||||
### Dependency security
|
### Deliberately out of scope
|
||||||
|
|
||||||
The **shipped app has 0 known vulnerabilities** (`npm audit --omit=dev`) — and, since the
|
Real auth/DigiD, real BRP/DUO upstreams, a production-grade database, NgRx, licensed RO/Rijks
|
||||||
`@babel/core` pin below, the **full dev audit is 0 too**. All advisories live(d) in
|
fonts and logo. The i18n build seam is proven, but the `en` translation is demo-quality and locale
|
||||||
dev/build tooling (Storybook + the Angular build chain) and never reach the bundle.
|
is a build-time choice, not a runtime switch.
|
||||||
`package.json` `overrides` pin patched transitive versions; the last remaining cluster
|
|
||||||
cascaded from `@babel/core`'s low-severity sourceMappingURL issue, closed by pinning
|
|
||||||
`@babel/core` to a patched **7.x** (`^7.29.7`) — no jump to Babel 8, no breaking change.
|
|
||||||
We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 22 → 21.
|
|
||||||
|
|
||||||
### Deliberately out of scope (POC)
|
|
||||||
|
|
||||||
Real auth/DigiD, real BRP/DUO upstreams, a production-grade database (Postgres/SQL
|
|
||||||
Server — SQLite persists applications/documents/the brief + a real audit table,
|
|
||||||
see `backend/README.md`, WP-22), NgRx, licensed RO/Rijks fonts + logo (system-font
|
|
||||||
stack; text wordmark). (The backend itself _is_ implemented.) i18n's build seam is
|
|
||||||
proven (see above) but
|
|
||||||
production-quality translation, a runtime locale switcher, and RTL/pluralization
|
|
||||||
edge cases are not — the `en` file is demo-quality, and locale is a build-time
|
|
||||||
choice, not a switch in the running app.
|
|
||||||
|
|||||||
+4
-3
@@ -106,7 +106,7 @@
|
|||||||
"**/*.spec.ts",
|
"**/*.spec.ts",
|
||||||
"**/*.stories.ts",
|
"**/*.stories.ts",
|
||||||
"**/contracts/**",
|
"**/contracts/**",
|
||||||
"libs/shared/src/infrastructure/api-client.ts",
|
"**/infrastructure/api-client.ts",
|
||||||
"apps/ssp/src/main.ts",
|
"apps/ssp/src/main.ts",
|
||||||
"**/*.testing.ts",
|
"**/*.testing.ts",
|
||||||
"**/*.d.ts"
|
"**/*.d.ts"
|
||||||
@@ -235,7 +235,7 @@
|
|||||||
"**/*.spec.ts",
|
"**/*.spec.ts",
|
||||||
"**/*.stories.ts",
|
"**/*.stories.ts",
|
||||||
"**/contracts/**",
|
"**/contracts/**",
|
||||||
"libs/shared/src/infrastructure/api-client.ts",
|
"**/infrastructure/api-client.ts",
|
||||||
"apps/behandelportal/src/main.ts",
|
"apps/behandelportal/src/main.ts",
|
||||||
"**/*.testing.ts",
|
"**/*.testing.ts",
|
||||||
"**/*.d.ts"
|
"**/*.d.ts"
|
||||||
@@ -293,7 +293,7 @@
|
|||||||
"**/*.spec.ts",
|
"**/*.spec.ts",
|
||||||
"**/*.stories.ts",
|
"**/*.stories.ts",
|
||||||
"**/contracts/**",
|
"**/contracts/**",
|
||||||
"src/infrastructure/api-client.ts",
|
"**/infrastructure/api-client.ts",
|
||||||
"src/test-entry.ts",
|
"src/test-entry.ts",
|
||||||
"**/*.testing.ts",
|
"**/*.testing.ts",
|
||||||
"**/*.d.ts"
|
"**/*.d.ts"
|
||||||
@@ -332,6 +332,7 @@
|
|||||||
"**/*.spec.ts",
|
"**/*.spec.ts",
|
||||||
"**/*.stories.ts",
|
"**/*.stories.ts",
|
||||||
"**/contracts/**",
|
"**/contracts/**",
|
||||||
|
"**/infrastructure/api-client.ts",
|
||||||
"src/test-entry.ts",
|
"src/test-entry.ts",
|
||||||
"**/*.testing.ts",
|
"**/*.testing.ts",
|
||||||
"**/*.d.ts"
|
"**/*.d.ts"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import localeEn from '@angular/common/locales/en';
|
|||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
|
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
|
||||||
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
|
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
|
||||||
|
import { subjectInterceptor } from '@shared/infrastructure/subject.interceptor';
|
||||||
import { medewerkerInterceptor } from '@auth/infrastructure/medewerker.interceptor';
|
import { medewerkerInterceptor } from '@auth/infrastructure/medewerker.interceptor';
|
||||||
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||||
import { SESSION_PORT } from '@shared/application/session.port';
|
import { SESSION_PORT } from '@shared/application/session.port';
|
||||||
@@ -55,7 +56,9 @@ export const appConfig: ApplicationConfig = {
|
|||||||
// a query param could otherwise force errors on the live app.
|
// a query param could otherwise force errors on the live app.
|
||||||
provideHttpClient(
|
provideHttpClient(
|
||||||
withInterceptors(
|
withInterceptors(
|
||||||
isDevMode() ? [scenarioInterceptor, roleInterceptor, medewerkerInterceptor] : [],
|
isDevMode()
|
||||||
|
? [scenarioInterceptor, roleInterceptor, subjectInterceptor, medewerkerInterceptor]
|
||||||
|
: [],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
provideApiClient(),
|
provideApiClient(),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -1,58 +1,50 @@
|
|||||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||||
import { Result } from '@shared/kernel/fp';
|
import { Principal, parseStoredPrincipal } from '../domain/principal';
|
||||||
import { Session } from '../domain/session';
|
import { MedewerkerAdapter } from '../infrastructure/medewerker.adapter';
|
||||||
import { DigidAdapter } from '../infrastructure/digid.adapter';
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'session-v1';
|
const STORAGE_KEY = 'session-v1';
|
||||||
|
|
||||||
/** Restore a persisted session (best-effort; corrupt entry → logged out).
|
/** Restore a persisted principal (best-effort; corrupt entry → logged out).
|
||||||
G2: validate the shape before trusting it. G1: the BSN is never persisted
|
The shape validation (G2 — there is no BSN here, so no G1 to enforce) lives in
|
||||||
(see the effect below), so a restored session carries an empty one — it is
|
`parseStoredPrincipal` (`../domain/principal`) — pure, spec'd, and testable
|
||||||
unused after login; only `naam` is shown in the chrome. */
|
without stubbing `localStorage`; this just supplies the raw value. */
|
||||||
function restore(): Session | null {
|
function restore(): Principal | null {
|
||||||
try {
|
return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
const parsed = JSON.parse(raw) as Partial<Session>;
|
|
||||||
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the current session for the whole app. Because it is providedIn:'root'
|
* Holds the current medewerker principal for the whole backoffice app. One
|
||||||
* there is exactly one instance — every component that injects it sees the same
|
* `providedIn: 'root'` instance, so logging in is instantly visible everywhere
|
||||||
* session signal, so logging in is instantly visible everywhere (the guard, the
|
* (the guard, the header). Persisted to localStorage — a refresh or the
|
||||||
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link,
|
* cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
|
||||||
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`,
|
* which is safe to do verbatim here: a medewerker principal carries no BSN or
|
||||||
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage —
|
* other national identifier, unlike the SSP's `SessionStore`, whose equivalent
|
||||||
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language
|
* comment explains why *that* app strips a field before writing. A real
|
||||||
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
|
* deployment keeps auth in an httpOnly cookie/token, not web storage, regardless.
|
||||||
* in an httpOnly cookie/token, not web storage.
|
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SessionStore {
|
export class SessionStore {
|
||||||
private digid = inject(DigidAdapter);
|
private medewerker = inject(MedewerkerAdapter);
|
||||||
private _session = signal<Session | null>(restore());
|
private _session = signal<Principal | null>(restore());
|
||||||
|
|
||||||
readonly session = this._session.asReadonly();
|
readonly session = this._session.asReadonly();
|
||||||
readonly isAuthenticated = computed(() => this._session() !== null);
|
readonly isAuthenticated = computed(() => this._session() !== null);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const s = this._session();
|
const p = this._session();
|
||||||
// G1: persist only `naam` — never write the BSN (national ID) to storage.
|
if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
|
||||||
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
|
|
||||||
else localStorage.removeItem(STORAGE_KEY);
|
else localStorage.removeItem(STORAGE_KEY);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Effectful command: authenticate, then store the session on success. */
|
/** Effectful command: authenticate via the SSO stand-in, then store the
|
||||||
async login(bsn: string): Promise<Result<string, Session>> {
|
resulting principal. No credential to pass in, and nothing that can fail
|
||||||
const r = await this.digid.authenticate(bsn);
|
today — see `MedewerkerAdapter`. */
|
||||||
if (r.ok) this._session.set(r.value);
|
async login(): Promise<Principal> {
|
||||||
return r;
|
const p = await this.medewerker.authenticate();
|
||||||
|
this._session.set(p);
|
||||||
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
|
|||||||
@@ -1,34 +1,10 @@
|
|||||||
import { inject } from '@angular/core';
|
|
||||||
import { CanActivateFn, Router } from '@angular/router';
|
|
||||||
import { AccessStore } from '@shared/application/access.store';
|
|
||||||
import { Capability } from '@shared/domain/capability';
|
|
||||||
import { SessionStore } from './application/session.store';
|
|
||||||
|
|
||||||
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
|
|
||||||
export const authGuard: CanActivateFn = () => {
|
|
||||||
const store = inject(SessionStore);
|
|
||||||
const router = inject(Router);
|
|
||||||
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
|
* The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading
|
||||||
* redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`).
|
* only `SESSION_PORT` and `AccessStore`, so both apps share one copy and one spec.
|
||||||
|
* Re-exported here so `app.routes.ts` keeps importing them from `@auth/auth.guard`:
|
||||||
|
* routing asks the auth context for its guards, which is the right direction to read.
|
||||||
*
|
*
|
||||||
* **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me`
|
* ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes —
|
||||||
* is still loading — it would deny an entitled admin and bounce them. We await
|
* `Principal`, the login flow, `SessionStore`. A guard is neither.
|
||||||
* `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user
|
|
||||||
* goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're
|
|
||||||
* logged in, just not allowed here — no re-login loop). The backend re-enforces
|
|
||||||
* regardless (403); this guard is the UX pre-gate.
|
|
||||||
*/
|
*/
|
||||||
export function capabilityGuard(capability: Capability): CanActivateFn {
|
export { authGuard, capabilityGuard } from '@shared/application/auth.guard';
|
||||||
return async () => {
|
|
||||||
const session = inject(SessionStore);
|
|
||||||
const access = inject(AccessStore);
|
|
||||||
const router = inject(Router);
|
|
||||||
if (!session.isAuthenticated()) return router.createUrlTree(['/login']);
|
|
||||||
await access.whenReady();
|
|
||||||
return access.can(capability) ? true : router.createUrlTree(['/dashboard']);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { isAuthenticated, parseRollen, parseStoredPrincipal, Principal } from './principal';
|
||||||
|
|
||||||
|
const principal: Principal = {
|
||||||
|
kind: 'medewerker',
|
||||||
|
medewerkerId: 'medewerker-1',
|
||||||
|
naam: 'Test',
|
||||||
|
rollen: ['behandelaar'],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('isAuthenticated', () => {
|
||||||
|
it('narrows a present principal to Principal', () => {
|
||||||
|
expect(isAuthenticated(principal)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no principal as not authenticated', () => {
|
||||||
|
expect(isAuthenticated(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseStoredPrincipal', () => {
|
||||||
|
it('returns null when nothing is stored', () => {
|
||||||
|
expect(parseStoredPrincipal(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for a non-JSON string', () => {
|
||||||
|
expect(parseStoredPrincipal('not json')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the stored shape is wrong (no naam)', () => {
|
||||||
|
expect(
|
||||||
|
parseStoredPrincipal(JSON.stringify({ kind: 'medewerker', medewerkerId: 'medewerker-1' })),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when kind is not medewerker', () => {
|
||||||
|
expect(
|
||||||
|
parseStoredPrincipal(
|
||||||
|
JSON.stringify({
|
||||||
|
kind: 'zorgverlener',
|
||||||
|
medewerkerId: 'medewerker-1',
|
||||||
|
naam: 'Test',
|
||||||
|
rollen: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when rollen holds an unrecognized token', () => {
|
||||||
|
expect(
|
||||||
|
parseStoredPrincipal(
|
||||||
|
JSON.stringify({
|
||||||
|
kind: 'medewerker',
|
||||||
|
medewerkerId: 'medewerker-1',
|
||||||
|
naam: 'Test',
|
||||||
|
rollen: ['geen'],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores a well-shaped stored principal as-is (no BSN to strip)', () => {
|
||||||
|
const restored = parseStoredPrincipal(JSON.stringify(principal));
|
||||||
|
expect(restored).toEqual(principal);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRollen', () => {
|
||||||
|
it('parses a single recognized rol', () => {
|
||||||
|
expect(parseRollen('behandelaar')).toEqual(['behandelaar']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive and trims whitespace', () => {
|
||||||
|
expect(parseRollen(' Behandelaar , behandelaar ')).toEqual(['behandelaar', 'behandelaar']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops unrecognized tokens (the deny-path toggle, e.g. ?rollen=geen)', () => {
|
||||||
|
expect(parseRollen('geen')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty list for an empty string', () => {
|
||||||
|
expect(parseRollen('')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Who is logged in. Framework-free domain type.
|
||||||
|
*
|
||||||
|
* The `medewerker` variant of ADR-0002 §3's `Principal` union — the backoffice has
|
||||||
|
* exactly one actor kind (an employee, authenticated via SSO), so this app's own copy
|
||||||
|
* of the union only ever holds this one member. Unlike the SSP's `zorgverlener`
|
||||||
|
* variant, there is no BSN: a Behandelaar is not a citizen, and §3 names this
|
||||||
|
* unrepresentable-by-construction distinction as the whole point of the union.
|
||||||
|
* `rollen` is the FE-visible echo of the same dev stand-in `medewerker.interceptor.ts`
|
||||||
|
* already stamps onto every backend request — it does not itself grant anything;
|
||||||
|
* `AccessStore`/`GET /me` (server-resolved capabilities) is still the sole authority
|
||||||
|
* on what this principal may do (ADR-0001, ADR-0002 §3).
|
||||||
|
*/
|
||||||
|
export type Rol = 'behandelaar';
|
||||||
|
|
||||||
|
const ROLLEN: readonly Rol[] = ['behandelaar'];
|
||||||
|
export const isRol = (v: unknown): v is Rol => typeof v === 'string' && ROLLEN.includes(v as Rol);
|
||||||
|
|
||||||
|
export interface Principal {
|
||||||
|
readonly kind: 'medewerker';
|
||||||
|
readonly medewerkerId: string;
|
||||||
|
readonly naam: string;
|
||||||
|
readonly rollen: readonly Rol[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthenticated(p: Principal | null): p is Principal {
|
||||||
|
return p !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the raw `X-Rollen` stand-in value (`medewerker.ts`'s `currentRollen()`) into
|
||||||
|
* typed `Rol[]`, mirroring the backend's own `StubIdentityProvider.ParseRollen`:
|
||||||
|
* comma-separated, case-insensitive, unrecognized tokens dropped — so
|
||||||
|
* `?rollen=geen` (the deny-path toggle) yields an empty list here too, rather than
|
||||||
|
* a fabricated recognized role. Pure so `MedewerkerAdapter` (infrastructure) can
|
||||||
|
* stay a thin wire-up instead of holding logic of its own.
|
||||||
|
*/
|
||||||
|
export function parseRollen(raw: string): Rol[] {
|
||||||
|
return raw
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim().toLowerCase())
|
||||||
|
.filter(isRol);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a persisted principal out of a raw `localStorage` string (best-effort;
|
||||||
|
* anything that isn't a well-shaped record → logged out). G2: validate the shape
|
||||||
|
* before trusting it. Unlike the zorgverlener variant there is no G1 field to strip
|
||||||
|
* — a medewerker carries no national identifier — so a well-shaped record is
|
||||||
|
* restored as-is rather than reconstructed field-by-field.
|
||||||
|
*/
|
||||||
|
export function parseStoredPrincipal(raw: string | null): Principal | null {
|
||||||
|
try {
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as Partial<Principal>;
|
||||||
|
return parsed?.kind === 'medewerker' &&
|
||||||
|
typeof parsed.medewerkerId === 'string' &&
|
||||||
|
typeof parsed.naam === 'string' &&
|
||||||
|
Array.isArray(parsed.rollen) &&
|
||||||
|
parsed.rollen.every(isRol)
|
||||||
|
? {
|
||||||
|
kind: 'medewerker',
|
||||||
|
medewerkerId: parsed.medewerkerId,
|
||||||
|
naam: parsed.naam,
|
||||||
|
rollen: parsed.rollen,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { isAuthenticated, Session } from './session';
|
|
||||||
|
|
||||||
const session: Session = { bsn: '19012345601', naam: 'Test' };
|
|
||||||
|
|
||||||
describe('isAuthenticated', () => {
|
|
||||||
it('narrows a present session to Session', () => {
|
|
||||||
expect(isAuthenticated(session)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports no session as not authenticated', () => {
|
|
||||||
expect(isAuthenticated(null)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
/** Who is logged in. Framework-free domain type. */
|
|
||||||
export interface Session {
|
|
||||||
readonly bsn: string;
|
|
||||||
readonly naam: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAuthenticated(s: Session | null): s is Session {
|
|
||||||
return s !== null;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
import { Result, ok } from '@shared/kernel/fp';
|
|
||||||
import { parseBsn } from '@shared/kernel/bsn';
|
|
||||||
import { Session } from '../domain/session';
|
|
||||||
|
|
||||||
/** Infrastructure: talks to the (mock) DigiD identity provider. */
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class DigidAdapter {
|
|
||||||
// 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
|
|
||||||
// for a real OIDC redirect flow when there's an IdP.
|
|
||||||
async authenticate(bsn: string): Promise<Result<string, Session>> {
|
|
||||||
const r = parseBsn(bsn);
|
|
||||||
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Principal, parseRollen } from '../domain/principal';
|
||||||
|
import { MEDEWERKER_ID, currentRollen } from './medewerker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infrastructure: resolves the current medewerker identity into a `Principal`
|
||||||
|
* (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
|
||||||
|
* 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
|
||||||
|
* actually occur. A real SSO callback (which *can* fail — session expired, access
|
||||||
|
* denied) swaps in behind this same method; that is the point where this return
|
||||||
|
* type would gain a `Result`, not before.
|
||||||
|
*
|
||||||
|
* Resolves the same `MEDEWERKER_ID` + `currentRollen()` the dev-only
|
||||||
|
* `medewerkerInterceptor` already stamps onto every backend request as
|
||||||
|
* `X-Medewerker`/`X-Rollen` — this only makes that identity visible on the
|
||||||
|
* frontend (the guard, the header, `SessionStore`'s persisted principal), it does
|
||||||
|
* not change what the backend resolves or authorizes.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MedewerkerAdapter {
|
||||||
|
// ponytail: fake employee SSO — a fixed medewerker, no credential exchange.
|
||||||
|
async authenticate(): Promise<Principal> {
|
||||||
|
return {
|
||||||
|
kind: 'medewerker',
|
||||||
|
medewerkerId: MEDEWERKER_ID,
|
||||||
|
naam: 'H. (Hassan) Bakker',
|
||||||
|
rollen: parseRollen(currentRollen()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +1,27 @@
|
|||||||
import { Component, output } from '@angular/core';
|
import { Component, output } 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 { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
|
||||||
|
|
||||||
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
|
/**
|
||||||
|
* 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
|
||||||
|
* 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
|
||||||
|
* two apps' login flows are meant to look this different.
|
||||||
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-login-form',
|
selector: 'app-login-form',
|
||||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
|
imports: [ButtonComponent],
|
||||||
template: `
|
template: `
|
||||||
<form (ngSubmit)="submitted.emit(bsn)" class="form-horizontal">
|
<div class="form-horizontal">
|
||||||
<div class="form-header">
|
<p i18n="@@login.ssoExplainer">
|
||||||
<div class="form-action">
|
U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig.
|
||||||
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
|
</p>
|
||||||
|
<app-button type="button" variant="primary" (click)="submitted.emit()" i18n="@@login.submit">
|
||||||
|
Inloggen met SSO
|
||||||
|
</app-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<app-form-field
|
|
||||||
i18n-label="@@login.bsnLabel"
|
|
||||||
label="BSN"
|
|
||||||
fieldId="bsn"
|
|
||||||
required
|
|
||||||
i18n-description="@@login.bsnDescription"
|
|
||||||
description="9-cijferig BSN, elfproef-geldig (demo: 123456782)"
|
|
||||||
>
|
|
||||||
<app-text-input
|
|
||||||
inputId="bsn"
|
|
||||||
hasDescription
|
|
||||||
[(ngModel)]="bsn"
|
|
||||||
name="bsn"
|
|
||||||
placeholder="123456782"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
|
|
||||||
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>
|
|
||||||
<app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" />
|
|
||||||
</app-form-field>
|
|
||||||
|
|
||||||
<app-button type="submit" variant="primary" i18n="@@login.submit"
|
|
||||||
>Inloggen met DigiD</app-button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class LoginFormComponent {
|
export class LoginFormComponent {
|
||||||
bsn = '';
|
submitted = output<void>();
|
||||||
password = '';
|
|
||||||
submitted = output<string>();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,35 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject } 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 { 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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No error alert here — unlike the SSP's DigiD form, `SessionStore.login()` has
|
||||||
|
* nothing to fail on (see `MedewerkerAdapter`). A real SSO integration is where
|
||||||
|
* this page would grow one back.
|
||||||
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-login-page',
|
selector: 'app-login-page',
|
||||||
imports: [PageShellComponent, AlertComponent, LoginFormComponent],
|
imports: [PageShellComponent, LoginFormComponent],
|
||||||
template: `
|
template: `
|
||||||
<app-page-shell
|
<app-page-shell
|
||||||
i18n-heading="@@login.heading"
|
i18n-heading="@@login.heading"
|
||||||
heading="Inloggen"
|
heading="Inloggen bij het behandelportal"
|
||||||
width="narrow"
|
width="narrow"
|
||||||
i18n-intro="@@login.intro"
|
i18n-intro="@@login.intro"
|
||||||
intro="Log in op uw persoonlijke BIG-register omgeving."
|
intro="Voor medewerkers die aanvragen beoordelen."
|
||||||
>
|
>
|
||||||
@if (error()) {
|
<app-login-form (submitted)="login()" />
|
||||||
<app-alert type="error">{{ error() }}</app-alert>
|
|
||||||
}
|
|
||||||
<app-login-form (submitted)="login($event)" />
|
|
||||||
</app-page-shell>
|
</app-page-shell>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class LoginPage {
|
export class LoginPage {
|
||||||
private store = inject(SessionStore);
|
private store = inject(SessionStore);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
error = signal('');
|
|
||||||
|
|
||||||
async login(bsn: string) {
|
async login() {
|
||||||
const r = await this.store.login(bsn);
|
await this.store.login();
|
||||||
if (r.ok) this.router.navigate(['/dashboard']);
|
this.router.navigate(['/dashboard']);
|
||||||
else this.error.set(r.error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { expectTag } from '@shared/testing/expect-tag';
|
import { expectTag } from '@shared/testing/expect-tag';
|
||||||
import { BesluitState, reduce, initial } from './besluit.machine';
|
import { reduce, initial } from './besluit.machine';
|
||||||
|
import { givenBesluit } from './besluit.testing';
|
||||||
const editingWith = (besluit: string, toelichting = ''): BesluitState => ({
|
|
||||||
tag: 'Editing',
|
|
||||||
draft: { besluit, toelichting },
|
|
||||||
errors: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('besluit reduce', () => {
|
describe('besluit reduce', () => {
|
||||||
it('SetField updates the draft while editing', () => {
|
it('SetField updates the draft while editing', () => {
|
||||||
@@ -15,17 +10,23 @@ describe('besluit reduce', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Submit with no besluit chosen stays Editing and reports a field error', () => {
|
it('Submit with no besluit chosen stays Editing and reports a field error', () => {
|
||||||
const s = reduce(editingWith(''), { tag: 'Submit' });
|
const s = reduce(initial, { tag: 'Submit' });
|
||||||
expect(expectTag(s, 'Editing').errors.besluit).toBeTruthy();
|
expect(expectTag(s, 'Editing').errors.besluit).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Submit Afwijzen without a toelichting stays Editing and reports a field error', () => {
|
it('Submit Afwijzen without a toelichting stays Editing and reports a field error', () => {
|
||||||
const s = reduce(editingWith('Afwijzen'), { tag: 'Submit' });
|
const editingAfwijzen = givenBesluit({ tag: 'SetField', key: 'besluit', value: 'Afwijzen' });
|
||||||
|
const s = reduce(editingAfwijzen, { tag: 'Submit' });
|
||||||
expect(expectTag(s, 'Editing').errors.toelichting).toBeTruthy();
|
expect(expectTag(s, 'Editing').errors.toelichting).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Submit Goedkeuren with no toelichting moves to Submitting (optional there)', () => {
|
it('Submit Goedkeuren with no toelichting moves to Submitting (optional there)', () => {
|
||||||
const s = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
const editingGoedkeuren = givenBesluit({
|
||||||
|
tag: 'SetField',
|
||||||
|
key: 'besluit',
|
||||||
|
value: 'Goedkeuren',
|
||||||
|
});
|
||||||
|
const s = reduce(editingGoedkeuren, { tag: 'Submit' });
|
||||||
expect(expectTag(s, 'Submitting').data).toEqual({
|
expect(expectTag(s, 'Submitting').data).toEqual({
|
||||||
besluit: 'Goedkeuren',
|
besluit: 'Goedkeuren',
|
||||||
toelichting: undefined,
|
toelichting: undefined,
|
||||||
@@ -33,7 +34,11 @@ describe('besluit reduce', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Submit Afwijzen with a toelichting moves to Submitting with the trimmed value', () => {
|
it('Submit Afwijzen with a toelichting moves to Submitting with the trimmed value', () => {
|
||||||
const s = reduce(editingWith('Afwijzen', ' niet erkend '), { tag: 'Submit' });
|
const editingAfwijzenWithToelichting = givenBesluit(
|
||||||
|
{ tag: 'SetField', key: 'besluit', value: 'Afwijzen' },
|
||||||
|
{ tag: 'SetField', key: 'toelichting', value: ' niet erkend ' },
|
||||||
|
);
|
||||||
|
const s = reduce(editingAfwijzenWithToelichting, { tag: 'Submit' });
|
||||||
expect(expectTag(s, 'Submitting').data).toEqual({
|
expect(expectTag(s, 'Submitting').data).toEqual({
|
||||||
besluit: 'Afwijzen',
|
besluit: 'Afwijzen',
|
||||||
toelichting: 'niet erkend',
|
toelichting: 'niet erkend',
|
||||||
@@ -41,24 +46,36 @@ describe('besluit reduce', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('SubmitConfirmed maps Submitting to Submitted', () => {
|
it('SubmitConfirmed maps Submitting to Submitted', () => {
|
||||||
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
const submitting = givenBesluit(
|
||||||
|
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
|
||||||
|
{ tag: 'Submit' },
|
||||||
|
);
|
||||||
expect(reduce(submitting, { tag: 'SubmitConfirmed' }).tag).toBe('Submitted');
|
expect(reduce(submitting, { tag: 'SubmitConfirmed' }).tag).toBe('Submitted');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('SubmitFailed maps Submitting to Failed with the error', () => {
|
it('SubmitFailed maps Submitting to Failed with the error', () => {
|
||||||
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
const submitting = givenBesluit(
|
||||||
|
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
|
||||||
|
{ tag: 'Submit' },
|
||||||
|
);
|
||||||
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
|
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
|
||||||
expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' });
|
expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Retry re-submits a failure', () => {
|
it('Retry re-submits a failure', () => {
|
||||||
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
const submitting = givenBesluit(
|
||||||
|
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
|
||||||
|
{ tag: 'Submit' },
|
||||||
|
);
|
||||||
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
|
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
|
||||||
expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting');
|
expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Reset returns to the initial editing state', () => {
|
it('Reset returns to the initial editing state', () => {
|
||||||
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
const submitting = givenBesluit(
|
||||||
|
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
|
||||||
|
{ tag: 'Submit' },
|
||||||
|
);
|
||||||
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
|
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { given } from '@shared/testing/machine';
|
||||||
|
import { reduce, initial } from './besluit.machine';
|
||||||
|
|
||||||
|
/** Replay real `BesluitMsg`s through the real `reduce`, starting from `initial`.
|
||||||
|
Pure TS only (no Angular) — domain/ stays framework-free (dependency-cruiser
|
||||||
|
`domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */
|
||||||
|
export const givenBesluit = given(reduce, initial);
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { ApiClient, ApplicationSummaryDto } from '@shared/infrastructure/api-client';
|
import { ApiClient, AanvraagSummaryDto } from '@shared/infrastructure/api-client';
|
||||||
import {
|
import {
|
||||||
WerkvoorraadItem,
|
WerkvoorraadItem,
|
||||||
WerkvoorraadStatus,
|
WerkvoorraadStatus,
|
||||||
@@ -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
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
export class WerkvoorraadAdapter {
|
export class WerkvoorraadAdapter {
|
||||||
private client = inject(ApiClient);
|
private client = inject(ApiClient);
|
||||||
|
|
||||||
list(): Promise<ApplicationSummaryDto[]> {
|
list(): Promise<AanvraagSummaryDto[]> {
|
||||||
return this.client.werkvoorraad();
|
return this.client.werkvoorraad();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ export class WerkvoorraadAdapter {
|
|||||||
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
|
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
|
||||||
|
|
||||||
function parseWerkvoorraadStatus(
|
function parseWerkvoorraadStatus(
|
||||||
s: ApplicationSummaryDto['status'] | undefined,
|
s: AanvraagSummaryDto['status'] | undefined,
|
||||||
): Result<string, WerkvoorraadStatus> {
|
): Result<string, WerkvoorraadStatus> {
|
||||||
if (!s || typeof s.tag !== 'string') return err('werkvoorraad: missing status');
|
if (!s || typeof s.tag !== 'string') return err('werkvoorraad: missing status');
|
||||||
switch (s.tag) {
|
switch (s.tag) {
|
||||||
@@ -44,7 +44,7 @@ function parseWerkvoorraadStatus(
|
|||||||
|
|
||||||
export function parseWerkvoorraadItem(json: unknown): Result<string, WerkvoorraadItem> {
|
export function parseWerkvoorraadItem(json: unknown): Result<string, WerkvoorraadItem> {
|
||||||
if (typeof json !== 'object' || json === null) return err('werkvoorraad: not an object');
|
if (typeof json !== 'object' || json === null) return err('werkvoorraad: not an object');
|
||||||
const dto = json as ApplicationSummaryDto;
|
const dto = json as AanvraagSummaryDto;
|
||||||
if (typeof dto.id !== 'string') return err('werkvoorraad: missing id');
|
if (typeof dto.id !== 'string') return err('werkvoorraad: missing id');
|
||||||
if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type))
|
if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type))
|
||||||
return err(`werkvoorraad: bad type ${dto.type}`);
|
return err(`werkvoorraad: bad type ${dto.type}`);
|
||||||
|
|||||||
+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,18 +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 {
|
||||||
|
RadioGroupComponent,
|
||||||
|
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
|
||||||
@@ -29,10 +34,35 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
|
|||||||
FormFieldComponent,
|
FormFieldComponent,
|
||||||
TextInputComponent,
|
TextInputComponent,
|
||||||
RadioGroupComponent,
|
RadioGroupComponent,
|
||||||
|
DataBlockComponent,
|
||||||
|
DataRowComponent,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
@if (state().tag === 'Submitted') {
|
@if (state().tag === 'Submitted') {
|
||||||
<app-alert type="ok" i18n="@@besluit.success">Het besluit is vastgelegd.</app-alert>
|
<app-alert type="ok" i18n="@@besluit.success">Het besluit is vastgelegd.</app-alert>
|
||||||
|
} @else if (state().tag === 'Failed') {
|
||||||
|
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
|
||||||
|
|
||||||
|
<app-alert type="error"
|
||||||
|
><ng-container i18n="@@besluit.failed">Het vastleggen is niet gelukt:</ng-container>
|
||||||
|
{{ failedError() }}</app-alert
|
||||||
|
>
|
||||||
|
|
||||||
|
<app-data-block class="app-section" [ariaLabel]="besluitLabelText">
|
||||||
|
<div app-data-row [key]="besluitLabelText" [value]="besluitOptieLabel()"></div>
|
||||||
|
@if (toelichting()) {
|
||||||
|
<div app-data-row [key]="toelichtingLabelText" [value]="toelichting()"></div>
|
||||||
|
}
|
||||||
|
</app-data-block>
|
||||||
|
|
||||||
|
<div class="app-section">
|
||||||
|
<app-button
|
||||||
|
variant="secondary"
|
||||||
|
(click)="dispatch({ tag: 'Retry' })"
|
||||||
|
i18n="@@wizard.opnieuwProberen"
|
||||||
|
>Opnieuw proberen</app-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
|
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
|
||||||
|
|
||||||
@@ -70,13 +100,6 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
|
|||||||
/>
|
/>
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
|
|
||||||
@if (failedError()) {
|
|
||||||
<app-alert type="error"
|
|
||||||
><ng-container i18n="@@besluit.failed">Het vastleggen is niet gelukt:</ng-container>
|
|
||||||
{{ failedError() }}</app-alert
|
|
||||||
>
|
|
||||||
}
|
|
||||||
|
|
||||||
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
|
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
|
||||||
{{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}
|
{{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}
|
||||||
</app-button>
|
</app-button>
|
||||||
@@ -86,7 +109,19 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
|
|||||||
})
|
})
|
||||||
export class BesluitFormComponent {
|
export class BesluitFormComponent {
|
||||||
private submit = createSubmitBesluit();
|
private submit = createSubmitBesluit();
|
||||||
private store = createStore<BesluitState, BesluitMsg>(initial, reduce);
|
// 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).
|
||||||
|
private store = createStore<BesluitState, BesluitMsg>(initial, reduce, {
|
||||||
|
Submitting: async (s, store) => {
|
||||||
|
const r = await this.submit(this.id(), s.data);
|
||||||
|
if (r.ok) {
|
||||||
|
store.dispatch({ tag: 'SubmitConfirmed' });
|
||||||
|
this.decided.emit();
|
||||||
|
} else {
|
||||||
|
store.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
id = input.required<string>();
|
id = input.required<string>();
|
||||||
decided = output<void>();
|
decided = output<void>();
|
||||||
@@ -109,12 +144,33 @@ export class BesluitFormComponent {
|
|||||||
protected readonly submitLabel = $localize`:@@besluit.submit:Besluit vastleggen`;
|
protected readonly submitLabel = $localize`:@@besluit.submit:Besluit vastleggen`;
|
||||||
protected readonly submitBezigLabel = $localize`:@@besluit.submitBezig:Bezig met vastleggen…`;
|
protected readonly submitBezigLabel = $localize`:@@besluit.submitBezig:Bezig met vastleggen…`;
|
||||||
|
|
||||||
|
// Same ids as the form-field labels above, reused for the Failed data-block's row
|
||||||
|
// keys (the pattern change-request-form already uses for its read-only BRP rows).
|
||||||
|
protected readonly besluitLabelText = $localize`:@@besluit.besluitLabel:Besluit`;
|
||||||
|
protected readonly toelichtingLabelText = $localize`:@@besluit.toelichtingLabel:Toelichting`;
|
||||||
|
|
||||||
private editing = computed(() => whenTag(this.state(), 'Editing'));
|
private editing = computed(() => whenTag(this.state(), 'Editing'));
|
||||||
protected errors = computed(() => this.editing()?.errors ?? {});
|
protected errors = computed(() => this.editing()?.errors ?? {});
|
||||||
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
||||||
|
|
||||||
protected besluit = computed(() => this.editing()?.draft.besluit ?? '');
|
/** The value shown in the field — the live draft while editing, the parsed value
|
||||||
protected toelichting = computed(() => this.editing()?.draft.toelichting ?? '');
|
while submitting/failed (so the user sees what they sent, same idiom as
|
||||||
|
change-request-form.telefoon()). */
|
||||||
|
protected besluit = computed(() => {
|
||||||
|
const s = this.state();
|
||||||
|
if (s.tag === 'Editing') return s.draft.besluit;
|
||||||
|
if (s.tag === 'Submitting' || s.tag === 'Failed') return s.data.besluit;
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
protected toelichting = computed(() => {
|
||||||
|
const s = this.state();
|
||||||
|
if (s.tag === 'Editing') return s.draft.toelichting;
|
||||||
|
if (s.tag === 'Submitting' || s.tag === 'Failed') return s.data.toelichting ?? '';
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
protected besluitOptieLabel = computed(
|
||||||
|
() => this.BESLUIT_OPTIONS.find((o) => o.value === this.besluit())?.label ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
|
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
|
||||||
@@ -122,19 +178,5 @@ export class BesluitFormComponent {
|
|||||||
|
|
||||||
onSubmit() {
|
onSubmit() {
|
||||||
this.dispatch({ tag: 'Submit' });
|
this.dispatch({ tag: 'Submit' });
|
||||||
this.runIfSubmitting();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Effect: when we entered Submitting, call the command, then dispatch the outcome. */
|
|
||||||
private async runIfSubmitting() {
|
|
||||||
const s = this.state();
|
|
||||||
if (s.tag !== 'Submitting') return;
|
|
||||||
const r = await this.submit(this.id(), s.data);
|
|
||||||
if (r.ok) {
|
|
||||||
this.dispatch({ tag: 'SubmitConfirmed' });
|
|
||||||
this.decided.emit();
|
|
||||||
} else {
|
|
||||||
this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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`,
|
||||||
|
|||||||
@@ -26,65 +26,33 @@
|
|||||||
<context context-type="linenumber">27</context>
|
<context context-type="linenumber">27</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="form.verplichteVelden" datatype="html">
|
<trans-unit id="login.ssoExplainer" datatype="html">
|
||||||
<source>* verplichte velden</source>
|
<source>U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig.</source>
|
||||||
<target datatype="html">* required fields</target>
|
<target datatype="html">You sign in through your organization's SSO — no password is needed.</target>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||||
<context context-type="linenumber">15,18</context>
|
<context context-type="linenumber">17,19</context>
|
||||||
</context-group>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/registratie/ui/change-request-form/change-request-form.component.ts</context>
|
|
||||||
<context context-type="linenumber">44,46</context>
|
|
||||||
</context-group>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/shared/layout/wizard-shell/wizard-shell.component.ts</context>
|
|
||||||
<context context-type="linenumber">90,92</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="login.bsnLabel" datatype="html">
|
|
||||||
<source>BSN</source>
|
|
||||||
<target datatype="html">BSN</target>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
|
||||||
<context context-type="linenumber">22,23</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="login.bsnDescription" datatype="html">
|
|
||||||
<source>9-cijferig BSN, elfproef-geldig (demo: 123456782)</source>
|
|
||||||
<target datatype="html">9-digit BSN, valid eleven-test checksum (demo: 123456782)</target>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
|
||||||
<context context-type="linenumber">25,28</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="login.wachtwoordLabel" datatype="html">
|
|
||||||
<source>Wachtwoord</source>
|
|
||||||
<target datatype="html">Password</target>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
|
||||||
<context context-type="linenumber">36,37</context>
|
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="login.submit" datatype="html">
|
<trans-unit id="login.submit" datatype="html">
|
||||||
<source>Inloggen met DigiD</source>
|
<source>Inloggen met SSO</source>
|
||||||
<target datatype="html">Log in with DigiD</target>
|
<target datatype="html">Log in with SSO</target>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||||
<context context-type="linenumber">41,43</context>
|
<context context-type="linenumber">20,21</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="login.heading" datatype="html">
|
<trans-unit id="login.heading" datatype="html">
|
||||||
<source>Inloggen</source>
|
<source>Inloggen bij het behandelportal</source>
|
||||||
<target datatype="html">Log in</target>
|
<target datatype="html">Log in to the treatment portal</target>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/auth/ui/login.page.ts</context>
|
<context context-type="sourcefile">src/app/auth/ui/login.page.ts</context>
|
||||||
<context context-type="linenumber">14,16</context>
|
<context context-type="linenumber">14,16</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="login.intro" datatype="html">
|
<trans-unit id="login.intro" datatype="html">
|
||||||
<source>Log in op uw persoonlijke BIG-register omgeving.</source>
|
<source>Voor medewerkers die aanvragen beoordelen.</source>
|
||||||
<target datatype="html">Log in to your personal BIG register environment.</target>
|
<target datatype="html">For staff who assess applications.</target>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/auth/ui/login.page.ts</context>
|
<context context-type="sourcefile">src/app/auth/ui/login.page.ts</context>
|
||||||
<context context-type="linenumber">17,19</context>
|
<context context-type="linenumber">17,19</context>
|
||||||
@@ -3878,6 +3846,10 @@
|
|||||||
<source>De functievlaggen konden niet worden geladen.</source>
|
<source>De functievlaggen konden niet worden geladen.</source>
|
||||||
<target datatype="html">The feature flags could not be loaded.</target>
|
<target datatype="html">The feature flags could not be loaded.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="flags.set.failed" datatype="html">
|
||||||
|
<source>De functievlag kon niet worden opgeslagen.</source>
|
||||||
|
<target datatype="html">The feature flag could not be saved.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="flags.retry" datatype="html">
|
<trans-unit id="flags.retry" datatype="html">
|
||||||
<source>Opnieuw proberen</source>
|
<source>Opnieuw proberen</source>
|
||||||
<target datatype="html">Try again</target>
|
<target datatype="html">Try again</target>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import localeEn from '@angular/common/locales/en';
|
|||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
|
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
|
||||||
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
|
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
|
||||||
|
import { subjectInterceptor } from '@shared/infrastructure/subject.interceptor';
|
||||||
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||||
import { SESSION_PORT } from '@shared/application/session.port';
|
import { SESSION_PORT } from '@shared/application/session.port';
|
||||||
import { SessionStore } from '@auth/application/session.store';
|
import { SessionStore } from '@auth/application/session.store';
|
||||||
@@ -54,7 +55,11 @@ export const appConfig: ApplicationConfig = {
|
|||||||
),
|
),
|
||||||
// Dev-only: the ?scenario= toggle must never reach a production build, where
|
// Dev-only: the ?scenario= toggle must never reach a production build, where
|
||||||
// a query param could otherwise force errors on the live app.
|
// a query param could otherwise force errors on the live app.
|
||||||
provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor, roleInterceptor] : [])),
|
provideHttpClient(
|
||||||
|
withInterceptors(
|
||||||
|
isDevMode() ? [scenarioInterceptor, roleInterceptor, subjectInterceptor] : [],
|
||||||
|
),
|
||||||
|
),
|
||||||
provideApiClient(),
|
provideApiClient(),
|
||||||
{ provide: SESSION_PORT, useExisting: SessionStore },
|
{ provide: SESSION_PORT, useExisting: SessionStore },
|
||||||
// Per-bundle locale: the localize build sets `$localize.locale` ('nl'/'en'); the
|
// Per-bundle locale: the localize build sets `$localize.locale` ('nl'/'en'); the
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ export const routes: Routes = [
|
|||||||
loadComponent: () => import('@auth/ui/login.page').then((m) => m.LoginPage),
|
loadComponent: () => import('@auth/ui/login.page').then((m) => m.LoginPage),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Path stays 'dashboard' on purpose: it is a user-visible URL and four e2e
|
||||||
|
// specs assert it. The context is `overzicht`; only the path string differs.
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
loadComponent: () => import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage),
|
loadComponent: () => import('@overzicht/ui/overzicht.page').then((m) => m.OverzichtPage),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'registratie',
|
path: 'registratie',
|
||||||
@@ -59,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')],
|
||||||
@@ -76,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.
|
||||||
@@ -86,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),
|
||||||
|
|||||||
@@ -1,55 +1,50 @@
|
|||||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||||
import { Result } from '@shared/kernel/fp';
|
import { Result } from '@shared/kernel/fp';
|
||||||
import { Session } from '../domain/session';
|
import { Principal, parseStoredPrincipal } from '../domain/principal';
|
||||||
import { DigidAdapter } from '../infrastructure/digid.adapter';
|
import { DigidAdapter } from '../infrastructure/digid.adapter';
|
||||||
|
|
||||||
const STORAGE_KEY = 'session-v1';
|
const STORAGE_KEY = 'session-v1';
|
||||||
|
|
||||||
/** Restore a persisted session (best-effort; corrupt entry → logged out).
|
/** Restore a persisted principal (best-effort; corrupt entry → logged out).
|
||||||
G2: validate the shape before trusting it. G1: the BSN is never persisted
|
The parse + shape validation (G1/G2) lives in `parseStoredPrincipal`
|
||||||
(see the effect below), so a restored session carries an empty one — it is
|
(`../domain/principal`) — pure, spec'd, and testable without stubbing
|
||||||
unused after login; only `naam` is shown in the chrome. */
|
`localStorage`; this just supplies the raw value. */
|
||||||
function restore(): Session | null {
|
function restore(): Principal | null {
|
||||||
try {
|
return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
const parsed = JSON.parse(raw) as Partial<Session>;
|
|
||||||
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the current session for the whole app. Because it is providedIn:'root'
|
* Holds the current zorgverlener principal for the whole SSP. One
|
||||||
* there is exactly one instance — every component that injects it sees the same
|
* `providedIn: 'root'` instance, so logging in is instantly visible everywhere
|
||||||
* session signal, so logging in is instantly visible everywhere (the guard, the
|
* (the guard, the header). Persisted to localStorage — a refresh or the
|
||||||
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link,
|
* cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
|
||||||
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`,
|
* but never the BSN itself (G1 in the `effect` below): this principal carries a
|
||||||
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage —
|
* citizen's national identifier, which the behandelportal's equivalent store does
|
||||||
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language
|
* not have to guard against, because its `medewerker` principal has no BSN.
|
||||||
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
|
* ponytail: localStorage, not sessionStorage — sessionStorage's per-tab clearing
|
||||||
* in an httpOnly cookie/token, not web storage.
|
* dropped the login on the cross-bundle language switch. Trade-off: the demo
|
||||||
|
* session now survives tab close; a real portal keeps auth in an httpOnly
|
||||||
|
* cookie/token, not web storage.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SessionStore {
|
export class SessionStore {
|
||||||
private digid = inject(DigidAdapter);
|
private digid = inject(DigidAdapter);
|
||||||
private _session = signal<Session | null>(restore());
|
private _session = signal<Principal | null>(restore());
|
||||||
|
|
||||||
readonly session = this._session.asReadonly();
|
readonly session = this._session.asReadonly();
|
||||||
readonly isAuthenticated = computed(() => this._session() !== null);
|
readonly isAuthenticated = computed(() => this._session() !== null);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const s = this._session();
|
const p = this._session();
|
||||||
// G1: persist only `naam` — never write the BSN (national ID) to storage.
|
// G1: persist only `naam` — never write the BSN (national ID) to storage.
|
||||||
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
|
if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: p.naam }));
|
||||||
else localStorage.removeItem(STORAGE_KEY);
|
else localStorage.removeItem(STORAGE_KEY);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Effectful command: authenticate, then store the session on success. */
|
/** Effectful command: authenticate, then store the principal on success. */
|
||||||
async login(bsn: string): Promise<Result<string, Session>> {
|
async login(bsn: string): Promise<Result<string, Principal>> {
|
||||||
const r = await this.digid.authenticate(bsn);
|
const r = await this.digid.authenticate(bsn);
|
||||||
if (r.ok) this._session.set(r.value);
|
if (r.ok) this._session.set(r.value);
|
||||||
return r;
|
return r;
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { Router } from '@angular/router';
|
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
import { AccessStore } from '@shared/application/access.store';
|
|
||||||
import { SessionStore } from './application/session.store';
|
|
||||||
import { authGuard, capabilityGuard } from './auth.guard';
|
|
||||||
|
|
||||||
type Opts = {
|
|
||||||
authed: boolean;
|
|
||||||
can?: (c: string) => boolean;
|
|
||||||
whenReady?: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function setup({ authed, can = () => false, whenReady = () => Promise.resolve() }: Opts) {
|
|
||||||
const createUrlTree = vi.fn((cmds: string[]) => ({ tree: cmds }));
|
|
||||||
const readySpy = vi.fn(whenReady);
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
providers: [
|
|
||||||
{ provide: SessionStore, useValue: { isAuthenticated: () => authed } },
|
|
||||||
{ provide: AccessStore, useValue: { whenReady: readySpy, can } },
|
|
||||||
{ provide: Router, useValue: { createUrlTree } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return { createUrlTree, readySpy };
|
|
||||||
}
|
|
||||||
|
|
||||||
// The guards ignore their (route, state) args; cast to call with none.
|
|
||||||
const call = <T>(fn: unknown) => TestBed.runInInjectionContext(() => (fn as () => T)());
|
|
||||||
|
|
||||||
describe('authGuard', () => {
|
|
||||||
it('allows an authenticated user', () => {
|
|
||||||
setup({ authed: true });
|
|
||||||
expect(call(authGuard)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redirects an anonymous user to /login', () => {
|
|
||||||
const { createUrlTree } = setup({ authed: false });
|
|
||||||
expect(call(authGuard)).toEqual({ tree: ['/login'] });
|
|
||||||
expect(createUrlTree).toHaveBeenCalledWith(['/login']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('capabilityGuard', () => {
|
|
||||||
const guard = () => capabilityGuard('stamdata:edit');
|
|
||||||
|
|
||||||
it('waits for /me, then allows an entitled admin', async () => {
|
|
||||||
const { readySpy } = setup({ authed: true, can: (c) => c === 'stamdata:edit' });
|
|
||||||
await expect(call<Promise<unknown>>(guard())).resolves.toBe(true);
|
|
||||||
expect(readySpy).toHaveBeenCalledOnce(); // it awaited caps before deciding
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends an authenticated-but-unentitled user to /dashboard (not a login loop)', async () => {
|
|
||||||
setup({ authed: true, can: () => false });
|
|
||||||
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/dashboard'] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redirects an anonymous user to /login without waiting for caps', async () => {
|
|
||||||
const { readySpy } = setup({ authed: false, can: () => true });
|
|
||||||
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/login'] });
|
|
||||||
expect(readySpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,34 +1,10 @@
|
|||||||
import { inject } from '@angular/core';
|
|
||||||
import { CanActivateFn, Router } from '@angular/router';
|
|
||||||
import { AccessStore } from '@shared/application/access.store';
|
|
||||||
import { Capability } from '@shared/domain/capability';
|
|
||||||
import { SessionStore } from './application/session.store';
|
|
||||||
|
|
||||||
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
|
|
||||||
export const authGuard: CanActivateFn = () => {
|
|
||||||
const store = inject(SessionStore);
|
|
||||||
const router = inject(Router);
|
|
||||||
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
|
* The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading
|
||||||
* redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`).
|
* only `SESSION_PORT` and `AccessStore`, so both apps share one copy and one spec.
|
||||||
|
* Re-exported here so `app.routes.ts` keeps importing them from `@auth/auth.guard`:
|
||||||
|
* routing asks the auth context for its guards, which is the right direction to read.
|
||||||
*
|
*
|
||||||
* **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me`
|
* ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes —
|
||||||
* is still loading — it would deny an entitled admin and bounce them. We await
|
* `Principal`, the login flow, `SessionStore`. A guard is neither.
|
||||||
* `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user
|
|
||||||
* goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're
|
|
||||||
* logged in, just not allowed here — no re-login loop). The backend re-enforces
|
|
||||||
* regardless (403); this guard is the UX pre-gate.
|
|
||||||
*/
|
*/
|
||||||
export function capabilityGuard(capability: Capability): CanActivateFn {
|
export { authGuard, capabilityGuard } from '@shared/application/auth.guard';
|
||||||
return async () => {
|
|
||||||
const session = inject(SessionStore);
|
|
||||||
const access = inject(AccessStore);
|
|
||||||
const router = inject(Router);
|
|
||||||
if (!session.isAuthenticated()) return router.createUrlTree(['/login']);
|
|
||||||
await access.whenReady();
|
|
||||||
return access.can(capability) ? true : router.createUrlTree(['/dashboard']);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { isAuthenticated, parseStoredPrincipal, Principal } from './principal';
|
||||||
|
|
||||||
|
const principal: Principal = { kind: 'zorgverlener', bsn: '19012345601', naam: 'Test' };
|
||||||
|
|
||||||
|
describe('isAuthenticated', () => {
|
||||||
|
it('narrows a present principal to Principal', () => {
|
||||||
|
expect(isAuthenticated(principal)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no principal as not authenticated', () => {
|
||||||
|
expect(isAuthenticated(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseStoredPrincipal', () => {
|
||||||
|
it('returns null when nothing is stored', () => {
|
||||||
|
expect(parseStoredPrincipal(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for a non-JSON string', () => {
|
||||||
|
expect(parseStoredPrincipal('not json')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the stored shape is wrong (no naam)', () => {
|
||||||
|
expect(parseStoredPrincipal(JSON.stringify({ bsn: '19012345601' }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('G1: a stored bsn is never restored, even if present in the raw value', () => {
|
||||||
|
const restored = parseStoredPrincipal(JSON.stringify({ bsn: '19012345601', naam: 'Test' }));
|
||||||
|
expect(restored).toEqual({ kind: 'zorgverlener', bsn: '', naam: 'Test' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Who is logged in. Framework-free domain type.
|
||||||
|
*
|
||||||
|
* The `zorgverlener` variant of ADR-0002 §3's `Principal` union — the SSP has exactly
|
||||||
|
* one actor kind (a citizen, authenticated via DigiD/BSN), so this app's own copy of
|
||||||
|
* the union only ever holds this one member. `kind` is still a discriminant, not
|
||||||
|
* decoration: it is what makes `apps/behandelportal`'s `medewerker` variant a
|
||||||
|
* genuinely different type rather than a same-shaped coincidence, and what a future
|
||||||
|
* third actor (§4 — admin/auditor/institution-rep) would add a member to.
|
||||||
|
*/
|
||||||
|
export interface Principal {
|
||||||
|
readonly kind: 'zorgverlener';
|
||||||
|
readonly bsn: string;
|
||||||
|
readonly naam: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthenticated(p: Principal | null): p is Principal {
|
||||||
|
return p !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a persisted principal out of a raw `localStorage` string (best-effort;
|
||||||
|
* anything that isn't a well-shaped record → logged out). G2: validate the
|
||||||
|
* shape before trusting it. G1: even if a stored entry carries a `bsn`, the
|
||||||
|
* restored principal's `bsn` is always `''` — the BSN is never persisted (see
|
||||||
|
* the `SessionStore` effect that writes it), so a legacy or tampered entry
|
||||||
|
* cannot resurrect one.
|
||||||
|
*/
|
||||||
|
export function parseStoredPrincipal(raw: string | null): Principal | null {
|
||||||
|
try {
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as Partial<Principal>;
|
||||||
|
return typeof parsed?.naam === 'string'
|
||||||
|
? { kind: 'zorgverlener', bsn: '', naam: parsed.naam }
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { isAuthenticated, Session } from './session';
|
|
||||||
|
|
||||||
const session: Session = { bsn: '19012345601', naam: 'Test' };
|
|
||||||
|
|
||||||
describe('isAuthenticated', () => {
|
|
||||||
it('narrows a present session to Session', () => {
|
|
||||||
expect(isAuthenticated(session)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports no session as not authenticated', () => {
|
|
||||||
expect(isAuthenticated(null)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
/** Who is logged in. Framework-free domain type. */
|
|
||||||
export interface Session {
|
|
||||||
readonly bsn: string;
|
|
||||||
readonly naam: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAuthenticated(s: Session | null): s is Session {
|
|
||||||
return s !== null;
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Result, ok } from '@shared/kernel/fp';
|
import { Result, ok } from '@shared/kernel/fp';
|
||||||
import { parseBsn } from '@shared/kernel/bsn';
|
import { parseBsn } from '@shared/kernel/bsn';
|
||||||
import { Session } from '../domain/session';
|
import { Principal } from '../domain/principal';
|
||||||
|
|
||||||
/** Infrastructure: talks to the (mock) DigiD identity provider. */
|
/** Infrastructure: talks to the (mock) DigiD identity provider. */
|
||||||
@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, Session>> {
|
async authenticate(bsn: string): Promise<Result<string, Principal>> {
|
||||||
const r = parseBsn(bsn);
|
const r = parseBsn(bsn);
|
||||||
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
|
return r.ok ? ok({ kind: 'zorgverlener', bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { Result } from '@shared/kernel/fp';
|
import { Result } from '@shared/kernel/fp';
|
||||||
|
import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter';
|
||||||
import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief';
|
import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief';
|
||||||
import { OrgTemplate } from '@brief/domain/org-template';
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
import {
|
||||||
|
BRIEF_LOAD_FAILED,
|
||||||
|
BriefAdapter,
|
||||||
|
BriefLoadFailure,
|
||||||
|
BriefView,
|
||||||
|
} from '@brief/infrastructure/brief.adapter';
|
||||||
import { LetterPreviewAdapter, PREVIEW_FAILED } from '@brief/infrastructure/letter-preview.adapter';
|
import { LetterPreviewAdapter, PREVIEW_FAILED } from '@brief/infrastructure/letter-preview.adapter';
|
||||||
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
|
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
|
||||||
import { BriefStore } from './brief.store';
|
import { BriefStore } from './brief.store';
|
||||||
@@ -48,8 +54,26 @@ const caseContext: CaseContext = {
|
|||||||
|
|
||||||
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
|
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
|
||||||
|
|
||||||
function setup(adapter: Partial<BriefAdapter>): BriefStore {
|
/** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of
|
||||||
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
|
touching the DOM, so a spec can assert a command's success path directly. */
|
||||||
|
function fakeBlobPresenter() {
|
||||||
|
const opened: Blob[] = [];
|
||||||
|
const presenter: BlobPresenter = {
|
||||||
|
open: (blob) => opened.push(blob),
|
||||||
|
download: () => {
|
||||||
|
throw new Error('not used by BriefStore');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { presenter, opened };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(adapter: Partial<BriefAdapter>, blobPresenter?: BlobPresenter): BriefStore {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
{ provide: BriefAdapter, useValue: adapter },
|
||||||
|
...(blobPresenter ? [{ provide: BLOB_PRESENTER, useValue: blobPresenter }] : []),
|
||||||
|
],
|
||||||
|
});
|
||||||
return TestBed.inject(BriefStore);
|
return TestBed.inject(BriefStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +84,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
|||||||
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
||||||
};
|
};
|
||||||
const store = setup({
|
const store = setup({
|
||||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
||||||
|
Promise.resolve({ ok: true, value: view }),
|
||||||
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||||
approve: (): Promise<Result<string, BriefView>> =>
|
approve: (): Promise<Result<string, BriefView>> =>
|
||||||
Promise.resolve({ ok: true, value: approved }),
|
Promise.resolve({ ok: true, value: approved }),
|
||||||
@@ -79,7 +104,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
|||||||
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
||||||
};
|
};
|
||||||
const store = setup({
|
const store = setup({
|
||||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
||||||
|
Promise.resolve({ ok: true, value: view }),
|
||||||
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||||
approve: (): Promise<Result<string, BriefView>> =>
|
approve: (): Promise<Result<string, BriefView>> =>
|
||||||
Promise.resolve({ ok: true, value: approved }),
|
Promise.resolve({ ok: true, value: approved }),
|
||||||
@@ -93,7 +119,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
|||||||
|
|
||||||
it('goes Busy then Failed on a failing transition, surfacing the error', async () => {
|
it('goes Busy then Failed on a failing transition, surfacing the error', async () => {
|
||||||
const store = setup({
|
const store = setup({
|
||||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
||||||
|
Promise.resolve({ ok: true, value: view }),
|
||||||
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||||
approve: (): Promise<Result<string, BriefView>> =>
|
approve: (): Promise<Result<string, BriefView>> =>
|
||||||
Promise.resolve({ ok: false, error: 'niet toegestaan' }),
|
Promise.resolve({ ok: false, error: 'niet toegestaan' }),
|
||||||
@@ -108,7 +135,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
|||||||
it('a subsequent successful transition clears a prior Failed state', async () => {
|
it('a subsequent successful transition clears a prior Failed state', async () => {
|
||||||
let approveResult: Result<string, BriefView> = { ok: false, error: 'eerste poging mislukt' };
|
let approveResult: Result<string, BriefView> = { ok: false, error: 'eerste poging mislukt' };
|
||||||
const store = setup({
|
const store = setup({
|
||||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
||||||
|
Promise.resolve({ ok: true, value: view }),
|
||||||
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||||
approve: (): Promise<Result<string, BriefView>> => Promise.resolve(approveResult),
|
approve: (): Promise<Result<string, BriefView>> => Promise.resolve(approveResult),
|
||||||
});
|
});
|
||||||
@@ -130,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 {
|
||||||
@@ -151,13 +179,15 @@ const filledView: BriefView = { ...view, brief: filledBrief };
|
|||||||
|
|
||||||
function loadedBrief(store: BriefStore): Brief {
|
function loadedBrief(store: BriefStore): Brief {
|
||||||
const s = store.model();
|
const s = store.model();
|
||||||
if (s.tag !== 'loaded') throw new Error('not loaded');
|
if (s.tag !== 'Loaded') throw new Error('not loaded');
|
||||||
return s.brief;
|
return s.brief;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> {
|
async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> {
|
||||||
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
|
// Untyped return (inferred as the narrow `{ ok: true; value }` literal) so this one
|
||||||
Promise.resolve({ ok: true, value: v });
|
// helper satisfies both `load` (error channel `BriefLoadFailure`) and `save` (error
|
||||||
|
// channel `string`) — it only ever produces the `ok: true` branch.
|
||||||
|
const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
|
||||||
const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over });
|
const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over });
|
||||||
await store.load();
|
await store.load();
|
||||||
return store;
|
return store;
|
||||||
@@ -255,8 +285,7 @@ describe('BriefStore rejection diff', () => {
|
|||||||
...filledBrief,
|
...filledBrief,
|
||||||
status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' },
|
status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' },
|
||||||
};
|
};
|
||||||
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
|
const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
|
||||||
Promise.resolve({ ok: true, value: v });
|
|
||||||
const store = setup({
|
const store = setup({
|
||||||
load: () => ok({ ...filledView, brief: submitted }),
|
load: () => ok({ ...filledView, brief: submitted }),
|
||||||
save: () => ok(filledView),
|
save: () => ok(filledView),
|
||||||
@@ -277,41 +306,46 @@ describe('BriefStore rejection diff', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('BriefStore.previewLetter', () => {
|
describe('BriefStore.previewLetter', () => {
|
||||||
// vi.spyOn reuses an existing spy (and its call history) if one is already on
|
|
||||||
// the property — window.open/URL.createObjectURL must be restored between tests.
|
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
it('opens the composed letter in a new tab on success', async () => {
|
it('opens the composed letter via BLOB_PRESENTER on success', async () => {
|
||||||
const store = setup({
|
const { presenter, opened } = fakeBlobPresenter();
|
||||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
const store = setup(
|
||||||
});
|
{
|
||||||
|
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
||||||
|
Promise.resolve({ ok: true, value: view }),
|
||||||
|
},
|
||||||
|
presenter,
|
||||||
|
);
|
||||||
await store.load();
|
await store.load();
|
||||||
const blob = new Blob(['<html></html>'], { type: 'text/html' });
|
const blob = new Blob(['<html></html>'], { type: 'text/html' });
|
||||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
|
||||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
|
||||||
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: blob,
|
value: blob,
|
||||||
});
|
});
|
||||||
|
|
||||||
await store.previewLetter();
|
await store.previewLetter();
|
||||||
expect(open).toHaveBeenCalledWith('blob:mock', '_blank');
|
expect(opened).toEqual([blob]);
|
||||||
expect(store.lastError()).toBeNull();
|
expect(store.lastError()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('surfaces the error without opening a tab on failure', async () => {
|
it('surfaces the error without opening a tab on failure', async () => {
|
||||||
const store = setup({
|
const { presenter, opened } = fakeBlobPresenter();
|
||||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
const store = setup(
|
||||||
});
|
{
|
||||||
|
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
||||||
|
Promise.resolve({ ok: true, value: view }),
|
||||||
|
},
|
||||||
|
presenter,
|
||||||
|
);
|
||||||
await store.load();
|
await store.load();
|
||||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
|
||||||
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: PREVIEW_FAILED,
|
error: PREVIEW_FAILED,
|
||||||
});
|
});
|
||||||
|
|
||||||
await store.previewLetter();
|
await store.previewLetter();
|
||||||
expect(open).not.toHaveBeenCalled();
|
expect(opened).toHaveLength(0);
|
||||||
expect(store.lastError()).toBe(PREVIEW_FAILED);
|
expect(store.lastError()).toBe(PREVIEW_FAILED);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -377,3 +411,42 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
|
|||||||
expect(save).not.toHaveBeenCalled();
|
expect(save).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- CQ-007's expand half: a 404 from GET /brief tolerates by calling the
|
||||||
|
// existing reset() command, exactly once. Today's backend never 404s yet;
|
||||||
|
// this fake adapter is what exercises the branch until then. ---
|
||||||
|
|
||||||
|
describe('BriefStore.load — 404 tolerance', () => {
|
||||||
|
const notFound: Result<BriefLoadFailure, BriefView> = { ok: false, error: { tag: 'notFound' } };
|
||||||
|
const resetOk: Result<string, BriefView> = { ok: true, value: view };
|
||||||
|
|
||||||
|
it('a 404 drives exactly one reset(), which populates the store', async () => {
|
||||||
|
// Given GET /brief 404s (no brief exists yet) and reset() succeeds.
|
||||||
|
const load = vi.fn(() => Promise.resolve(notFound));
|
||||||
|
const reset = vi.fn(() => Promise.resolve(resetOk));
|
||||||
|
const store = setup({ load, reset });
|
||||||
|
|
||||||
|
// When the store loads...
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
// Then reset() ran exactly once, and the store ends up loaded from its result.
|
||||||
|
expect(reset).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.model().tag).toBe('Loaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a second 404 does not drive a second reset()', async () => {
|
||||||
|
// Given every load() attempt 404s (e.g. the brief still fails to appear).
|
||||||
|
const load = vi.fn(() => Promise.resolve(notFound));
|
||||||
|
const reset = vi.fn(() => Promise.resolve(resetOk));
|
||||||
|
const store = setup({ load, reset });
|
||||||
|
|
||||||
|
// When the store loads twice...
|
||||||
|
await store.load();
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
// Then reset() ran exactly once — the once-only bound holds across calls, not
|
||||||
|
// just within one — and the second 404 surfaces as an ordinary load failure.
|
||||||
|
expect(reset).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.model()).toEqual({ tag: 'Failed', reason: BRIEF_LOAD_FAILED });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { Result } from '@shared/kernel/fp';
|
import { Result } from '@shared/kernel/fp';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
import { ActionState, SaveState } from '@shared/application/action-state';
|
|
||||||
import { createHistory } from '@shared/application/history';
|
import { createHistory } from '@shared/application/history';
|
||||||
import { createDebouncedSave } from '@shared/application/debounced-save';
|
import { SaveState, createDebouncedSave } from '@shared/application/debounced-save';
|
||||||
import { machineRemoteData } from '@shared/application/machine-remote-data';
|
import { fromLoadLifecycle } from '@shared/application/remote-data';
|
||||||
import {
|
import {
|
||||||
Brief,
|
Brief,
|
||||||
CaseContext,
|
CaseContext,
|
||||||
@@ -16,11 +15,12 @@ import {
|
|||||||
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
||||||
import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';
|
import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';
|
||||||
import { OrgTemplate } from '@brief/domain/org-template';
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
import { BRIEF_LOAD_FAILED, BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||||
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||||
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
|
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
|
||||||
import { uploadContentUrl } from '@shared/upload/upload.adapter';
|
import { uploadContentUrl } from '@shared/infrastructure/upload.adapter';
|
||||||
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
||||||
|
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
|
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
|
||||||
@@ -28,29 +28,35 @@ import { PendingSave, registerPendingSave } from '@shared/application/pending-sa
|
|||||||
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
|
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
|
||||||
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
|
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
|
||||||
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase
|
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase
|
||||||
* P1) via `BriefState.loaded.decisions` — this store never computes them itself.
|
* P1) via `BriefState.Loaded.decisions` — this store never computes them itself.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class BriefStore implements PendingSave {
|
export class BriefStore implements PendingSave {
|
||||||
private adapter = inject(BriefAdapter);
|
private adapter = inject(BriefAdapter);
|
||||||
private previewAdapter = inject(LetterPreviewAdapter);
|
private previewAdapter = inject(LetterPreviewAdapter);
|
||||||
private revealAdapter = inject(RevealBigNummerAdapter);
|
private revealAdapter = inject(RevealBigNummerAdapter);
|
||||||
|
private blobPresenter = inject(BLOB_PRESENTER);
|
||||||
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
||||||
|
|
||||||
readonly model = this.store.model;
|
readonly model = this.store.model;
|
||||||
|
|
||||||
private actionState = signal<ActionState>({ tag: 'Idle' });
|
/** The one-shot action lifecycle now lives on the machine's `Loaded.action` (RD-12);
|
||||||
readonly busy = computed(() => this.actionState().tag === 'Busy');
|
these stay as plain `computed`s so the render seam (four `busy = input(...)`
|
||||||
|
components, two page templates) keeps a byte-identical boolean/string API. */
|
||||||
|
readonly busy = computed(() => {
|
||||||
|
const s = this.model();
|
||||||
|
return s.tag === 'Loaded' && s.action.tag === 'Busy';
|
||||||
|
});
|
||||||
readonly lastError = computed(() => {
|
readonly lastError = computed(() => {
|
||||||
const s = this.actionState();
|
const s = this.model();
|
||||||
return s.tag === 'Failed' ? s.error : null;
|
return s.tag === 'Loaded' && s.action.tag === 'Failed' ? s.action.error : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 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. */
|
||||||
@@ -58,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);
|
||||||
@@ -75,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);
|
||||||
@@ -93,11 +99,11 @@ export class BriefStore implements PendingSave {
|
|||||||
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
|
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
|
||||||
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
|
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
|
||||||
purely a projection of its loading/failed tags onto the shared async seam. */
|
purely a projection of its loading/failed tags onto the shared async seam. */
|
||||||
readonly remoteData = computed(() => machineRemoteData(this.model()));
|
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
|
||||||
|
|
||||||
private brief = computed<Brief | null>(() => {
|
private brief = computed<Brief | null>(() => {
|
||||||
const s = this.model();
|
const s = this.model();
|
||||||
return s.tag === 'loaded' ? s.brief : null;
|
return s.tag === 'Loaded' ? s.brief : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
|
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
|
||||||
@@ -109,7 +115,7 @@ export class BriefStore implements PendingSave {
|
|||||||
|
|
||||||
private decisions = computed(() => {
|
private decisions = computed(() => {
|
||||||
const s = this.model();
|
const s = this.model();
|
||||||
return s.tag === 'loaded' ? s.decisions : null;
|
return s.tag === 'Loaded' ? s.decisions : null;
|
||||||
});
|
});
|
||||||
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
|
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
|
||||||
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
|
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
|
||||||
@@ -119,13 +125,40 @@ 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 (CQ-007's expand
|
||||||
|
half — see `recoverFromMissingBrief`). This is the structural once-only bound:
|
||||||
|
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. */
|
||||||
|
private hasRecoveredFromMissingBrief = false;
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
const r = await this.adapter.load();
|
const r = await this.adapter.load();
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
this.orgTemplate.set(r.value.orgTemplate);
|
this.applyLoadedView(r.value);
|
||||||
this.caseContext.set(r.value.caseContext);
|
} else if (r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief) {
|
||||||
|
this.hasRecoveredFromMissingBrief = true;
|
||||||
|
await this.recoverFromMissingBrief();
|
||||||
|
} else {
|
||||||
|
const reason = r.error.tag === 'notFound' ? BRIEF_LOAD_FAILED : r.error.reason;
|
||||||
|
this.store.dispatch({ tag: 'BriefLoadFailed', reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyLoadedView(view: BriefView) {
|
||||||
|
this.orgTemplate.set(view.orgTemplate);
|
||||||
|
this.caseContext.set(view.caseContext);
|
||||||
this.history.clear();
|
this.history.clear();
|
||||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
this.store.dispatch({ tag: 'BriefLoaded', ...view });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /brief` 404'd — no brief exists yet for this owner. Recover by calling the
|
||||||
|
existing `reset()` command directly (the same POST `resetDemo()` uses) and
|
||||||
|
applying whatever it returns; this NEVER calls `load()` again, so a second 404
|
||||||
|
(e.g. `reset()` itself failing) cannot loop back into this method. */
|
||||||
|
private async recoverFromMissingBrief() {
|
||||||
|
const r = await this.adapter.reset();
|
||||||
|
if (r.ok) {
|
||||||
|
this.applyLoadedView(r.value);
|
||||||
} else {
|
} else {
|
||||||
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
||||||
}
|
}
|
||||||
@@ -153,7 +186,7 @@ export class BriefStore implements PendingSave {
|
|||||||
}
|
}
|
||||||
private restore(step: (current: Brief) => Brief | undefined) {
|
private restore(step: (current: Brief) => Brief | undefined) {
|
||||||
const s = this.model();
|
const s = this.model();
|
||||||
if (s.tag !== 'loaded') return;
|
if (s.tag !== 'Loaded') return;
|
||||||
const target = step(s.brief);
|
const target = step(s.brief);
|
||||||
if (target === undefined) return;
|
if (target === undefined) return;
|
||||||
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
|
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
|
||||||
@@ -167,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(),
|
||||||
@@ -183,31 +216,33 @@ export class BriefStore implements PendingSave {
|
|||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
this.saveState.set({ tag: 'Saved' });
|
this.saveState.set({ tag: 'Saved' });
|
||||||
} else {
|
} else {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
// The autosave failure legitimately surfaces in two places: the small save
|
||||||
|
// indicator below (kept as-is) and the action error line (RD-12).
|
||||||
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
this.saveState.set({ tag: 'Error' });
|
this.saveState.set({ tag: 'Error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Demo "start over": recreate the brief server-side and load the fresh view. */
|
/** Demo "start over": recreate the brief server-side and load the fresh view. */
|
||||||
async resetDemo() {
|
async resetDemo() {
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.store.dispatch({ tag: 'ActionStarted' });
|
||||||
this.debouncedSave.cancel();
|
this.debouncedSave.cancel();
|
||||||
const r = await this.adapter.reset();
|
const r = await this.adapter.reset();
|
||||||
this.saveState.set({ tag: 'Idle' });
|
this.saveState.set({ tag: 'Idle' });
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
this.actionState.set({ tag: 'Idle' });
|
this.store.dispatch({ tag: 'ActionFinished' });
|
||||||
this.orgTemplate.set(r.value.orgTemplate);
|
this.orgTemplate.set(r.value.orgTemplate);
|
||||||
this.caseContext.set(r.value.caseContext);
|
this.caseContext.set(r.value.caseContext);
|
||||||
this.history.clear();
|
this.history.clear();
|
||||||
this.rejectionSnapshot.set(null);
|
this.rejectionSnapshot.set(null);
|
||||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||||
} else {
|
} else {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,27 +252,31 @@ export class BriefStore implements PendingSave {
|
|||||||
send = () => this.transition(() => this.adapter.send());
|
send = () => this.transition(() => this.adapter.send());
|
||||||
|
|
||||||
/** Explicit action, never a live re-render (PRD §8): opens the server-composed
|
/** Explicit action, never a live re-render (PRD §8): opens the server-composed
|
||||||
letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and
|
letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the
|
||||||
the tab outlives this call; not worth a teardown hook for a POC. */
|
object URL is never revoked. */
|
||||||
async previewLetter() {
|
async previewLetter() {
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.store.dispatch({ tag: 'ActionStarted' });
|
||||||
const r = await this.previewAdapter.preview();
|
const r = await this.previewAdapter.preview();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.actionState.set({ tag: 'Idle' });
|
this.store.dispatch({ tag: 'ActionFinished' });
|
||||||
window.open(URL.createObjectURL(r.value), '_blank');
|
this.blobPresenter.open(r.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability
|
/** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability
|
||||||
+ step-up and audits the attempt; on success we swap the masked value in the
|
+ step-up and audits the attempt; on success we swap the masked value in the
|
||||||
already-loaded caseContext (a field update, not a reload). The step-up gesture
|
already-loaded caseContext (a field update, not a reload). The step-up gesture
|
||||||
itself is the UI's concern — this command just runs the audited server call. */
|
itself is the UI's concern (`behandel-scherm.component.ts`'s `onReveal()` confirm)
|
||||||
|
— this command is only reachable once that gesture has happened, so it is the one
|
||||||
|
that tells the adapter to send `X-Step-Up` (BIO-006a: the adapter itself no longer
|
||||||
|
hardcodes the header). */
|
||||||
async revealBigNummer() {
|
async revealBigNummer() {
|
||||||
const r = await this.revealAdapter.reveal();
|
const r = await this.revealAdapter.reveal(true);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
// Never sets Busy — an existing asymmetry (RD-12), not fixed here.
|
||||||
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c));
|
this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c));
|
||||||
@@ -246,15 +285,15 @@ export class BriefStore implements PendingSave {
|
|||||||
// A transition: flush any pending save, call the server (authoritative), then mirror
|
// A transition: flush any pending save, call the server (authoritative), then mirror
|
||||||
// the returned status through the pure reducer's guarded transition.
|
// the returned status through the pure reducer's guarded transition.
|
||||||
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.store.dispatch({ tag: 'ActionStarted' });
|
||||||
this.debouncedSave.cancel();
|
this.debouncedSave.cancel();
|
||||||
await this.flushSave();
|
await this.flushSave();
|
||||||
const r = await action();
|
const r = await action();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.actionState.set({ tag: 'Idle' });
|
this.store.dispatch({ tag: 'ActionFinished' });
|
||||||
this.applyServerStatus(r.value);
|
this.applyServerStatus(r.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,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({
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Result, ok } from '@shared/kernel/fp';
|
||||||
|
import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter';
|
||||||
|
import { UploadAdapter } from '@shared/infrastructure/upload.adapter';
|
||||||
|
import { UploadShellService } from '@shared/application/upload-shell.service';
|
||||||
|
import { OrgTemplate, OrgTemplateAdminView, SubOrgSummary } from '@brief/domain/org-template';
|
||||||
|
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
|
||||||
|
import { OrgTemplateStore } from './org-template.store';
|
||||||
|
|
||||||
|
const template: OrgTemplate = {
|
||||||
|
subOrgId: 'cibg-registers',
|
||||||
|
orgName: 'CIBG — Registers',
|
||||||
|
returnAddress: 'Postbus 00000\n2500 AA Den Haag',
|
||||||
|
footerContact: 'info@voorbeeld.example',
|
||||||
|
footerLegal: 'KvK 00000000',
|
||||||
|
signatureName: 'A. de Vries',
|
||||||
|
signatureRole: 'Hoofd Registratie',
|
||||||
|
signatureClosing: 'Met vriendelijke groet,',
|
||||||
|
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const view: OrgTemplateAdminView = {
|
||||||
|
draft: template,
|
||||||
|
publishedVersion: 1,
|
||||||
|
history: [],
|
||||||
|
unsentBriefs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const subOrgs: SubOrgSummary[] = [
|
||||||
|
{ subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
function fakeBlobPresenter() {
|
||||||
|
const opened: Blob[] = [];
|
||||||
|
const presenter: BlobPresenter = {
|
||||||
|
open: (blob) => opened.push(blob),
|
||||||
|
download: () => {
|
||||||
|
throw new Error('not used by OrgTemplateStore');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { presenter, opened };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A no-op categories resource: the logo-upload sub-state is untouched by these
|
||||||
|
tests, so 'idle' (never resolved) keeps the constructor effect from dispatching. */
|
||||||
|
function fakeCategoriesResource(): ReturnType<UploadAdapter['categoriesResource']> {
|
||||||
|
const fake = { status: () => 'idle' as const, value: () => undefined };
|
||||||
|
return fake as unknown as ReturnType<UploadAdapter['categoriesResource']>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
adapter: Partial<OrgTemplateAdapter>,
|
||||||
|
blobPresenter: BlobPresenter,
|
||||||
|
): OrgTemplateStore {
|
||||||
|
const uploadAdapter: Partial<UploadAdapter> = {
|
||||||
|
categoriesResource: () => fakeCategoriesResource(),
|
||||||
|
};
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
{ provide: OrgTemplateAdapter, useValue: adapter },
|
||||||
|
{ provide: UploadAdapter, useValue: uploadAdapter },
|
||||||
|
{ provide: UploadShellService, useValue: {} },
|
||||||
|
{ provide: BLOB_PRESENTER, useValue: blobPresenter },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return TestBed.inject(OrgTemplateStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TE-006: proefbrief() ends in BLOB_PRESENTER.open, not a raw
|
||||||
|
// window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. ---
|
||||||
|
|
||||||
|
describe('OrgTemplateStore.proefbrief', () => {
|
||||||
|
it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => {
|
||||||
|
// Given a loaded sub-org template.
|
||||||
|
const { presenter, opened } = fakeBlobPresenter();
|
||||||
|
const blob = new Blob(['<html></html>'], { type: 'text/html' });
|
||||||
|
const store = setup(
|
||||||
|
{
|
||||||
|
list: (): Promise<Result<string, SubOrgSummary[]>> => Promise.resolve(ok(subOrgs)),
|
||||||
|
load: (): Promise<Result<string, OrgTemplateAdminView>> => Promise.resolve(ok(view)),
|
||||||
|
proefbrief: (): Promise<Result<string, Blob>> => Promise.resolve(ok(blob)),
|
||||||
|
},
|
||||||
|
presenter,
|
||||||
|
);
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
// When proefbrief() is called...
|
||||||
|
await store.proefbrief();
|
||||||
|
|
||||||
|
// Then the presenter receives exactly the rendered blob, and no error surfaces.
|
||||||
|
expect(opened).toEqual([blob]);
|
||||||
|
expect(store.lastError()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the error without opening a tab on failure', async () => {
|
||||||
|
// Given a loaded sub-org template whose proefbrief call fails server-side.
|
||||||
|
const { presenter, opened } = fakeBlobPresenter();
|
||||||
|
const store = setup(
|
||||||
|
{
|
||||||
|
list: (): Promise<Result<string, SubOrgSummary[]>> => Promise.resolve(ok(subOrgs)),
|
||||||
|
load: (): Promise<Result<string, OrgTemplateAdminView>> => Promise.resolve(ok(view)),
|
||||||
|
proefbrief: (): Promise<Result<string, Blob>> =>
|
||||||
|
Promise.resolve({ ok: false, error: 'mislukt' }),
|
||||||
|
},
|
||||||
|
presenter,
|
||||||
|
);
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
// When proefbrief() is called...
|
||||||
|
await store.proefbrief();
|
||||||
|
|
||||||
|
// Then the presenter is never reached and the error is surfaced.
|
||||||
|
expect(opened).toHaveLength(0);
|
||||||
|
expect(store.lastError()).toBe('mislukt');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
import { ActionState, SaveState } from '@shared/application/action-state';
|
import { SaveState, createDebouncedSave } from '@shared/application/debounced-save';
|
||||||
import { createDebouncedSave } from '@shared/application/debounced-save';
|
import { fromLoadLifecycle } from '@shared/application/remote-data';
|
||||||
import { machineRemoteData } from '@shared/application/machine-remote-data';
|
import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter';
|
||||||
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
import { UploadShellService } from '@shared/application/upload-shell.service';
|
||||||
import { UploadShellService } from '@shared/upload/upload-shell.service';
|
import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine';
|
||||||
import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';
|
|
||||||
import {
|
import {
|
||||||
MARGIN_MAX_MM,
|
MARGIN_MAX_MM,
|
||||||
MARGIN_MIN_MM,
|
MARGIN_MIN_MM,
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
SubOrgSummary,
|
SubOrgSummary,
|
||||||
} from '@brief/domain/org-template';
|
} from '@brief/domain/org-template';
|
||||||
import {
|
import {
|
||||||
|
OrgTemplateActionState,
|
||||||
OrgTemplateMsg,
|
OrgTemplateMsg,
|
||||||
OrgTemplateState,
|
OrgTemplateState,
|
||||||
initial,
|
initial,
|
||||||
@@ -20,14 +20,15 @@ import {
|
|||||||
} from '@brief/domain/org-template.machine';
|
} from '@brief/domain/org-template.machine';
|
||||||
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
|
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
|
||||||
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
||||||
|
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
|
||||||
|
|
||||||
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>;
|
type LoadedState = Extract<OrgTemplateState, { tag: 'Loaded' }>;
|
||||||
|
|
||||||
const LOGO_CATEGORY = 'org-logo';
|
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
|
||||||
@@ -38,6 +39,7 @@ export class OrgTemplateStore implements PendingSave {
|
|||||||
private adapter = inject(OrgTemplateAdapter);
|
private adapter = inject(OrgTemplateAdapter);
|
||||||
private uploadAdapter = inject(UploadAdapter);
|
private uploadAdapter = inject(UploadAdapter);
|
||||||
private shell = inject(UploadShellService);
|
private shell = inject(UploadShellService);
|
||||||
|
private blobPresenter = inject(BLOB_PRESENTER);
|
||||||
private store = createStore<OrgTemplateState, OrgTemplateMsg>(initial, reduce);
|
private store = createStore<OrgTemplateState, OrgTemplateMsg>(initial, reduce);
|
||||||
|
|
||||||
readonly model = this.store.model;
|
readonly model = this.store.model;
|
||||||
@@ -45,22 +47,28 @@ export class OrgTemplateStore implements PendingSave {
|
|||||||
readonly subOrgs = signal<readonly SubOrgSummary[]>([]);
|
readonly subOrgs = signal<readonly SubOrgSummary[]>([]);
|
||||||
readonly selectedSubOrgId = signal<string | null>(null);
|
readonly selectedSubOrgId = signal<string | null>(null);
|
||||||
|
|
||||||
private actionState = signal<ActionState>({ tag: 'Idle' });
|
/** The one-shot action lifecycle and the publish impact-confirm gate now live on
|
||||||
readonly busy = computed(() => this.actionState().tag === 'Busy');
|
the machine's `Loaded.action` as one four-variant union (RD-13); these stay as
|
||||||
|
plain `computed`s so the render seam (the editor organism's `input()`s, the
|
||||||
|
page template) keeps a byte-identical boolean/string API. */
|
||||||
|
private action = computed<OrgTemplateActionState>(() => this.loaded()?.action ?? { tag: 'Idle' });
|
||||||
|
readonly busy = computed(() => this.action().tag === 'Busy');
|
||||||
readonly lastError = computed(() => {
|
readonly lastError = computed(() => {
|
||||||
const s = this.actionState();
|
const a = this.action();
|
||||||
return s.tag === 'Failed' ? s.error : null;
|
return a.tag === 'Failed' ? a.error : null;
|
||||||
});
|
});
|
||||||
|
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST).
|
||||||
|
Before RD-13 this was an independent boolean, so it could be `true` at the same
|
||||||
|
time `busy` was `true` — representable and meaningless. It is now derived from
|
||||||
|
the same union `busy` reads, so the two are mutually exclusive by construction. */
|
||||||
|
readonly pendingPublish = computed(() => this.action().tag === 'ConfirmingPublish');
|
||||||
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
||||||
|
|
||||||
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */
|
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
|
||||||
readonly pendingPublish = signal(false);
|
|
||||||
|
|
||||||
readonly remoteData = computed(() => machineRemoteData(this.model()));
|
|
||||||
|
|
||||||
private loaded = computed<LoadedState | null>(() => {
|
private loaded = computed<LoadedState | null>(() => {
|
||||||
const s = this.model();
|
const s = this.model();
|
||||||
return s.tag === 'loaded' ? s : null;
|
return s.tag === 'Loaded' ? s : null;
|
||||||
});
|
});
|
||||||
readonly draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null);
|
readonly draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null);
|
||||||
readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);
|
readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);
|
||||||
@@ -72,6 +80,9 @@ export class OrgTemplateStore implements PendingSave {
|
|||||||
return id ? this.uploadAdapter.contentUrl(id) : null;
|
return id ? this.uploadAdapter.contentUrl(id) : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Preview/download link for any completed upload in the editor's document list. */
|
||||||
|
readonly previewUrlFor = (documentId: string): string | undefined => uploadContentUrl(documentId);
|
||||||
|
|
||||||
/** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;
|
/** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;
|
||||||
the server re-validates and stays the authority — publish is gated on this. */
|
the server re-validates and stays the authority — publish is gated on this. */
|
||||||
readonly draftValid = computed(() => {
|
readonly draftValid = computed(() => {
|
||||||
@@ -96,7 +107,7 @@ export class OrgTemplateStore implements PendingSave {
|
|||||||
// the length guard makes it idempotent (no dispatch loop).
|
// the length guard makes it idempotent (no dispatch loop).
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const s = this.model();
|
const s = this.model();
|
||||||
if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;
|
if (s.tag !== 'Loaded' || s.upload.categories.length > 0) return;
|
||||||
const status = this.categoriesRes.status();
|
const status = this.categoriesRes.status();
|
||||||
if (status === 'resolved' || status === 'local')
|
if (status === 'resolved' || status === 'local')
|
||||||
this.dispatchUpload({
|
this.dispatchUpload({
|
||||||
@@ -140,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,
|
||||||
@@ -160,61 +171,65 @@ export class OrgTemplateStore implements PendingSave {
|
|||||||
this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });
|
this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });
|
||||||
} else {
|
} else {
|
||||||
this.saveState.set({ tag: 'Error' });
|
this.saveState.set({ tag: 'Error' });
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- publish (impact-confirm) / rollback / proefbrief ---
|
// --- publish (impact-confirm) / rollback / proefbrief ---
|
||||||
|
|
||||||
|
// RD-13: `requestPublish`/`cancelPublish` are the only two commands here that do
|
||||||
|
// NOT guard on `loaded()` — as dispatches they no-op outside `Loaded` by
|
||||||
|
// construction (the reducer's own guard), so behaviour is unchanged.
|
||||||
requestPublish() {
|
requestPublish() {
|
||||||
this.pendingPublish.set(true);
|
this.store.dispatch({ tag: 'PublishRequested' });
|
||||||
}
|
}
|
||||||
cancelPublish() {
|
cancelPublish() {
|
||||||
this.pendingPublish.set(false);
|
this.store.dispatch({ tag: 'PublishCancelled' });
|
||||||
}
|
}
|
||||||
async confirmPublish() {
|
async confirmPublish() {
|
||||||
const s = this.loaded();
|
const s = this.loaded();
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
this.pendingPublish.set(false);
|
// ActionStarted overwrites `action` straight to Busy, so ConfirmingPublish and
|
||||||
this.actionState.set({ tag: 'Busy' });
|
// Busy are never simultaneously true (RD-13).
|
||||||
|
this.store.dispatch({ tag: 'ActionStarted' });
|
||||||
this.debouncedSave.cancel();
|
this.debouncedSave.cancel();
|
||||||
await this.flushSave(); // publish the saved draft — flush any pending edit first
|
await this.flushSave(); // publish the saved draft — flush any pending edit first
|
||||||
const r = await this.adapter.publish(s.subOrgId);
|
const r = await this.adapter.publish(s.subOrgId);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.actionState.set({ tag: 'Idle' });
|
this.store.dispatch({ tag: 'ActionFinished' });
|
||||||
await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0
|
await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
async rollback(version: number) {
|
async rollback(version: number) {
|
||||||
const s = this.loaded();
|
const s = this.loaded();
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.store.dispatch({ tag: 'ActionStarted' });
|
||||||
this.debouncedSave.cancel();
|
this.debouncedSave.cancel();
|
||||||
const r = await this.adapter.rollback(s.subOrgId, version);
|
const r = await this.adapter.rollback(s.subOrgId, version);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.actionState.set({ tag: 'Idle' });
|
this.store.dispatch({ tag: 'ActionFinished' });
|
||||||
this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft
|
this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft
|
||||||
}
|
}
|
||||||
|
|
||||||
async proefbrief() {
|
async proefbrief() {
|
||||||
const s = this.loaded();
|
const s = this.loaded();
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.store.dispatch({ tag: 'ActionStarted' });
|
||||||
this.debouncedSave.cancel();
|
this.debouncedSave.cancel();
|
||||||
await this.flushSave(); // the proefbrief renders the server's draft
|
await this.flushSave(); // the proefbrief renders the server's draft
|
||||||
const r = await this.adapter.proefbrief(s.subOrgId);
|
const r = await this.adapter.proefbrief(s.subOrgId);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.actionState.set({ tag: 'Idle' });
|
this.store.dispatch({ tag: 'ActionFinished' });
|
||||||
window.open(URL.createObjectURL(r.value), '_blank');
|
this.blobPresenter.open(r.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- logo upload (reuses the shared upload transport; single `org-logo` file) ---
|
// --- logo upload (reuses the shared upload transport; single `org-logo` file) ---
|
||||||
|
|||||||
@@ -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,6 +3,7 @@ import { Besluit, Brief, BriefDecisions, BriefStatus, LibraryPassage } from './b
|
|||||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||||
import { PlaceholderDef } from './placeholders';
|
import { PlaceholderDef } from './placeholders';
|
||||||
import { BriefState, reduce } from './brief.machine';
|
import { BriefState, reduce } from './brief.machine';
|
||||||
|
import { givenBrief } from './brief.testing';
|
||||||
|
|
||||||
const placeholders: PlaceholderDef[] = [
|
const placeholders: PlaceholderDef[] = [
|
||||||
{ key: 'naam', label: 'Naam', autoResolvable: true },
|
{ key: 'naam', label: 'Naam', autoResolvable: true },
|
||||||
@@ -64,18 +65,18 @@ const decisions: BriefDecisions = {
|
|||||||
canRevealBigNummer: true,
|
canRevealBigNummer: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const loaded = (
|
// Replays a real `BriefLoaded` message through the real `reduce` (ADR-0006 §2)
|
||||||
status: BriefStatus = { tag: 'draft' },
|
// instead of hand-assembling the 'loaded' state directly.
|
||||||
sections?: Brief['sections'],
|
const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sections']): BriefState =>
|
||||||
): BriefState => ({
|
givenBrief({
|
||||||
tag: 'loaded',
|
tag: 'BriefLoaded',
|
||||||
brief: briefWith(status, sections),
|
brief: briefWith(status, sections),
|
||||||
availablePassages: lib,
|
availablePassages: lib,
|
||||||
decisions,
|
decisions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sectionBlocks = (s: BriefState, key: string) =>
|
const sectionBlocks = (s: BriefState, key: string) =>
|
||||||
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
|
s.tag === 'Loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
|
||||||
|
|
||||||
const passageIds = (s: BriefState, key: string) =>
|
const passageIds = (s: BriefState, key: string) =>
|
||||||
sectionBlocks(s, key)
|
sectionBlocks(s, key)
|
||||||
@@ -91,12 +92,12 @@ describe('brief.machine reduce', () => {
|
|||||||
availablePassages: [],
|
availablePassages: [],
|
||||||
decisions,
|
decisions,
|
||||||
}).tag,
|
}).tag,
|
||||||
).toBe('loaded');
|
).toBe('Loaded');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('BriefLoadFailed moves loading to failed with the reason', () => {
|
it('BriefLoadFailed moves loading to failed with the reason', () => {
|
||||||
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
|
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
|
||||||
tag: 'failed',
|
tag: 'Failed',
|
||||||
reason: 'x',
|
reason: 'x',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -128,12 +129,12 @@ describe('brief.machine reduce', () => {
|
|||||||
|
|
||||||
it('BesluitSelected deep-copies content — later library mutation does not leak in', () => {
|
it('BesluitSelected deep-copies content — later library mutation does not leak in', () => {
|
||||||
const passage = libPassage('intro', 'kern'); // shared → offered for any besluit
|
const passage = libPassage('intro', 'kern'); // shared → offered for any besluit
|
||||||
const st: BriefState = {
|
const st = givenBrief({
|
||||||
tag: 'loaded',
|
tag: 'BriefLoaded',
|
||||||
brief: briefWith({ tag: 'draft' }),
|
brief: briefWith({ tag: 'draft' }),
|
||||||
availablePassages: [passage],
|
availablePassages: [passage],
|
||||||
decisions,
|
decisions,
|
||||||
};
|
});
|
||||||
const s = reduce(st, besluit('positief'));
|
const s = reduce(st, besluit('positief'));
|
||||||
// Mutate the source passage object after composition.
|
// Mutate the source passage object after composition.
|
||||||
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
|
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
|
||||||
@@ -209,7 +210,7 @@ describe('brief.machine reduce', () => {
|
|||||||
comments: 'graag aanpassen',
|
comments: 'graag aanpassen',
|
||||||
});
|
});
|
||||||
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
|
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
|
||||||
expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft');
|
expect(next.tag === 'Loaded' && next.brief.status.tag).toBe('draft');
|
||||||
expect(sectionBlocks(next, 'slot')).toHaveLength(1);
|
expect(sectionBlocks(next, 'slot')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,7 +220,7 @@ describe('brief.machine reduce', () => {
|
|||||||
// fill the required section via the besluit, then submit
|
// fill the required section via the besluit, then submit
|
||||||
const filled = reduce(loaded(), besluit('positief'));
|
const filled = reduce(loaded(), besluit('positief'));
|
||||||
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
|
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
|
||||||
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
|
expect(submitted.tag === 'Loaded' && submitted.brief.status).toEqual({
|
||||||
tag: 'submitted',
|
tag: 'submitted',
|
||||||
submittedBy: 'u1',
|
submittedBy: 'u1',
|
||||||
submittedAt: 't',
|
submittedAt: 't',
|
||||||
@@ -231,7 +232,7 @@ describe('brief.machine reduce', () => {
|
|||||||
// approve from draft is a no-op
|
// approve from draft is a no-op
|
||||||
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
|
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
|
||||||
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
|
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
|
||||||
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({
|
expect(approved.tag === 'Loaded' && approved.brief.status).toEqual({
|
||||||
tag: 'approved',
|
tag: 'approved',
|
||||||
approvedBy: 'u2',
|
approvedBy: 'u2',
|
||||||
approvedAt: 't2',
|
approvedAt: 't2',
|
||||||
@@ -247,7 +248,7 @@ describe('brief.machine reduce', () => {
|
|||||||
comments: 'nee',
|
comments: 'nee',
|
||||||
decisions,
|
decisions,
|
||||||
});
|
});
|
||||||
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
|
expect(rejected.tag === 'Loaded' && rejected.brief.status).toEqual({
|
||||||
tag: 'rejected',
|
tag: 'rejected',
|
||||||
rejectedBy: 'u2',
|
rejectedBy: 'u2',
|
||||||
rejectedAt: 't2',
|
rejectedAt: 't2',
|
||||||
@@ -261,7 +262,40 @@ describe('brief.machine reduce', () => {
|
|||||||
// send from submitted is a no-op
|
// send from submitted is a no-op
|
||||||
expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
|
expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
|
||||||
const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions });
|
const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions });
|
||||||
expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
|
expect(sent.tag === 'Loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- RD-12: the action lifecycle lives on `Loaded.action`, driven by three msgs ---
|
||||||
|
|
||||||
|
it('ActionStarted moves a loaded brief to Busy', () => {
|
||||||
|
const s = reduce(loaded(), { tag: 'ActionStarted' });
|
||||||
|
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Busy' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ActionFailed carries the error', () => {
|
||||||
|
const s = reduce(loaded(), { tag: 'ActionFailed', error: 'niet gelukt' });
|
||||||
|
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Failed', error: 'niet gelukt' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ActionFinished returns to Idle', () => {
|
||||||
|
const busy = reduce(loaded(), { tag: 'ActionStarted' });
|
||||||
|
const s = reduce(busy, { tag: 'ActionFinished' });
|
||||||
|
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Idle' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BriefLoaded resets a stale action error to Idle', () => {
|
||||||
|
const failed = reduce(loaded(), { tag: 'ActionFailed', error: 'niet gelukt' });
|
||||||
|
const reloaded = reduce(failed, {
|
||||||
|
tag: 'BriefLoaded',
|
||||||
|
brief: briefWith({ tag: 'draft' }),
|
||||||
|
availablePassages: lib,
|
||||||
|
decisions,
|
||||||
|
});
|
||||||
|
expect(reloaded.tag === 'Loaded' && reloaded.action).toEqual({ tag: 'Idle' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an action message is a no-op when the brief is not loaded', () => {
|
||||||
|
expect(reduce(initialLoading(), { tag: 'ActionStarted' })).toEqual(initialLoading());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a status transition replaces decisions with the fresh server value', () => {
|
it('a status transition replaces decisions with the fresh server value', () => {
|
||||||
@@ -279,10 +313,10 @@ describe('brief.machine reduce', () => {
|
|||||||
at: 't2',
|
at: 't2',
|
||||||
decisions: staleApprover,
|
decisions: staleApprover,
|
||||||
});
|
});
|
||||||
expect(approved.tag === 'loaded' && approved.decisions).toEqual(staleApprover);
|
expect(approved.tag === 'Loaded' && approved.decisions).toEqual(staleApprover);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function initialLoading(): BriefState {
|
function initialLoading(): BriefState {
|
||||||
return { tag: 'loading' };
|
return { tag: 'Loading' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,17 +36,22 @@ import { passagesForBesluit } from './besluit';
|
|||||||
* structurally impossible (a pasted `{{…}}` is caught by the linter as `malformed`).
|
* structurally impossible (a pasted `{{…}}` is caught by the linter as `malformed`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** The one-shot action lifecycle (submit/approve/reject/send/preview/reveal/reset),
|
||||||
|
owned by the reducer instead of an imperative store-level signal (RD-12). */
|
||||||
|
export type BriefActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
|
||||||
|
|
||||||
export type BriefState =
|
export type BriefState =
|
||||||
| { tag: 'loading' }
|
| { tag: 'Loading' }
|
||||||
| {
|
| {
|
||||||
tag: 'loaded';
|
tag: 'Loaded';
|
||||||
brief: Brief;
|
brief: Brief;
|
||||||
availablePassages: readonly LibraryPassage[];
|
availablePassages: readonly LibraryPassage[];
|
||||||
decisions: BriefDecisions;
|
decisions: BriefDecisions;
|
||||||
|
action: BriefActionState;
|
||||||
}
|
}
|
||||||
| { tag: 'failed'; reason: string };
|
| { tag: 'Failed'; reason: string };
|
||||||
|
|
||||||
export const initial: BriefState = { tag: 'loading' };
|
export const initial: BriefState = { tag: 'Loading' };
|
||||||
|
|
||||||
export type BriefMsg =
|
export type BriefMsg =
|
||||||
| {
|
| {
|
||||||
@@ -65,7 +70,10 @@ export type BriefMsg =
|
|||||||
| { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
|
| { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
|
||||||
| { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
|
| { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
|
||||||
| { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent
|
| { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent
|
||||||
| { tag: 'Seed'; state: BriefState };
|
| { tag: 'Seed'; state: BriefState }
|
||||||
|
| { tag: 'ActionStarted' } // a one-shot action (submit/approve/preview/…) began
|
||||||
|
| { tag: 'ActionFinished' } // it completed successfully
|
||||||
|
| { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show
|
||||||
|
|
||||||
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
|
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
|
||||||
function isEditable(status: BriefStatus): boolean {
|
function isEditable(status: BriefStatus): boolean {
|
||||||
@@ -110,7 +118,7 @@ function mapBlocks(brief: Brief, f: (blocks: readonly LetterBlock[]) => LetterBl
|
|||||||
|
|
||||||
/** Apply an edit to the brief, guarded by status. A rejected letter reopens to draft. */
|
/** Apply an edit to the brief, guarded by status. A rejected letter reopens to draft. */
|
||||||
function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
|
function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
|
||||||
if (s.tag !== 'loaded' || !isEditable(s.brief.status)) return s;
|
if (s.tag !== 'Loaded' || !isEditable(s.brief.status)) return s;
|
||||||
let brief = f(s.brief);
|
let brief = f(s.brief);
|
||||||
if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } };
|
if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } };
|
||||||
return { ...s, brief };
|
return { ...s, brief };
|
||||||
@@ -189,13 +197,16 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
|||||||
switch (m.tag) {
|
switch (m.tag) {
|
||||||
case 'BriefLoaded':
|
case 'BriefLoaded':
|
||||||
return {
|
return {
|
||||||
tag: 'loaded',
|
tag: 'Loaded',
|
||||||
brief: m.brief,
|
brief: m.brief,
|
||||||
availablePassages: m.availablePassages,
|
availablePassages: m.availablePassages,
|
||||||
decisions: m.decisions,
|
decisions: m.decisions,
|
||||||
|
// A fresh load clears a stale action error rather than letting it outlive
|
||||||
|
// the reload (RD-12, decision 4).
|
||||||
|
action: { tag: 'Idle' },
|
||||||
};
|
};
|
||||||
case 'BriefLoadFailed':
|
case 'BriefLoadFailed':
|
||||||
return { tag: 'failed', reason: m.reason };
|
return { tag: 'Failed', reason: m.reason };
|
||||||
case 'Seed':
|
case 'Seed':
|
||||||
return m.state;
|
return m.state;
|
||||||
|
|
||||||
@@ -203,7 +214,7 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
|||||||
// drafter's free text. `availablePassages` lives on the loaded state, so this stays pure.
|
// drafter's free text. `availablePassages` lives on the loaded state, so this stays pure.
|
||||||
case 'BesluitSelected':
|
case 'BesluitSelected':
|
||||||
return withEdit(s, (b) =>
|
return withEdit(s, (b) =>
|
||||||
s.tag === 'loaded' && isSectionEditable(b, 'kern')
|
s.tag === 'Loaded' && isSectionEditable(b, 'kern')
|
||||||
? composeKern(b, s.availablePassages, m.besluit, m.reasons)
|
? composeKern(b, s.availablePassages, m.besluit, m.reasons)
|
||||||
: b,
|
: b,
|
||||||
);
|
);
|
||||||
@@ -260,6 +271,15 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
|||||||
case 'Sent':
|
case 'Sent':
|
||||||
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions);
|
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions);
|
||||||
|
|
||||||
|
// The action lifecycle (RD-12): a no-op unless a brief is loaded, since there is
|
||||||
|
// nothing to attach the action state to otherwise.
|
||||||
|
case 'ActionStarted':
|
||||||
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s;
|
||||||
|
case 'ActionFinished':
|
||||||
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
|
||||||
|
case 'ActionFailed':
|
||||||
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return assertNever(m);
|
return assertNever(m);
|
||||||
}
|
}
|
||||||
@@ -275,6 +295,6 @@ function transition(
|
|||||||
decisions: BriefDecisions,
|
decisions: BriefDecisions,
|
||||||
guard: (b: Brief) => boolean = () => true,
|
guard: (b: Brief) => boolean = () => true,
|
||||||
): BriefState {
|
): BriefState {
|
||||||
if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
|
if (s.tag !== 'Loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
|
||||||
return { ...s, brief: { ...s.brief, status: next() }, decisions };
|
return { ...s, brief: { ...s.brief, status: next() }, decisions };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { given } from '@shared/testing/machine';
|
||||||
|
import { reduce, initial } from './brief.machine';
|
||||||
|
|
||||||
|
/** Replay real `BriefMsg`s through the real `reduce`, starting from `initial`.
|
||||||
|
Pure TS only (no Angular) — domain/ stays framework-free (dependency-cruiser
|
||||||
|
`domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */
|
||||||
|
export const givenBrief = given(reduce, initial);
|
||||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { expectTag } from '@shared/testing/expect-tag';
|
import { expectTag } from '@shared/testing/expect-tag';
|
||||||
import { OrgTemplate, OrgTemplateAdminView } from './org-template';
|
import { OrgTemplate, OrgTemplateAdminView } from './org-template';
|
||||||
import { OrgTemplateState, reduce } from './org-template.machine';
|
import { OrgTemplateState, reduce } from './org-template.machine';
|
||||||
import { DocumentCategory } from '@shared/upload/upload.machine';
|
import { DocumentCategory } from '@shared/domain/upload.machine';
|
||||||
|
|
||||||
const template: OrgTemplate = {
|
const template: OrgTemplate = {
|
||||||
subOrgId: 'cibg-registers',
|
subOrgId: 'cibg-registers',
|
||||||
@@ -26,7 +26,7 @@ const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
const loaded = (): OrgTemplateState =>
|
const loaded = (): OrgTemplateState =>
|
||||||
reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
|
reduce({ tag: 'Loading' }, { tag: 'DraftLoaded', view: view() });
|
||||||
|
|
||||||
const logoCategory: DocumentCategory = {
|
const logoCategory: DocumentCategory = {
|
||||||
categoryId: 'org-logo',
|
categoryId: 'org-logo',
|
||||||
@@ -41,7 +41,7 @@ const logoCategory: DocumentCategory = {
|
|||||||
|
|
||||||
describe('org-template.machine', () => {
|
describe('org-template.machine', () => {
|
||||||
it('DraftLoaded moves to loaded with the draft, clean', () => {
|
it('DraftLoaded moves to loaded with the draft, clean', () => {
|
||||||
const s = expectTag(loaded(), 'loaded');
|
const s = expectTag(loaded(), 'Loaded');
|
||||||
expect(s.draft.orgName).toBe('CIBG');
|
expect(s.draft.orgName).toBe('CIBG');
|
||||||
expect(s.subOrgId).toBe('cibg-registers');
|
expect(s.subOrgId).toBe('cibg-registers');
|
||||||
expect(s.unsentBriefs).toBe(2);
|
expect(s.unsentBriefs).toBe(2);
|
||||||
@@ -49,14 +49,14 @@ describe('org-template.machine', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('LoadFailed carries the reason', () => {
|
it('LoadFailed carries the reason', () => {
|
||||||
const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' });
|
const s = reduce({ tag: 'Loading' }, { tag: 'LoadFailed', reason: 'boom' });
|
||||||
expect(s).toEqual({ tag: 'failed', reason: 'boom' });
|
expect(s).toEqual({ tag: 'Failed', reason: 'boom' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FieldEdited edits the draft and marks dirty', () => {
|
it('FieldEdited edits the draft and marks dirty', () => {
|
||||||
const s = expectTag(
|
const s = expectTag(
|
||||||
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }),
|
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }),
|
||||||
'loaded',
|
'Loaded',
|
||||||
);
|
);
|
||||||
expect(s.draft.orgName).toBe('CIBG Nieuw');
|
expect(s.draft.orgName).toBe('CIBG Nieuw');
|
||||||
expect(s.dirty).toBe(true);
|
expect(s.dirty).toBe(true);
|
||||||
@@ -65,7 +65,7 @@ describe('org-template.machine', () => {
|
|||||||
it('MarginEdited edits one edge and marks dirty', () => {
|
it('MarginEdited edits one edge and marks dirty', () => {
|
||||||
const s = expectTag(
|
const s = expectTag(
|
||||||
reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }),
|
reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }),
|
||||||
'loaded',
|
'Loaded',
|
||||||
);
|
);
|
||||||
expect(s.draft.margins.topMm).toBe(40);
|
expect(s.draft.margins.topMm).toBe(40);
|
||||||
expect(s.draft.margins.leftMm).toBe(20);
|
expect(s.draft.margins.leftMm).toBe(20);
|
||||||
@@ -75,9 +75,9 @@ describe('org-template.machine', () => {
|
|||||||
it('DraftSaved clears dirty when the saved draft is the current one', () => {
|
it('DraftSaved clears dirty when the saved draft is the current one', () => {
|
||||||
const edited = expectTag(
|
const edited = expectTag(
|
||||||
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
|
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
|
||||||
'loaded',
|
'Loaded',
|
||||||
);
|
);
|
||||||
const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'loaded');
|
const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'Loaded');
|
||||||
expect(s.dirty).toBe(false);
|
expect(s.dirty).toBe(false);
|
||||||
expect(s.draft.orgName).toBe('X');
|
expect(s.draft.orgName).toBe('X');
|
||||||
});
|
});
|
||||||
@@ -85,20 +85,20 @@ describe('org-template.machine', () => {
|
|||||||
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
|
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
|
||||||
const editing = expectTag(
|
const editing = expectTag(
|
||||||
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
|
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
|
||||||
'loaded',
|
'Loaded',
|
||||||
);
|
);
|
||||||
const savedDraft = editing.draft;
|
const savedDraft = editing.draft;
|
||||||
// a further edit changes the draft reference before the save resolves
|
// a further edit changes the draft reference before the save resolves
|
||||||
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
|
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
|
||||||
const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'loaded');
|
const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'Loaded');
|
||||||
expect(s.dirty).toBe(true);
|
expect(s.dirty).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('edits are no-ops in non-loaded states', () => {
|
it('edits are no-ops in non-loaded states', () => {
|
||||||
expect(
|
expect(
|
||||||
reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
|
reduce({ tag: 'Loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
tag: 'loading',
|
tag: 'Loading',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ describe('org-template.machine', () => {
|
|||||||
tag: 'Upload',
|
tag: 'Upload',
|
||||||
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
|
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
|
||||||
}),
|
}),
|
||||||
'loaded',
|
'Loaded',
|
||||||
);
|
);
|
||||||
expect(done.draft.logoDocumentId).toBe('doc-1');
|
expect(done.draft.logoDocumentId).toBe('doc-1');
|
||||||
expect(done.dirty).toBe(true);
|
expect(done.dirty).toBe(true);
|
||||||
@@ -138,7 +138,7 @@ describe('org-template.machine', () => {
|
|||||||
tag: 'Upload',
|
tag: 'Upload',
|
||||||
msg: { type: 'UploadRemoved', localId: 'a' },
|
msg: { type: 'UploadRemoved', localId: 'a' },
|
||||||
}),
|
}),
|
||||||
'loaded',
|
'Loaded',
|
||||||
);
|
);
|
||||||
expect(removed.draft.logoDocumentId).toBeUndefined();
|
expect(removed.draft.logoDocumentId).toBeUndefined();
|
||||||
expect(removed.dirty).toBe(true);
|
expect(removed.dirty).toBe(true);
|
||||||
@@ -154,10 +154,50 @@ describe('org-template.machine', () => {
|
|||||||
tag: 'DraftLoaded',
|
tag: 'DraftLoaded',
|
||||||
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
|
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
|
||||||
}),
|
}),
|
||||||
'loaded',
|
'Loaded',
|
||||||
);
|
);
|
||||||
expect(switched.upload.categories).toHaveLength(1);
|
expect(switched.upload.categories).toHaveLength(1);
|
||||||
expect(switched.upload.uploads).toHaveLength(0);
|
expect(switched.upload.uploads).toHaveLength(0);
|
||||||
expect(switched.subOrgId).toBe('cibg-vakbekwaamheid');
|
expect(switched.subOrgId).toBe('cibg-vakbekwaamheid');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- the action lifecycle + publish impact-confirm gate, folded into one union (RD-13) ---
|
||||||
|
|
||||||
|
it('PublishRequested moves a loaded template to ConfirmingPublish', () => {
|
||||||
|
const s = expectTag(reduce(loaded(), { tag: 'PublishRequested' }), 'Loaded');
|
||||||
|
expect(s.action).toEqual({ tag: 'ConfirmingPublish' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PublishCancelled returns to Idle', () => {
|
||||||
|
const confirming = reduce(loaded(), { tag: 'PublishRequested' });
|
||||||
|
const s = expectTag(reduce(confirming, { tag: 'PublishCancelled' }), 'Loaded');
|
||||||
|
expect(s.action).toEqual({ tag: 'Idle' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ActionStarted from ConfirmingPublish goes to Busy, so confirming and busy cannot coexist', () => {
|
||||||
|
const confirming = expectTag(reduce(loaded(), { tag: 'PublishRequested' }), 'Loaded');
|
||||||
|
expect(confirming.action.tag).toBe('ConfirmingPublish');
|
||||||
|
const s = expectTag(reduce(confirming, { tag: 'ActionStarted' }), 'Loaded');
|
||||||
|
expect(s.action).toEqual({ tag: 'Busy' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ActionFailed carries the error', () => {
|
||||||
|
const busy = reduce(loaded(), { tag: 'ActionStarted' });
|
||||||
|
const s = expectTag(reduce(busy, { tag: 'ActionFailed', error: 'mislukt' }), 'Loaded');
|
||||||
|
expect(s.action).toEqual({ tag: 'Failed', error: 'mislukt' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DraftLoaded resets a stale action error to Idle', () => {
|
||||||
|
const failed = reduce(loaded(), { tag: 'ActionFailed', error: 'mislukt' });
|
||||||
|
const s = expectTag(reduce(failed, { tag: 'DraftLoaded', view: view() }), 'Loaded');
|
||||||
|
expect(s.action).toEqual({ tag: 'Idle' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an action message is a no-op when the template is not loaded', () => {
|
||||||
|
expect(reduce({ tag: 'Loading' }, { tag: 'PublishRequested' })).toEqual({ tag: 'Loading' });
|
||||||
|
expect(reduce({ tag: 'Loading' }, { tag: 'ActionStarted' })).toEqual({ tag: 'Loading' });
|
||||||
|
expect(reduce({ tag: 'Loading' }, { tag: 'ActionFailed', error: 'x' })).toEqual({
|
||||||
|
tag: 'Loading',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { assertNever } from '@shared/kernel/fp';
|
import { assertNever } from '@shared/kernel/fp';
|
||||||
import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template';
|
import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template';
|
||||||
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/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
|
||||||
* the composable upload sub-machine folded in, exactly like the wizards fold
|
* the composable upload sub-machine folded in, exactly like the wizards fold
|
||||||
* `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`.
|
* `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`.
|
||||||
|
*
|
||||||
|
* `action` (RD-13) owns the one-shot action lifecycle AND the publish impact-confirm
|
||||||
|
* gate as ONE four-variant union, replacing two independent store-level signals
|
||||||
|
* (`actionState` + `pendingPublish`). Before RD-13, `pendingPublish === true && busy
|
||||||
|
* === true` was representable and meaningless — the confirm dialog could show while a
|
||||||
|
* publish was already in flight. A single field with one tag at a time makes that
|
||||||
|
* combination unrepresentable.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** The org-identity text fields editable directly on the letter canvas. */
|
/** The org-identity text fields editable directly on the letter canvas. */
|
||||||
@@ -21,11 +28,22 @@ export type OrgTemplateTextField =
|
|||||||
| 'signatureRole'
|
| 'signatureRole'
|
||||||
| 'signatureClosing';
|
| 'signatureClosing';
|
||||||
|
|
||||||
|
/** The one-shot action lifecycle (publish/rollback/proefbrief), plus the publish
|
||||||
|
impact-confirm gate, owned by the reducer instead of two independent store-level
|
||||||
|
signals (RD-13). `ConfirmingPublish` is a variant of this SAME union, so
|
||||||
|
"confirming a publish while one is already in flight" is unrepresentable — no
|
||||||
|
state can ever carry both at once. */
|
||||||
|
export type OrgTemplateActionState =
|
||||||
|
| { tag: 'Idle' }
|
||||||
|
| { tag: 'ConfirmingPublish' }
|
||||||
|
| { tag: 'Busy' }
|
||||||
|
| { tag: 'Failed'; error: string };
|
||||||
|
|
||||||
export type OrgTemplateState =
|
export type OrgTemplateState =
|
||||||
| { tag: 'loading' }
|
| { tag: 'Loading' }
|
||||||
| { tag: 'failed'; reason: string }
|
| { tag: 'Failed'; reason: string }
|
||||||
| {
|
| {
|
||||||
tag: 'loaded';
|
tag: 'Loaded';
|
||||||
subOrgId: string;
|
subOrgId: string;
|
||||||
draft: OrgTemplate;
|
draft: OrgTemplate;
|
||||||
publishedVersion: number;
|
publishedVersion: number;
|
||||||
@@ -34,9 +52,10 @@ export type OrgTemplateState =
|
|||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
/** Logo upload sub-state (single file, `org-logo` category). */
|
/** Logo upload sub-state (single file, `org-logo` category). */
|
||||||
upload: UploadState;
|
upload: UploadState;
|
||||||
|
action: OrgTemplateActionState;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initial: OrgTemplateState = { tag: 'loading' };
|
export const initial: OrgTemplateState = { tag: 'Loading' };
|
||||||
|
|
||||||
export type OrgTemplateMsg =
|
export type OrgTemplateMsg =
|
||||||
| { tag: 'Loading' }
|
| { tag: 'Loading' }
|
||||||
@@ -47,22 +66,27 @@ export type OrgTemplateMsg =
|
|||||||
/** Carries the draft that was saved: clears `dirty` only if no edit landed during
|
/** Carries the draft that was saved: clears `dirty` only if no edit landed during
|
||||||
the round-trip (reference-equal), so a concurrent edit keeps its pending save. */
|
the round-trip (reference-equal), so a concurrent edit keeps its pending save. */
|
||||||
| { tag: 'DraftSaved'; savedDraft: OrgTemplate }
|
| { tag: 'DraftSaved'; savedDraft: OrgTemplate }
|
||||||
| { tag: 'Upload'; msg: UploadMsg };
|
| { tag: 'Upload'; msg: UploadMsg }
|
||||||
|
| { tag: 'PublishRequested' } // opens the publish impact-confirm gate
|
||||||
|
| { tag: 'PublishCancelled' } // closes it without publishing
|
||||||
|
| { tag: 'ActionStarted' } // a one-shot action (publish/rollback/proefbrief) began
|
||||||
|
| { tag: 'ActionFinished' } // it completed successfully
|
||||||
|
| { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show
|
||||||
|
|
||||||
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */
|
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */
|
||||||
function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState {
|
function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState {
|
||||||
return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
|
return s.tag === 'Loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState {
|
export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState {
|
||||||
switch (m.tag) {
|
switch (m.tag) {
|
||||||
case 'Loading':
|
case 'Loading':
|
||||||
return { tag: 'loading' };
|
return { tag: 'Loading' };
|
||||||
case 'LoadFailed':
|
case 'LoadFailed':
|
||||||
return { tag: 'failed', reason: m.reason };
|
return { tag: 'Failed', reason: m.reason };
|
||||||
case 'DraftLoaded':
|
case 'DraftLoaded':
|
||||||
return {
|
return {
|
||||||
tag: 'loaded',
|
tag: 'Loaded',
|
||||||
subOrgId: m.view.draft.subOrgId,
|
subOrgId: m.view.draft.subOrgId,
|
||||||
draft: m.view.draft,
|
draft: m.view.draft,
|
||||||
publishedVersion: m.view.publishedVersion,
|
publishedVersion: m.view.publishedVersion,
|
||||||
@@ -71,16 +95,19 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
|
|||||||
dirty: false,
|
dirty: false,
|
||||||
// Keep the loaded logo category across sub-org switches (it's the same
|
// Keep the loaded logo category across sub-org switches (it's the same
|
||||||
// `org-logo` category, loaded once); drop only any in-flight/finished uploads.
|
// `org-logo` category, loaded once); drop only any in-flight/finished uploads.
|
||||||
upload: s.tag === 'loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
|
upload: s.tag === 'Loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
|
||||||
|
// A fresh load clears a stale action error rather than letting it outlive
|
||||||
|
// the reload (RD-13, same as brief's RD-12).
|
||||||
|
action: { tag: 'Idle' },
|
||||||
};
|
};
|
||||||
case 'FieldEdited':
|
case 'FieldEdited':
|
||||||
return editDraft(s, (d) => ({ ...d, [m.field]: m.value }));
|
return editDraft(s, (d) => ({ ...d, [m.field]: m.value }));
|
||||||
case 'MarginEdited':
|
case 'MarginEdited':
|
||||||
return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } }));
|
return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } }));
|
||||||
case 'DraftSaved':
|
case 'DraftSaved':
|
||||||
return s.tag === 'loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
|
return s.tag === 'Loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
|
||||||
case 'Upload': {
|
case 'Upload': {
|
||||||
if (s.tag !== 'loaded') return s;
|
if (s.tag !== 'Loaded') return s;
|
||||||
const upload = reduceUpload(s.upload, m.msg);
|
const upload = reduceUpload(s.upload, m.msg);
|
||||||
// A completed/removed logo upload also updates the draft's logoDocumentId.
|
// A completed/removed logo upload also updates the draft's logoDocumentId.
|
||||||
if (m.msg.type === 'UploadComplete')
|
if (m.msg.type === 'UploadComplete')
|
||||||
@@ -96,6 +123,22 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
|
|||||||
}
|
}
|
||||||
return { ...s, upload };
|
return { ...s, upload };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The action lifecycle (RD-13): a no-op unless a template is loaded, since there
|
||||||
|
// is nothing to attach the action state to otherwise. `ConfirmingPublish` and
|
||||||
|
// `Busy` are variants of one field, so ActionStarted overwriting it to `Busy` is
|
||||||
|
// what makes the two mutually exclusive by construction — not by convention.
|
||||||
|
case 'PublishRequested':
|
||||||
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'ConfirmingPublish' } } : s;
|
||||||
|
case 'PublishCancelled':
|
||||||
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
|
||||||
|
case 'ActionStarted':
|
||||||
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s;
|
||||||
|
case 'ActionFinished':
|
||||||
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
|
||||||
|
case 'ActionFailed':
|
||||||
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return assertNever(m);
|
return assertNever(m);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { runSubmit } from '@shared/application/submit';
|
import { runSubmit } from '@shared/application/submit';
|
||||||
|
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||||
import {
|
import {
|
||||||
ApiClient,
|
ApiClient,
|
||||||
BriefDecisionsDto,
|
BriefDecisionsDto,
|
||||||
@@ -33,8 +34,13 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric
|
|||||||
* The only place brief HTTP lives (ADR-0001 anti-corruption boundary). The wire
|
* The only place brief HTTP lives (ADR-0001 anti-corruption boundary). The wire
|
||||||
* uses FLAT unions (a `type`/`tag` string + nullable fields, the repo convention);
|
* uses FLAT unions (a `type`/`tag` string + nullable fields, the repo convention);
|
||||||
* the `parse*` boundary narrows them into the domain's proper discriminated unions
|
* the `parse*` boundary narrows them into the domain's proper discriminated unions
|
||||||
* and rejects malformed shapes. Mutations go through `runSubmit` (ProblemDetails →
|
* and rejects malformed shapes. Every mutation folds through `runSubmit`
|
||||||
* error string), then parse the returned brief.
|
* (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
|
||||||
|
* shared `runResult` fold, because it needs one extra bit `runResult` throws away:
|
||||||
|
* whether the failure was an HTTP 404 (see `BriefLoadFailure` — CQ-007's
|
||||||
|
* expand half). Today's backend never 404s `GET /brief`, so the
|
||||||
|
* `notFound` branch is unreached until it does; this adapter is ready in advance.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface BriefView {
|
export interface BriefView {
|
||||||
@@ -45,16 +51,39 @@ export interface BriefView {
|
|||||||
readonly caseContext: CaseContext;
|
readonly caseContext: CaseContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why `load()` did not return a brief. `notFound` is a bare HTTP 404 — kept
|
||||||
|
* distinct from every other failure so `BriefStore.load()` can tolerate it (call
|
||||||
|
* `reset()` instead of showing an error banner) without conflating it with a real
|
||||||
|
* failure. See the class docstring above.
|
||||||
|
*/
|
||||||
|
export type BriefLoadFailure =
|
||||||
|
{ readonly tag: 'notFound' } | { readonly tag: 'error'; readonly reason: string };
|
||||||
|
|
||||||
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
|
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
|
||||||
export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
|
export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
|
||||||
|
|
||||||
|
/** True when the thrown value carries an HTTP 404 status — matches both the
|
||||||
|
generic `SwaggerException` (today's shape, since `GET /brief` declares no 404
|
||||||
|
response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once
|
||||||
|
the endpoint gets a documented 404 response). */
|
||||||
|
function isHttpNotFound(e: unknown): boolean {
|
||||||
|
return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class BriefAdapter {
|
export class BriefAdapter {
|
||||||
private client = inject(ApiClient);
|
private client = inject(ApiClient);
|
||||||
|
|
||||||
async load(): Promise<Result<string, BriefView>> {
|
async load(): Promise<Result<BriefLoadFailure, BriefView>> {
|
||||||
const r = await runSubmit(() => this.client.briefGET(), BRIEF_LOAD_FAILED);
|
try {
|
||||||
return r.ok ? parseBriefView(r.value) : r;
|
const dto = await this.client.briefGET();
|
||||||
|
const parsed = parseBriefView(dto);
|
||||||
|
return parsed.ok ? ok(parsed.value) : err({ tag: 'error', reason: parsed.error });
|
||||||
|
} catch (e) {
|
||||||
|
if (isHttpNotFound(e)) return err({ tag: 'notFound' });
|
||||||
|
return err({ tag: 'error', reason: problemDetail(e, BRIEF_LOAD_FAILED) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(sections: readonly LetterSection[]): Promise<Result<string, BriefView>> {
|
async save(sections: readonly LetterSection[]): Promise<Result<string, BriefView>> {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { errorMessage, PREVIEW_FAILED, LetterPreviewAdapter } from './letter-preview.adapter';
|
||||||
|
|
||||||
|
// Minimal Response stand-in — errorMessage only calls `.json()`. Avoids stubbing
|
||||||
|
// globalThis.fetch to reach this trust boundary (TE-002).
|
||||||
|
const fakeResponse = (body: unknown): Response =>
|
||||||
|
({ json: () => Promise.resolve(body) }) as unknown as Response;
|
||||||
|
|
||||||
|
describe('errorMessage (TE-002 trust boundary)', () => {
|
||||||
|
it('surfaces the ProblemDetails detail when present', async () => {
|
||||||
|
expect(await errorMessage(fakeResponse({ detail: 'Geen toegang.', status: 403 }))).toBe(
|
||||||
|
'Geen toegang.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to PREVIEW_FAILED when the body has no detail', async () => {
|
||||||
|
expect(await errorMessage(fakeResponse({ status: 500 }))).toBe(PREVIEW_FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to PREVIEW_FAILED when the body is not JSON', async () => {
|
||||||
|
const res = { json: () => Promise.reject(new Error('not json')) } as unknown as Response;
|
||||||
|
expect(await errorMessage(res)).toBe(PREVIEW_FAILED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a
|
||||||
|
// production build. There is no ambient type for it in app code, so this is
|
||||||
|
// accessed through an untyped bag rather than a `declare const`.
|
||||||
|
const globals = globalThis as Record<string, unknown>;
|
||||||
|
const originalNgDevMode = globals['ngDevMode'];
|
||||||
|
const setDevMode = (on: boolean) => {
|
||||||
|
globals['ngDevMode'] = on;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('LetterPreviewAdapter.preview (BIO-012)', () => {
|
||||||
|
const okResponse = () =>
|
||||||
|
({ ok: true, blob: () => Promise.resolve(new Blob()) }) as unknown as Response;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globals['ngDevMode'] = originalNgDevMode;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
history.pushState({}, '', '/');
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends no X-Role/X-Subject headers outside isDevMode()', async () => {
|
||||||
|
setDevMode(false);
|
||||||
|
history.pushState({}, '', '/?subject=111222333');
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
await new LetterPreviewAdapter().preview();
|
||||||
|
|
||||||
|
expect(fetchSpy.mock.calls[0][1].headers).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends X-Role (and X-Subject when known) under isDevMode()', async () => {
|
||||||
|
setDevMode(true);
|
||||||
|
history.pushState({}, '', '/?subject=111222333');
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
await new LetterPreviewAdapter().preview();
|
||||||
|
|
||||||
|
const headers = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
|
||||||
|
expect(headers['X-Role']).toBeDefined();
|
||||||
|
expect(headers['X-Subject']).toBe('111222333');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, isDevMode } from '@angular/core';
|
||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { currentRole } from '@shared/infrastructure/role';
|
import { currentRole } from '@shared/infrastructure/role';
|
||||||
|
import { currentSubject } from '@shared/infrastructure/subject';
|
||||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||||
import { environment } from '@shared/environments/environment';
|
import { environment } from '@shared/environments/environment';
|
||||||
|
|
||||||
@@ -12,15 +13,42 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
|
|||||||
* `/brief/preview` returns `text/html`, not JSON, and is `.ExcludeFromDescription()`'d
|
* `/brief/preview` returns `text/html`, not JSON, and is `.ExcludeFromDescription()`'d
|
||||||
* 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`, so `X-Role` is set here explicitly.
|
* `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set
|
||||||
|
* here explicitly (without `X-Subject` this always previewed
|
||||||
|
* `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
|
||||||
|
* `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).
|
||||||
|
*
|
||||||
|
* `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
|
||||||
|
* draft → sent. Explicitly bypassing the HTTP cache is the correct default for any
|
||||||
|
* mutable resource served under one unversioned URL — independent of the
|
||||||
|
* identity work above, and not a complete fix by itself: see the KNOWN GAP note below.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* 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
|
||||||
|
* same moment correctly returned the frozen, unwatermarked archive. `cache: 'no-store'`
|
||||||
|
* 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
|
||||||
|
* read path, reproducible for MULTIPLE distinct owners and NOT reproducible for
|
||||||
|
* `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
|
||||||
|
* `zorgverlener` identity until this is root-caused).
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class LetterPreviewAdapter {
|
export class LetterPreviewAdapter {
|
||||||
async preview(): Promise<Result<string, Blob>> {
|
async preview(): Promise<Result<string, Blob>> {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
|
const subject = currentSubject();
|
||||||
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
|
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
|
||||||
headers: { 'X-Role': currentRole() },
|
cache: 'no-store',
|
||||||
|
headers: isDevMode()
|
||||||
|
? { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) }
|
||||||
|
: {},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return err(PREVIEW_FAILED);
|
return err(PREVIEW_FAILED);
|
||||||
@@ -30,7 +58,9 @@ export class LetterPreviewAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function errorMessage(res: Response): Promise<string> {
|
/** Trust boundary (TE-002): maps a non-OK response to a message. Exported so a spec
|
||||||
|
can call it directly instead of stubbing `globalThis.fetch`. */
|
||||||
|
export async function errorMessage(res: Response): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return problemDetail(await res.json(), PREVIEW_FAILED);
|
return problemDetail(await res.json(), PREVIEW_FAILED);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client';
|
import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client';
|
||||||
import { parseOrgTemplateAdminView } from './org-template.adapter';
|
import {
|
||||||
|
parseOrgTemplateAdminView,
|
||||||
|
proefbriefErrorMessage,
|
||||||
|
PROEFBRIEF_FAILED,
|
||||||
|
} from './org-template.adapter';
|
||||||
|
|
||||||
const draft: OrgTemplateDto = {
|
const draft: OrgTemplateDto = {
|
||||||
subOrgId: 'cibg-registers',
|
subOrgId: 'cibg-registers',
|
||||||
@@ -54,3 +58,25 @@ describe('parseOrgTemplateAdminView', () => {
|
|||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Minimal Response stand-in — proefbriefErrorMessage only calls `.json()`. Avoids
|
||||||
|
// stubbing globalThis.fetch to reach this trust boundary (TE-002).
|
||||||
|
const fakeResponse = (body: unknown): Response =>
|
||||||
|
({ json: () => Promise.resolve(body) }) as unknown as Response;
|
||||||
|
|
||||||
|
describe('proefbriefErrorMessage (TE-002 trust boundary)', () => {
|
||||||
|
it('surfaces the ProblemDetails detail when present', async () => {
|
||||||
|
expect(
|
||||||
|
await proefbriefErrorMessage(fakeResponse({ detail: 'Niet gevonden.', status: 404 })),
|
||||||
|
).toBe('Niet gevonden.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to PROEFBRIEF_FAILED when the body has no detail', async () => {
|
||||||
|
expect(await proefbriefErrorMessage(fakeResponse({ status: 500 }))).toBe(PROEFBRIEF_FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to PROEFBRIEF_FAILED when the body is not JSON', async () => {
|
||||||
|
const res = { json: () => Promise.reject(new Error('not json')) } as unknown as Response;
|
||||||
|
expect(await proefbriefErrorMessage(res)).toBe(PROEFBRIEF_FAILED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject, isDevMode } from '@angular/core';
|
||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { runSubmit } from '@shared/application/submit';
|
import { runResult, runSubmit } from '@shared/application/submit';
|
||||||
import { currentRole } from '@shared/infrastructure/role';
|
import { currentRole } from '@shared/infrastructure/role';
|
||||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||||
import { environment } from '@shared/environments/environment';
|
import { environment } from '@shared/environments/environment';
|
||||||
@@ -26,17 +26,20 @@ import { parseOrgTemplate } from '@brief/infrastructure/brief.adapter';
|
|||||||
* rollback go through the generated client (X-Role added by `roleInterceptor`);
|
* rollback go through the generated client (X-Role added by `roleInterceptor`);
|
||||||
* `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and
|
* `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and
|
||||||
* `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.
|
* `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.
|
||||||
|
* `X-Role` there is a dev-only identity stand-in (`role.ts`) and is sent only under
|
||||||
|
* `isDevMode()`, mirroring `roleInterceptor`'s own dev-only registration — a production
|
||||||
|
* build never sends it from this hand-written call either (BIO-012).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
|
const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
|
||||||
const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
|
export const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class OrgTemplateAdapter {
|
export class OrgTemplateAdapter {
|
||||||
private client = inject(ApiClient);
|
private client = inject(ApiClient);
|
||||||
|
|
||||||
async list(): Promise<Result<string, SubOrgSummary[]>> {
|
async list(): Promise<Result<string, SubOrgSummary[]>> {
|
||||||
const r = await runSubmit(() => this.client.orgTemplates(), FAILED);
|
const r = await runResult(() => this.client.orgTemplates(), FAILED);
|
||||||
if (!r.ok) return r;
|
if (!r.ok) return r;
|
||||||
const out: SubOrgSummary[] = [];
|
const out: SubOrgSummary[] = [];
|
||||||
for (const s of r.value ?? []) {
|
for (const s of r.value ?? []) {
|
||||||
@@ -48,7 +51,7 @@ export class OrgTemplateAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async load(subOrgId: string): Promise<Result<string, OrgTemplateAdminView>> {
|
async load(subOrgId: string): Promise<Result<string, OrgTemplateAdminView>> {
|
||||||
const r = await runSubmit(() => this.client.orgTemplateGET(subOrgId), FAILED);
|
const r = await runResult(() => this.client.orgTemplateGET(subOrgId), FAILED);
|
||||||
return r.ok ? parseAdminView(r.value) : r;
|
return r.ok ? parseAdminView(r.value) : r;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,22 +79,26 @@ export class OrgTemplateAdapter {
|
|||||||
try {
|
try {
|
||||||
res = await fetch(
|
res = await fetch(
|
||||||
`${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,
|
`${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,
|
||||||
{ headers: { 'X-Role': currentRole() } },
|
{ headers: isDevMode() ? { 'X-Role': currentRole() } : {} },
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return err(PROEFBRIEF_FAILED);
|
return err(PROEFBRIEF_FAILED);
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) return err(await proefbriefErrorMessage(res));
|
||||||
try {
|
|
||||||
return err(problemDetail(await res.json(), PROEFBRIEF_FAILED));
|
|
||||||
} catch {
|
|
||||||
return err(PROEFBRIEF_FAILED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ok(await res.blob());
|
return ok(await res.blob());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Trust boundary (TE-002): maps a non-OK proefbrief response to a message. Exported
|
||||||
|
so a spec can call it directly instead of stubbing `globalThis.fetch`. */
|
||||||
|
export async function proefbriefErrorMessage(res: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
return problemDetail(await res.json(), PROEFBRIEF_FAILED);
|
||||||
|
} catch {
|
||||||
|
return PROEFBRIEF_FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- parse: wire → domain, validating at the boundary ---
|
// --- parse: wire → domain, validating at the boundary ---
|
||||||
|
|
||||||
function parseSubOrg(dto: SubOrgSummaryDto): Result<string, SubOrgSummary> {
|
function parseSubOrg(dto: SubOrgSummaryDto): Result<string, SubOrgSummary> {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { parseRevealed, REVEAL_FAILED, RevealBigNummerAdapter } from './reveal-bignummer.adapter';
|
||||||
|
|
||||||
|
describe('parseRevealed (TE-002 trust boundary)', () => {
|
||||||
|
it('accepts a well-formed body', () => {
|
||||||
|
const r = parseRevealed({ bigNummer: '12345678' });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) expect(r.value).toBe('12345678');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The finding's own named case: a numeric bigNummer must be rejected, not
|
||||||
|
// coerced — this is a PII reveal, not a display formatter.
|
||||||
|
it('rejects a bigNummer sent as a number', () => {
|
||||||
|
const r = parseRevealed({ bigNummer: 42 });
|
||||||
|
expect(r).toEqual({ ok: false, error: REVEAL_FAILED });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing bigNummer field', () => {
|
||||||
|
expect(parseRevealed({}).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects null and non-object bodies', () => {
|
||||||
|
expect(parseRevealed(null).ok).toBe(false);
|
||||||
|
expect(parseRevealed(undefined).ok).toBe(false);
|
||||||
|
expect(parseRevealed('12345678').ok).toBe(false);
|
||||||
|
expect(parseRevealed(42).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a
|
||||||
|
// production build. There is no ambient type for it in app code, so this is
|
||||||
|
// accessed through an untyped bag rather than a `declare const`.
|
||||||
|
const globals = globalThis as Record<string, unknown>;
|
||||||
|
const originalNgDevMode = globals['ngDevMode'];
|
||||||
|
const setDevMode = (on: boolean) => {
|
||||||
|
globals['ngDevMode'] = on;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RevealBigNummerAdapter.reveal (BIO-006a + BIO-012)', () => {
|
||||||
|
const okResponse = () =>
|
||||||
|
({ ok: true, json: () => Promise.resolve({ bigNummer: '12345678' }) }) as unknown as Response;
|
||||||
|
|
||||||
|
beforeEach(() => setDevMode(true));
|
||||||
|
afterEach(() => {
|
||||||
|
globals['ngDevMode'] = originalNgDevMode;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends X-Step-Up only when the caller passes stepUp: true', async () => {
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
await new RevealBigNummerAdapter().reveal(false);
|
||||||
|
const headersWithoutStepUp = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
|
||||||
|
expect(headersWithoutStepUp['X-Step-Up']).toBeUndefined();
|
||||||
|
|
||||||
|
await new RevealBigNummerAdapter().reveal(true);
|
||||||
|
const headersWithStepUp = fetchSpy.mock.calls[1][1].headers as Record<string, string>;
|
||||||
|
expect(headersWithStepUp['X-Step-Up']).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends X-Role only under isDevMode()', async () => {
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
setDevMode(false);
|
||||||
|
await new RevealBigNummerAdapter().reveal(true);
|
||||||
|
const prodHeaders = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
|
||||||
|
expect(prodHeaders['X-Role']).toBeUndefined();
|
||||||
|
expect(prodHeaders['X-Step-Up']).toBe('true'); // step-up is not a dev-only hatch
|
||||||
|
|
||||||
|
setDevMode(true);
|
||||||
|
await new RevealBigNummerAdapter().reveal(true);
|
||||||
|
const devHeaders = fetchSpy.mock.calls[1][1].headers as Record<string, string>;
|
||||||
|
expect(devHeaders['X-Role']).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,36 +1,52 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, isDevMode } from '@angular/core';
|
||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { currentRole } from '@shared/infrastructure/role';
|
import { currentRole } from '@shared/infrastructure/role';
|
||||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||||
import { environment } from '@shared/environments/environment';
|
import { environment } from '@shared/environments/environment';
|
||||||
|
|
||||||
const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
|
/** Exported so specs can assert against the same message id instead of retyping the
|
||||||
|
Dutch sentence (matches `letter-preview.adapter.ts`'s `PREVIEW_FAILED`). */
|
||||||
|
export const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked;
|
* Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked;
|
||||||
* this unmasks it, gated server-side by the reveal capability AND a step-up. The
|
* this unmasks it, gated server-side by the reveal capability AND a step-up. The
|
||||||
* step-up is stubbed as the `X-Step-Up` header — the caller sends it only after the
|
* step-up is stubbed as the `X-Step-Up` header, sent only when the caller passes
|
||||||
* user's confirm gesture, so a plain call (or a role without the capability) 403s.
|
* `stepUp: true` — `BriefStore.revealBigNummer()` is the only caller and it is only
|
||||||
|
* ever reachable after `behandel-scherm.component.ts`'s `onReveal()` confirm gesture,
|
||||||
|
* so the header now reflects that gesture instead of being a constant baked into this
|
||||||
|
* adapter (BIO-006a — a call that skips confirmation sends no step-up at all).
|
||||||
*
|
*
|
||||||
* Hand-written fetch (not the `ApiClient`) because the call needs a per-request header;
|
* Hand-written fetch (not the `ApiClient`) because the call needs a per-request header;
|
||||||
* `.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the
|
* `.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the
|
||||||
* same seam as `/brief/preview` and uploads — which also means `X-Role` is set here.
|
* same seam as `/brief/preview` and uploads. `X-Role` is a dev-only identity stand-in
|
||||||
|
* (see `role.ts`) and is therefore only sent under `isDevMode()`, mirroring the
|
||||||
|
* `roleInterceptor` registration in `app.config.ts` — a production build never sends it
|
||||||
|
* from this hand-written call either (BIO-012).
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class RevealBigNummerAdapter {
|
export class RevealBigNummerAdapter {
|
||||||
async reveal(): Promise<Result<string, string>> {
|
async reveal(stepUp: boolean): Promise<Result<string, string>> {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, {
|
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' },
|
headers: {
|
||||||
|
...(isDevMode() ? { 'X-Role': currentRole() } : {}),
|
||||||
|
...(stepUp ? { 'X-Step-Up': 'true' } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return err(REVEAL_FAILED);
|
return err(REVEAL_FAILED);
|
||||||
}
|
}
|
||||||
if (!res.ok) return err(await errorMessage(res));
|
if (!res.ok) return err(await errorMessage(res));
|
||||||
const body: unknown = await res.json().catch(() => null);
|
return parseRevealed(await res.json().catch(() => null));
|
||||||
// Trust boundary: validate the shape before handing back a plain string.
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trust boundary: validate the untrusted response shape before handing back a plain
|
||||||
|
string (TE-002) — exported so a spec can call it without stubbing `globalThis.fetch`. */
|
||||||
|
export function parseRevealed(body: unknown): Result<string, string> {
|
||||||
if (
|
if (
|
||||||
typeof body === 'object' &&
|
typeof body === 'object' &&
|
||||||
body !== null &&
|
body !== null &&
|
||||||
@@ -40,7 +56,6 @@ export class RevealBigNummerAdapter {
|
|||||||
}
|
}
|
||||||
return err(REVEAL_FAILED);
|
return err(REVEAL_FAILED);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function errorMessage(res: Response): Promise<string> {
|
async function errorMessage(res: Response): Promise<string> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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,19 +167,19 @@ 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(() => {
|
||||||
const s = this.model();
|
const s = this.model();
|
||||||
return s.tag === 'loaded' ? s : undefined;
|
return s.tag === 'Loaded' ? s : undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
protected reload() {
|
protected reload() {
|
||||||
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,3 +1,6 @@
|
|||||||
|
/* 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,
|
||||||
@@ -11,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';
|
||||||
@@ -21,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 = {
|
||||||
@@ -39,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;
|
||||||
|
|
||||||
@@ -52,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 {
|
||||||
@@ -81,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);
|
||||||
@@ -97,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);
|
||||||
}
|
}
|
||||||
@@ -136,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
|
||||||
>
|
>
|
||||||
@@ -174,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
|
||||||
@@ -188,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>
|
||||||
@@ -200,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 {
|
||||||
@@ -223,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>
|
||||||
@@ -241,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>
|
||||||
}
|
}
|
||||||
@@ -261,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>
|
||||||
}
|
}
|
||||||
@@ -288,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 {
|
||||||
@@ -316,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 {
|
||||||
@@ -333,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>
|
||||||
@@ -344,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());
|
||||||
@@ -359,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());
|
||||||
@@ -394,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');
|
||||||
|
|
||||||
@@ -417,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) ---
|
||||||
|
|
||||||
@@ -444,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,12 +1,9 @@
|
|||||||
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 { UploadState } from '@shared/domain/upload.machine';
|
||||||
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/upload/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,
|
||||||
@@ -17,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: [
|
||||||
`
|
`
|
||||||
@@ -122,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;
|
||||||
@@ -153,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()">
|
||||||
@@ -190,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
|
|
||||||
inputId="org-logo-input"
|
|
||||||
[accept]="logoCategory()!.acceptedTypes"
|
|
||||||
[maxSizeMb]="logoCategory()!.maxSizeMb"
|
|
||||||
[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()"
|
[previewUrlFor]="previewUrlFor()"
|
||||||
(remove)="logoRemoved.emit(u.localId)"
|
(logoSelected)="logoSelected.emit($event)"
|
||||||
(retry)="logoRetry.emit(u.localId)"
|
(logoRemoved)="logoRemoved.emit($event)"
|
||||||
></li>
|
(logoRetry)="logoRetry.emit($event)"
|
||||||
}
|
/>
|
||||||
</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>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -299,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);
|
||||||
}
|
}
|
||||||
@@ -333,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.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/angular';
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
import { OrgTemplateEditorComponent } from './org-template-editor.component';
|
import { OrgTemplateEditorComponent } from './org-template-editor.component';
|
||||||
import { OrgTemplate, OrgTemplateVersion, SubOrgSummary } from '@brief/domain/org-template';
|
import { OrgTemplate, OrgTemplateVersion, SubOrgSummary } from '@brief/domain/org-template';
|
||||||
import { UploadState, initialUpload } from '@shared/upload/upload.machine';
|
import { UploadState, initialUpload } from '@shared/domain/upload.machine';
|
||||||
|
|
||||||
const draft: OrgTemplate = {
|
const draft: OrgTemplate = {
|
||||||
subOrgId: 'cibg-registers',
|
subOrgId: 'cibg-registers',
|
||||||
@@ -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,14 +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 { UploadAdapter } from '@shared/upload/upload.adapter';
|
|
||||||
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({
|
||||||
@@ -86,10 +85,9 @@ import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-te
|
|||||||
export class OrgTemplatePage {
|
export class OrgTemplatePage {
|
||||||
protected store = inject(OrgTemplateStore);
|
protected store = inject(OrgTemplateStore);
|
||||||
protected access = inject(AccessStore);
|
protected access = inject(AccessStore);
|
||||||
private uploadAdapter = inject(UploadAdapter);
|
|
||||||
|
|
||||||
protected canEdit = computed(() => this.access.can('orgtemplate:edit'));
|
protected canEdit = computed(() => this.access.can('orgtemplate:edit'));
|
||||||
protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId);
|
protected previewUrlFor = this.store.previewUrlFor;
|
||||||
|
|
||||||
protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`;
|
protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`;
|
||||||
protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`;
|
protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`;
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
back,
|
back,
|
||||||
gaNaarStap,
|
gaNaarStap,
|
||||||
submit,
|
submit,
|
||||||
|
primary,
|
||||||
resolve,
|
resolve,
|
||||||
reduce,
|
reduce,
|
||||||
WizardState,
|
WizardState,
|
||||||
@@ -104,6 +105,25 @@ describe('wizard.machine', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('primary', () => {
|
||||||
|
it('Primary advances to the next step from a non-final step', () => {
|
||||||
|
const s = toStep2('4160', '200'); // Editing, step 2 — not the final step
|
||||||
|
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Next' }));
|
||||||
|
expect(expectTag(primary(s), 'Editing').step).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Primary submits from the final step', () => {
|
||||||
|
const s = toStep3('4160', '200'); // Editing, step 3 — the final step
|
||||||
|
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Submit' }));
|
||||||
|
expect(primary(s).tag).toBe('Submitting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Primary is a no-op from a non-editing state', () => {
|
||||||
|
const submitting = submit(toStep3('4160', '200'));
|
||||||
|
expect(primary(submitting)).toBe(submitting);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('reduce (message-driven)', () => {
|
describe('reduce (message-driven)', () => {
|
||||||
it('drives the full happy path via messages', () => {
|
it('drives the full happy path via messages', () => {
|
||||||
let s: WizardState = initial;
|
let s: WizardState = initial;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
reduceUpload,
|
reduceUpload,
|
||||||
requiredCategoriesSatisfied,
|
requiredCategoriesSatisfied,
|
||||||
deliveryRefs,
|
deliveryRefs,
|
||||||
} from '@shared/upload/upload.machine';
|
digitalDocumentIds,
|
||||||
|
} from '@shared/domain/upload.machine';
|
||||||
|
|
||||||
/** What the user is typing (raw, possibly invalid). */
|
/** What the user is typing (raw, possibly invalid). */
|
||||||
export interface Draft {
|
export interface Draft {
|
||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +122,13 @@ export function submit(s: WizardState): WizardState {
|
|||||||
return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
|
return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The primary button's action: advance, or submit from the last step. No-op
|
||||||
|
outside Editing — this is the one decision the old component-side handler used to make. */
|
||||||
|
export function primary(s: WizardState): WizardState {
|
||||||
|
if (s.tag !== 'Editing') return s;
|
||||||
|
return s.step === 3 ? submit(s) : next(s);
|
||||||
|
}
|
||||||
|
|
||||||
/** Route an upload sub-message through the pure upload reducer (Editing only). */
|
/** Route an upload sub-message through the pure upload reducer (Editing only). */
|
||||||
export function upload(s: WizardState, msg: UploadMsg): WizardState {
|
export function upload(s: WizardState, msg: UploadMsg): WizardState {
|
||||||
if (s.tag !== 'Editing') return s;
|
if (s.tag !== 'Editing') return s;
|
||||||
@@ -152,6 +160,7 @@ export type WizardMsg =
|
|||||||
| { tag: 'Back' }
|
| { tag: 'Back' }
|
||||||
| { tag: 'GaNaarStap'; step: 1 | 2 | 3 }
|
| { tag: 'GaNaarStap'; step: 1 | 2 | 3 }
|
||||||
| { tag: 'Submit' }
|
| { tag: 'Submit' }
|
||||||
|
| { tag: 'Primary' }
|
||||||
| { tag: 'Retry' }
|
| { tag: 'Retry' }
|
||||||
| { tag: 'SubmitConfirmed' }
|
| { tag: 'SubmitConfirmed' }
|
||||||
| { tag: 'SubmitFailed'; error: string }
|
| { tag: 'SubmitFailed'; error: string }
|
||||||
@@ -170,6 +179,8 @@ export function reduce(s: WizardState, m: WizardMsg): WizardState {
|
|||||||
return gaNaarStap(s, m.step);
|
return gaNaarStap(s, m.step);
|
||||||
case 'Submit':
|
case 'Submit':
|
||||||
return submit(s);
|
return submit(s);
|
||||||
|
case 'Primary':
|
||||||
|
return primary(s);
|
||||||
case 'Retry':
|
case 'Retry':
|
||||||
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||||
case 'SubmitConfirmed':
|
case 'SubmitConfirmed':
|
||||||
|
|||||||
@@ -147,6 +147,35 @@ describe('intake acceptance journeys', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('raising uren above the threshold after answering scholing drops both fields', () => {
|
||||||
|
// Given a journey that answered the scholing question while uren was low.
|
||||||
|
const atReview = givenIntake(
|
||||||
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||||
|
{ tag: 'Next' },
|
||||||
|
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||||
|
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||||
|
{ tag: 'SetAnswer', key: 'punten', value: '150' },
|
||||||
|
{ tag: 'Next' },
|
||||||
|
);
|
||||||
|
expect(atReview.tag).toBe('Answering');
|
||||||
|
expect(atReview.tag === 'Answering' && atReview.cursor).toBe(2); // review
|
||||||
|
|
||||||
|
// When the user goes back and raises uren above the threshold, then submits...
|
||||||
|
const done = given(reduce, atReview)(
|
||||||
|
{ tag: 'GaNaarStap', cursor: 1 },
|
||||||
|
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
|
||||||
|
{ tag: 'Next' }, // the now-hidden scholing question no longer blocks
|
||||||
|
{ tag: 'Submit' },
|
||||||
|
{ tag: 'SubmitConfirmed' },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Then the submission succeeds, and BOTH the stale answer and its punten are gone —
|
||||||
|
// exactly the crafted-POST-shaped payload the server rule rejects.
|
||||||
|
expect(done.tag).toBe('Submitted');
|
||||||
|
expect(done.tag === 'Submitted' && done.data.aanvullendeScholing).toBeUndefined();
|
||||||
|
expect(done.tag === 'Submitted' && done.data.punten).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('SetPolicy (server-owned threshold) can turn an already-answered uren into one that now requires scholing', () => {
|
it('SetPolicy (server-owned threshold) can turn an already-answered uren into one that now requires scholing', () => {
|
||||||
const atWerkStep = givenIntake(
|
const atWerkStep = givenIntake(
|
||||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { ok, err } from '@shared/kernel/fp';
|
import { ok, err } from '@shared/kernel/fp';
|
||||||
import { expectTag } from '@shared/testing/expect-tag';
|
import { expectTag } from '@shared/testing/expect-tag';
|
||||||
import {
|
import {
|
||||||
Answers,
|
|
||||||
initial,
|
initial,
|
||||||
STEPS,
|
STEPS,
|
||||||
lageUren,
|
lageUren,
|
||||||
@@ -11,18 +10,12 @@ import {
|
|||||||
back,
|
back,
|
||||||
gaNaarStap,
|
gaNaarStap,
|
||||||
submit,
|
submit,
|
||||||
|
primary,
|
||||||
resolve,
|
resolve,
|
||||||
reduce,
|
reduce,
|
||||||
IntakeState,
|
IntakeState,
|
||||||
} from './intake.machine';
|
} from './intake.machine';
|
||||||
|
import { givenIntake } from './intake.testing';
|
||||||
const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({
|
|
||||||
tag: 'Answering',
|
|
||||||
answers,
|
|
||||||
cursor,
|
|
||||||
errors: {},
|
|
||||||
scholingThreshold,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('STEPS (fixed) and inline questions', () => {
|
describe('STEPS (fixed) and inline questions', () => {
|
||||||
it('always has the same three steps', () => {
|
it('always has the same three steps', () => {
|
||||||
@@ -31,12 +24,12 @@ describe('STEPS (fixed) and inline questions', () => {
|
|||||||
|
|
||||||
it('reveals the buitenland detail questions inline only when worked abroad', () => {
|
it('reveals the buitenland detail questions inline only when worked abroad', () => {
|
||||||
// No new step; instead these fields become required within the buitenland step.
|
// No new step; instead these fields become required within the buitenland step.
|
||||||
expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked
|
const abroad = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' });
|
||||||
expect(
|
expect(next(abroad).tag).toBe('Answering'); // land/uren missing -> blocked
|
||||||
expectTag(next(answering({ buitenlandGewerkt: 'ja' })), 'Answering').errors.land,
|
expect(expectTag(next(abroad), 'Answering').errors.land).toBeTruthy();
|
||||||
).toBeTruthy();
|
const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
|
||||||
expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves)
|
expect(next(domestic).tag).toBe('Answering'); // valid, advances (cursor moves)
|
||||||
expect(expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering').cursor).toBe(1);
|
expect(expectTag(next(domestic), 'Answering').cursor).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reveals the scholing question only when NL-hours are below the threshold', () => {
|
it('reveals the scholing question only when NL-hours are below the threshold', () => {
|
||||||
@@ -49,9 +42,13 @@ describe('STEPS (fixed) and inline questions', () => {
|
|||||||
expect(lageUren({ uren: '1500' }, 1000)).toBe(false);
|
expect(lageUren({ uren: '1500' }, 1000)).toBe(false);
|
||||||
expect(lageUren({ uren: '1500' }, 2000)).toBe(true);
|
expect(lageUren({ uren: '1500' }, 2000)).toBe(true);
|
||||||
// And the threshold from state flows through submit:
|
// And the threshold from state flows through submit:
|
||||||
const lowThreshold = submit(
|
const lowThresholdState = givenIntake(
|
||||||
answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000),
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||||
|
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
|
||||||
|
{ tag: 'SetAnswer', key: 'punten', value: '200' },
|
||||||
|
{ tag: 'SetPolicy', scholingThreshold: 2000 },
|
||||||
);
|
);
|
||||||
|
const lowThreshold = submit(lowThresholdState);
|
||||||
expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked
|
expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked
|
||||||
expect(expectTag(lowThreshold, 'Answering').errors.scholingGevolgd).toBeTruthy();
|
expect(expectTag(lowThreshold, 'Answering').errors.scholingGevolgd).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -65,18 +62,21 @@ describe('navigation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Next advances once the step is valid', () => {
|
it('Next advances once the step is valid', () => {
|
||||||
const s = expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering');
|
const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
|
||||||
|
const s = expectTag(next(domestic), 'Answering');
|
||||||
expect(s.cursor).toBe(1);
|
expect(s.cursor).toBe(1);
|
||||||
expect(currentStep(s)).toBe('werk');
|
expect(currentStep(s)).toBe('werk');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('editing an answer leaves the cursor fixed (steps never collapse)', () => {
|
it('editing an answer leaves the cursor fixed (steps never collapse)', () => {
|
||||||
|
const atWerk = givenIntake(
|
||||||
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' },
|
||||||
|
{ tag: 'SetAnswer', key: 'land', value: 'België' },
|
||||||
|
{ tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' },
|
||||||
|
{ tag: 'Next' }, // buitenland step valid -> cursor 0 -> 1
|
||||||
|
);
|
||||||
const edited = expectTag(
|
const edited = expectTag(
|
||||||
reduce(answering({ buitenlandGewerkt: 'ja' }, 1), {
|
reduce(atWerk, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }),
|
||||||
tag: 'SetAnswer',
|
|
||||||
key: 'buitenlandGewerkt',
|
|
||||||
value: 'nee',
|
|
||||||
}),
|
|
||||||
'Answering',
|
'Answering',
|
||||||
);
|
);
|
||||||
expect(edited.cursor).toBe(1); // cursor untouched; only inline questions change
|
expect(edited.cursor).toBe(1); // cursor untouched; only inline questions change
|
||||||
@@ -87,72 +87,154 @@ describe('navigation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
|
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
|
||||||
const s = answering({ buitenlandGewerkt: 'nee' }, 2);
|
const s = givenIntake(
|
||||||
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||||
|
{ tag: 'Next' }, // cursor 0 -> 1
|
||||||
|
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
|
||||||
|
{ tag: 'Next' }, // cursor 1 -> 2
|
||||||
|
);
|
||||||
expect(expectTag(gaNaarStap(s, 0), 'Answering').cursor).toBe(0);
|
expect(expectTag(gaNaarStap(s, 0), 'Answering').cursor).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => {
|
it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => {
|
||||||
const s = answering({ buitenlandGewerkt: 'nee' }, 1);
|
const s = givenIntake(
|
||||||
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||||
|
{ tag: 'Next' }, // cursor 0 -> 1
|
||||||
|
);
|
||||||
expect(gaNaarStap(s, 1)).toBe(s); // same step -> no-op
|
expect(gaNaarStap(s, 1)).toBe(s); // same step -> no-op
|
||||||
expect(gaNaarStap(s, 2)).toBe(s); // forward -> no-op
|
expect(gaNaarStap(s, 2)).toBe(s); // forward -> no-op
|
||||||
const submitting = submit(answering({ buitenlandGewerkt: 'nee', uren: '4160' }, 2));
|
const atReview = givenIntake(
|
||||||
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||||
|
{ tag: 'Next' }, // cursor 0 -> 1
|
||||||
|
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
|
||||||
|
{ tag: 'Next' }, // cursor 1 -> 2
|
||||||
|
);
|
||||||
|
const submitting = submit(atReview);
|
||||||
expect(gaNaarStap(submitting, 0)).toBe(submitting); // not Answering -> no-op
|
expect(gaNaarStap(submitting, 0)).toBe(submitting); // not Answering -> no-op
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('submit', () => {
|
describe('submit', () => {
|
||||||
// High hours: no scholing question, so no punten is asked or collected.
|
// High hours: no scholing question, so no punten is asked or collected.
|
||||||
const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160' };
|
const highUren = givenIntake(
|
||||||
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||||
|
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
|
||||||
|
);
|
||||||
|
|
||||||
it('reaches Submitting ONLY with valid answers', () => {
|
it('reaches Submitting ONLY with valid answers', () => {
|
||||||
// Bad punten only blocks when scholing was followed (otherwise punten is ignored).
|
// Bad punten only blocks when scholing was followed (otherwise punten is ignored).
|
||||||
expect(
|
const badPunten = givenIntake(
|
||||||
submit(
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||||
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' }),
|
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||||
).tag,
|
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||||
).toBe('Answering');
|
{ tag: 'SetAnswer', key: 'punten', value: 'x' },
|
||||||
const good = expectTag(submit(answering(complete)), 'Submitting');
|
);
|
||||||
|
expect(submit(badPunten).tag).toBe('Answering');
|
||||||
|
const good = expectTag(submit(highUren), 'Submitting');
|
||||||
expect(good.data.uren).toBe(4160);
|
expect(good.data.uren).toBe(4160);
|
||||||
expect(good.data.punten).toBeUndefined(); // not collected without scholing
|
expect(good.data.punten).toBeUndefined(); // not collected without scholing
|
||||||
});
|
});
|
||||||
|
|
||||||
it('punten is required only when aanvullende scholing was gevolgd', () => {
|
it('punten is required only when aanvullende scholing was gevolgd', () => {
|
||||||
// scholing = ja but punten missing -> blocked on punten.
|
// scholing = ja but punten missing -> blocked on punten.
|
||||||
const missing = expectTag(
|
const scholingJaNoPunten = givenIntake(
|
||||||
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' })),
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||||
'Answering',
|
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||||
|
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||||
);
|
);
|
||||||
|
const missing = expectTag(submit(scholingJaNoPunten), 'Answering');
|
||||||
expect(missing.errors.punten).toBeTruthy();
|
expect(missing.errors.punten).toBeTruthy();
|
||||||
// scholing = nee -> punten not required, submits without it.
|
// scholing = nee -> punten not required, submits without it.
|
||||||
expect(
|
const scholingNee = givenIntake(
|
||||||
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag,
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||||
).toBe('Submitting');
|
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||||
|
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'nee' },
|
||||||
|
);
|
||||||
|
expect(submit(scholingNee).tag).toBe('Submitting');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('low hours requires the scholing answer before submit', () => {
|
it('low hours requires the scholing answer before submit', () => {
|
||||||
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' }));
|
const lowUrenNoScholing = givenIntake(
|
||||||
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||||
const withScholing = expectTag(
|
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||||
submit(
|
|
||||||
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }),
|
|
||||||
),
|
|
||||||
'Submitting',
|
|
||||||
);
|
);
|
||||||
|
const noScholing = submit(lowUrenNoScholing);
|
||||||
|
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
|
||||||
|
const lowUrenWithScholing = givenIntake(
|
||||||
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||||
|
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||||
|
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||||
|
{ tag: 'SetAnswer', key: 'punten', value: '200' },
|
||||||
|
);
|
||||||
|
const withScholing = expectTag(submit(lowUrenWithScholing), 'Submitting');
|
||||||
expect(withScholing.data.aanvullendeScholing).toBe(true);
|
expect(withScholing.data.aanvullendeScholing).toBe(true);
|
||||||
expect(withScholing.data.punten).toBe(200);
|
expect(withScholing.data.punten).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not require punten for a hidden question', () => {
|
||||||
|
// 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.
|
||||||
|
const staleScholingNoPunten = givenIntake(
|
||||||
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||||
|
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
|
||||||
|
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||||
|
);
|
||||||
|
const good = expectTag(submit(staleScholingNoPunten), 'Submitting');
|
||||||
|
expect(good.data.aanvullendeScholing).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops punten when raising uren hides the question', () => {
|
||||||
|
// Same stale answer, but this time punten was also filled in while uren was low.
|
||||||
|
const staleScholingWithPunten = givenIntake(
|
||||||
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||||
|
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
|
||||||
|
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||||
|
{ tag: 'SetAnswer', key: 'punten', value: '150' },
|
||||||
|
);
|
||||||
|
const good = expectTag(submit(staleScholingWithPunten), 'Submitting');
|
||||||
|
// ValidIntake stays honest: neither the stale 'ja' nor its punten leak through.
|
||||||
|
expect(good.data.aanvullendeScholing).toBeUndefined();
|
||||||
|
expect(good.data.punten).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('resolve maps Submitting to Submitted on a successful submit', () => {
|
it('resolve maps Submitting to Submitted on a successful submit', () => {
|
||||||
const submitting = submit(answering(complete));
|
const submitting = submit(highUren);
|
||||||
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
|
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolve maps Submitting to Failed on a failed submit', () => {
|
it('resolve maps Submitting to Failed on a failed submit', () => {
|
||||||
const submitting = submit(answering(complete));
|
const submitting = submit(highUren);
|
||||||
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
|
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('primary', () => {
|
||||||
|
// Same fixture as the 'submit' describe block above: buitenland answered 'nee',
|
||||||
|
// uren high enough to skip the scholing question.
|
||||||
|
const highUren = givenIntake(
|
||||||
|
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||||
|
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
|
||||||
|
);
|
||||||
|
|
||||||
|
it('Primary advances to the next step from a non-final step', () => {
|
||||||
|
expect(currentStep(expectTag(highUren, 'Answering'))).toBe('buitenland'); // not the final step
|
||||||
|
expect(reduce(highUren, { tag: 'Primary' })).toEqual(reduce(highUren, { tag: 'Next' }));
|
||||||
|
expect(expectTag(primary(highUren), 'Answering').cursor).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Primary submits from the final step', () => {
|
||||||
|
const atReview = reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' });
|
||||||
|
expect(currentStep(expectTag(atReview, 'Answering'))).toBe('review'); // the final step
|
||||||
|
expect(reduce(atReview, { tag: 'Primary' })).toEqual(reduce(atReview, { tag: 'Submit' }));
|
||||||
|
expect(primary(atReview).tag).toBe('Submitting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Primary is a no-op from a non-editing state', () => {
|
||||||
|
const submitting = submit(reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' }));
|
||||||
|
expect(primary(submitting)).toBe(submitting);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('reduce (message-driven happy path)', () => {
|
describe('reduce (message-driven happy path)', () => {
|
||||||
it('drives abroad branch end to end', () => {
|
it('drives abroad branch end to end', () => {
|
||||||
let s: IntakeState = initial;
|
let s: IntakeState = initial;
|
||||||
|
|||||||
@@ -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 =
|
||||||
| {
|
| {
|
||||||
@@ -112,8 +112,12 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
|
|||||||
if (!u.ok) errors.uren = u.error;
|
if (!u.ok) errors.uren = u.error;
|
||||||
if (lageUren(a, scholingThreshold) && !a.scholingGevolgd)
|
if (lageUren(a, scholingThreshold) && !a.scholingGevolgd)
|
||||||
errors.scholingGevolgd = $localize`:@@validation.maakKeuze:Maak een keuze.`;
|
errors.scholingGevolgd = $localize`:@@validation.maakKeuze:Maak een keuze.`;
|
||||||
// Nascholingspunten are only asked (and required) when scholing was followed.
|
// Nascholingspunten are only asked (and required) when the question is actually
|
||||||
if (a.scholingGevolgd === 'ja') {
|
// visible (lageUren) AND scholing was followed — matching the template's
|
||||||
|
// `@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
|
||||||
|
// renders.
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@@ -142,14 +146,19 @@ function validateAll(a: Answers, scholingThreshold: number): Result<Errors, Vali
|
|||||||
|
|
||||||
const werktBuitenland = a.buitenlandGewerkt === 'ja';
|
const werktBuitenland = a.buitenlandGewerkt === 'ja';
|
||||||
const buitenland = parseUren(a.buitenlandseUren ?? '');
|
const buitenland = parseUren(a.buitenlandseUren ?? '');
|
||||||
// Punten are only collected when aanvullende scholing was gevolgd.
|
const aanvullendeScholing = lageUren(a, scholingThreshold)
|
||||||
const punten = a.scholingGevolgd === 'ja' ? parseUren(a.punten ?? '') : undefined;
|
? a.scholingGevolgd === 'ja'
|
||||||
|
: undefined;
|
||||||
|
// Punten are derived from aanvullendeScholing, NOT the raw scholingGevolgd answer —
|
||||||
|
// 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.
|
||||||
|
const punten = aanvullendeScholing === true ? parseUren(a.punten ?? '') : undefined;
|
||||||
return ok({
|
return ok({
|
||||||
werktBuitenland,
|
werktBuitenland,
|
||||||
land: werktBuitenland ? a.land : undefined,
|
land: werktBuitenland ? a.land : undefined,
|
||||||
buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,
|
buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,
|
||||||
uren: uren.value,
|
uren: uren.value,
|
||||||
aanvullendeScholing: lageUren(a, scholingThreshold) ? a.scholingGevolgd === 'ja' : undefined,
|
aanvullendeScholing,
|
||||||
punten: punten?.ok ? punten.value : undefined,
|
punten: punten?.ok ? punten.value : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -191,6 +200,13 @@ export function submit(s: IntakeState): IntakeState {
|
|||||||
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
|
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The primary button's action: advance, or submit from the review step. No-op
|
||||||
|
outside Answering — this is the one decision the old component-side handler used to make. */
|
||||||
|
export function primary(s: IntakeState): IntakeState {
|
||||||
|
if (s.tag !== 'Answering') return s;
|
||||||
|
return currentStep(s) === 'review' ? submit(s) : next(s);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolve(s: IntakeState, r: Result<string, void>): IntakeState {
|
export function resolve(s: IntakeState, r: Result<string, void>): IntakeState {
|
||||||
if (s.tag !== 'Submitting') return s;
|
if (s.tag !== 'Submitting') return s;
|
||||||
return r.ok
|
return r.ok
|
||||||
@@ -204,6 +220,7 @@ export type IntakeMsg =
|
|||||||
| { tag: 'Back' }
|
| { tag: 'Back' }
|
||||||
| { tag: 'GaNaarStap'; cursor: number }
|
| { tag: 'GaNaarStap'; cursor: number }
|
||||||
| { tag: 'Submit' }
|
| { tag: 'Submit' }
|
||||||
|
| { tag: 'Primary' }
|
||||||
| { tag: 'Retry' }
|
| { tag: 'Retry' }
|
||||||
| { tag: 'SubmitConfirmed' }
|
| { tag: 'SubmitConfirmed' }
|
||||||
| { tag: 'SubmitFailed'; error: string }
|
| { tag: 'SubmitFailed'; error: string }
|
||||||
@@ -222,6 +239,8 @@ export function reduce(s: IntakeState, m: IntakeMsg): IntakeState {
|
|||||||
return gaNaarStap(s, m.cursor);
|
return gaNaarStap(s, m.cursor);
|
||||||
case 'Submit':
|
case 'Submit':
|
||||||
return submit(s);
|
return submit(s);
|
||||||
|
case 'Primary':
|
||||||
|
return primary(s);
|
||||||
case 'Retry':
|
case 'Retry':
|
||||||
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||||
case 'SubmitConfirmed':
|
case 'SubmitConfirmed':
|
||||||
|
|||||||
+76
-88
@@ -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,
|
||||||
WizardStatus,
|
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,10 +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/upload/upload-controller';
|
import { createUploadController } from '@shared/application/upload-controller';
|
||||||
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
import { UploadState, initialUpload, digitalDocumentIds } from '@shared/domain/upload.machine';
|
||||||
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/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
|
||||||
@@ -51,15 +51,14 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
|
|||||||
[stepTitle]="stepTitle()"
|
[stepTitle]="stepTitle()"
|
||||||
i18n-processName="@@herregWizard.processName"
|
i18n-processName="@@herregWizard.processName"
|
||||||
processName="Herregistratie aanvragen"
|
processName="Herregistratie aanvragen"
|
||||||
[status]="shellStatus()"
|
[phase]="phase()"
|
||||||
[primaryLabel]="primaryLabel()"
|
[primaryLabel]="primaryLabel()"
|
||||||
[canGoBack]="step() > 1"
|
[canGoBack]="step() > 1"
|
||||||
[errors]="errorList()"
|
[errors]="errorList()"
|
||||||
[errorMessage]="errorMessage()"
|
(primary)="dispatch({ tag: 'Primary' })"
|
||||||
(primary)="onPrimary()"
|
|
||||||
(back)="dispatch({ tag: 'Back' })"
|
(back)="dispatch({ tag: 'Back' })"
|
||||||
(cancel)="restart()"
|
(cancel)="restart()"
|
||||||
(retry)="onRetry()"
|
(retry)="dispatch({ tag: 'Retry' })"
|
||||||
(goToStep)="goToStep($event)"
|
(goToStep)="goToStep($event)"
|
||||||
>
|
>
|
||||||
@switch (step()) {
|
@switch (step()) {
|
||||||
@@ -149,37 +148,31 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
|
|||||||
})
|
})
|
||||||
export class HerregistratieWizardComponent {
|
export class HerregistratieWizardComponent {
|
||||||
private profile = inject(BigProfileStore);
|
private profile = inject(BigProfileStore);
|
||||||
private uploadAdapter = inject(UploadAdapter);
|
|
||||||
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
|
||||||
|
|
||||||
/** Preview/download link for a completed upload; dev-simulation `demo-*` ids have
|
|
||||||
no stored bytes, so they get no link. */
|
|
||||||
protected previewUrlFor = (documentId: string): string | undefined =>
|
|
||||||
documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
|
|
||||||
|
|
||||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||||
seed = input<WizardState>(initial);
|
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, {
|
||||||
|
Submitting: async (s, store) => {
|
||||||
|
this.profile.beginHerregistratie();
|
||||||
|
const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents });
|
||||||
|
if (r.ok) {
|
||||||
|
store.dispatch({ tag: 'SubmitConfirmed' });
|
||||||
|
this.profile.confirmHerregistratie();
|
||||||
|
} else {
|
||||||
|
store.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||||
|
this.profile.rollbackHerregistratie();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
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`,
|
||||||
@@ -191,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>(
|
||||||
@@ -201,12 +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 ?? '');
|
||||||
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
|
||||||
|
// --- 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]);
|
||||||
@@ -216,33 +233,32 @@ 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`;
|
||||||
});
|
});
|
||||||
|
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
|
||||||
|
composing the localized failure prefix so the `Failed` message arrives intact. */
|
||||||
|
protected phase = computed<WizardPhase>(() => {
|
||||||
|
const s = this.state();
|
||||||
|
switch (s.tag) {
|
||||||
|
case 'Editing':
|
||||||
|
return { tag: 'Editing' };
|
||||||
|
case 'Submitting':
|
||||||
|
return { tag: 'Submitting' };
|
||||||
|
case 'Submitted':
|
||||||
|
return { tag: 'Submitted' };
|
||||||
|
case 'Failed':
|
||||||
|
return {
|
||||||
|
tag: 'Failed',
|
||||||
|
message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
/** Current step's field errors, flattened for the shell's error summary. */
|
||||||
|
protected errorList = computed<WizardError[]>(() => toWizardErrors(this.editing()?.errors ?? {}));
|
||||||
|
|
||||||
|
// --- Event handlers: narrow a child event into a message -------------------
|
||||||
/** Stepper emits a 0-based index for an earlier (visited) step. */
|
/** Stepper emits a 0-based index for an earlier (visited) step. */
|
||||||
protected goToStep(index: number) {
|
protected goToStep(index: number) {
|
||||||
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 });
|
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 });
|
||||||
}
|
}
|
||||||
protected errorMessage = computed(
|
|
||||||
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
|
|
||||||
);
|
|
||||||
protected shellStatus = computed<WizardStatus>(() => {
|
|
||||||
switch (this.state().tag) {
|
|
||||||
case 'Editing':
|
|
||||||
return 'editing';
|
|
||||||
case 'Submitting':
|
|
||||||
return 'submitting';
|
|
||||||
case 'Submitted':
|
|
||||||
return 'submitted';
|
|
||||||
case 'Failed':
|
|
||||||
return 'failed';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
/** Current step's field errors, flattened for the shell's error summary. */
|
|
||||||
protected errorList = computed<WizardError[]>(() => {
|
|
||||||
const e = this.editing()?.errors ?? {};
|
|
||||||
return (Object.keys(e) as (keyof typeof e)[])
|
|
||||||
.filter((k) => e[k])
|
|
||||||
.map((k) => ({ id: k, message: e[k]! }));
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
|
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
|
||||||
@@ -253,37 +269,9 @@ export class HerregistratieWizardComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onPrimary() {
|
|
||||||
const s = this.state();
|
|
||||||
if (s.tag !== 'Editing') return;
|
|
||||||
this.dispatch(s.step < 3 ? { tag: 'Next' } : { tag: 'Submit' });
|
|
||||||
this.runIfSubmitting();
|
|
||||||
}
|
|
||||||
|
|
||||||
onRetry() {
|
|
||||||
this.dispatch({ tag: 'Retry' });
|
|
||||||
this.runIfSubmitting();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reset the wizard to a fresh, empty start. */
|
/** Reset the wizard to a fresh, empty start. */
|
||||||
restart() {
|
restart() {
|
||||||
this.draftSync.reset();
|
this.draftSync.reset();
|
||||||
this.dispatch({ tag: 'Seed', state: initial });
|
this.dispatch({ tag: 'Seed', state: initial });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The effect: when we entered Submitting, submit through the aanvraag lifecycle,
|
|
||||||
flip the optimistic cross-page flag, then dispatch the result (commit/rollback). */
|
|
||||||
private async runIfSubmitting() {
|
|
||||||
const s = this.state();
|
|
||||||
if (s.tag !== 'Submitting') return;
|
|
||||||
this.profile.beginHerregistratie();
|
|
||||||
const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents });
|
|
||||||
if (r.ok) {
|
|
||||||
this.dispatch({ tag: 'SubmitConfirmed' });
|
|
||||||
this.profile.confirmHerregistratie();
|
|
||||||
} else {
|
|
||||||
this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
|
||||||
this.profile.rollbackHerregistratie();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import { provideHttpClient } from '@angular/common/http';
|
|||||||
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||||
import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
|
import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
|
||||||
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
|
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
|
||||||
import { initialUpload } from '@shared/upload/upload.machine';
|
import { initialUpload } from '@shared/domain/upload.machine';
|
||||||
import { Uren } from '@registratie/domain/value-objects/uren';
|
import { Uren } from '@registratie/domain/value-objects/uren';
|
||||||
|
|
||||||
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] };
|
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] };
|
||||||
|
|||||||
@@ -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] ?? '';
|
||||||
|
}
|
||||||
@@ -1,19 +1,13 @@
|
|||||||
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,
|
||||||
WizardStatus,
|
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';
|
||||||
@@ -21,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
|
||||||
@@ -58,193 +52,38 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
|
|||||||
[stepTitle]="stepTitle()"
|
[stepTitle]="stepTitle()"
|
||||||
i18n-processName="@@intake.processName"
|
i18n-processName="@@intake.processName"
|
||||||
processName="Herregistratie-intake"
|
processName="Herregistratie-intake"
|
||||||
[status]="shellStatus()"
|
[phase]="phase()"
|
||||||
[primaryLabel]="primaryLabel()"
|
[primaryLabel]="primaryLabel()"
|
||||||
[canGoBack]="cursor() > 0"
|
[canGoBack]="cursor() > 0"
|
||||||
[errors]="errorList()"
|
[errors]="errorList()"
|
||||||
[errorMessage]="errorMessage()"
|
(primary)="dispatch({ tag: 'Primary' })"
|
||||||
(primary)="onPrimary()"
|
|
||||||
(back)="dispatch({ tag: 'Back' })"
|
(back)="dispatch({ tag: 'Back' })"
|
||||||
(cancel)="restart()"
|
(cancel)="restart()"
|
||||||
(retry)="onRetry()"
|
(retry)="dispatch({ tag: 'Retry' })"
|
||||||
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
|
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
|
||||||
>
|
>
|
||||||
@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,16 +107,66 @@ 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);
|
||||||
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
|
|
||||||
|
|
||||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||||
seed = input<IntakeState>(initial);
|
seed = input<IntakeState>(initial);
|
||||||
|
|
||||||
readonly jaNee = JA_NEE;
|
// --- 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, {
|
||||||
|
Submitting: async (s, store) => {
|
||||||
|
this.profile.beginHerregistratie();
|
||||||
|
// The scholing answer rides along so the server can re-validate it as the
|
||||||
|
// authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by
|
||||||
|
// JSON.stringify, so a wizard above the threshold sends neither field.
|
||||||
|
const r = await this.draftSync.submit({
|
||||||
|
uren: s.data.uren,
|
||||||
|
aanvullendeScholing: s.data.aanvullendeScholing,
|
||||||
|
scholingPunten: s.data.punten,
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
store.dispatch({ tag: 'SubmitConfirmed' });
|
||||||
|
this.profile.confirmHerregistratie();
|
||||||
|
} else {
|
||||||
|
store.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||||
|
this.profile.rollbackHerregistratie();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
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: () => {
|
||||||
@@ -289,64 +178,36 @@ 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()));
|
|
||||||
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
|
||||||
|
|
||||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||||
readonly stepLabels = [
|
|
||||||
$localize`:@@intake.step.buitenland:Buitenland`,
|
|
||||||
$localize`:@@intake.step.werk:Werk`,
|
|
||||||
$localize`:@@intake.step.controle:Controle`,
|
|
||||||
];
|
|
||||||
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`;
|
||||||
const next = this.cursor() + 1;
|
const next = this.cursor() + 1;
|
||||||
return naarStapLabel(next + 1, this.stepLabels[next]);
|
return naarStapLabel(next + 1, this.stepLabels[next]);
|
||||||
});
|
});
|
||||||
protected errorMessage = computed(
|
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
|
||||||
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
|
composing the localized failure prefix so the `Failed` message arrives intact. */
|
||||||
);
|
protected phase = computed<WizardPhase>(() => {
|
||||||
protected shellStatus = computed<WizardStatus>(() => {
|
const s = this.state();
|
||||||
switch (this.state().tag) {
|
switch (s.tag) {
|
||||||
case 'Answering':
|
case 'Answering':
|
||||||
return 'editing';
|
return { tag: 'Editing' };
|
||||||
case 'Submitting':
|
case 'Submitting':
|
||||||
return 'submitting';
|
return { tag: 'Submitting' };
|
||||||
case 'Submitted':
|
case 'Submitted':
|
||||||
return 'submitted';
|
return { tag: 'Submitted' };
|
||||||
case 'Failed':
|
case 'Failed':
|
||||||
return 'failed';
|
return {
|
||||||
|
tag: 'Failed',
|
||||||
|
message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
/** 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
|
||||||
@@ -364,36 +225,8 @@ export class IntakeWizardComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onPrimary() {
|
|
||||||
const s = this.state();
|
|
||||||
if (s.tag !== 'Answering') return;
|
|
||||||
this.dispatch(this.step() === 'review' ? { tag: 'Submit' } : { tag: 'Next' });
|
|
||||||
this.runIfSubmitting();
|
|
||||||
}
|
|
||||||
|
|
||||||
onRetry() {
|
|
||||||
this.dispatch({ tag: 'Retry' });
|
|
||||||
this.runIfSubmitting();
|
|
||||||
}
|
|
||||||
|
|
||||||
restart() {
|
restart() {
|
||||||
this.draftSync.reset();
|
this.draftSync.reset();
|
||||||
this.dispatch({ tag: 'Seed', state: initial });
|
this.dispatch({ tag: 'Seed', state: initial });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The effect: when we enter Submitting, submit through the aanvraag lifecycle,
|
|
||||||
flip the optimistic cross-page flag, then dispatch the outcome (commit/rollback). */
|
|
||||||
private async runIfSubmitting() {
|
|
||||||
const s = this.state();
|
|
||||||
if (s.tag !== 'Submitting') return;
|
|
||||||
this.profile.beginHerregistratie();
|
|
||||||
const r = await this.draftSync.submit({ uren: s.data.uren });
|
|
||||||
if (r.ok) {
|
|
||||||
this.dispatch({ tag: 'SubmitConfirmed' });
|
|
||||||
this.profile.confirmHerregistratie();
|
|
||||||
} else {
|
|
||||||
this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
|
||||||
this.profile.rollbackHerregistratie();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user