Compare commits
185
Commits
e4ea75414b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1866947438 | ||
|
|
6330773fd5 | ||
|
|
856463b738 | ||
|
|
44dcc69811 | ||
|
|
d9aef9541f | ||
|
|
682db41344 | ||
|
|
241fe1b57b | ||
|
|
d31e054504 | ||
|
|
12f17d9d73 | ||
|
|
097e8468e0 | ||
|
|
2aa343f255 | ||
|
|
f3e5745145 | ||
|
|
e6bc19c790 | ||
|
|
fc2a3c348b | ||
|
|
551cabce5e | ||
|
|
b6bd7eea5a | ||
|
|
d4c5a9450a | ||
|
|
57fc72f254 | ||
|
|
43dc3210cd | ||
|
|
7a8eab917b | ||
|
|
bcac3789d3 | ||
|
|
cf1f641534 | ||
|
|
5aed15bb98 | ||
|
|
630d68045f | ||
|
|
d5a7a25a78 | ||
|
|
11e3191099 | ||
|
|
8e1de38c68 | ||
|
|
4b3e6a6cfd | ||
|
|
8a3e42015b | ||
|
|
dff5f96bb3 | ||
|
|
831940f1b9 | ||
|
|
a196a380ce | ||
|
|
8560746d15 | ||
|
|
dd11eafe50 | ||
|
|
3895588b9a | ||
|
|
fb2e58ab87 | ||
|
|
e221834f6e | ||
|
|
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 | ||
|
|
9da385311d | ||
|
|
3652ff8d3f | ||
|
|
306d002221 | ||
|
|
28c0a250e7 | ||
|
|
b937e55ad3 | ||
|
|
66224b1644 | ||
|
|
a82332fa20 | ||
|
|
a7bf228ca8 | ||
|
|
a73a1c6f1e | ||
|
|
2eea860efe | ||
|
|
868fb55783 | ||
|
|
472a49f19f | ||
|
|
31d4aa1848 | ||
|
|
fc6e73806a | ||
|
|
fd04221d2f | ||
|
|
a394950a1d | ||
|
|
6a4a0ad435 | ||
|
|
6cfd70eeeb | ||
|
|
d2c2cffc1f | ||
|
|
a2e301060e | ||
|
|
d996ca2463 | ||
|
|
39409bdf76 | ||
|
|
6c6bef45f3 | ||
|
|
af8a011819 | ||
|
|
4133b30e5d | ||
|
|
fe69caee63 | ||
|
|
e7156c5132 | ||
|
|
d3f3b13345 | ||
|
|
a09c4ed87b |
@@ -20,11 +20,11 @@ The repo splits docs by kind (`docs/README.md`). Pick one — don't write all th
|
|||||||
_decision/pattern_ (a tradeoff, a "we chose X over Y"). One format, no drift:
|
_decision/pattern_ (a tradeoff, a "we chose X over Y"). One format, no drift:
|
||||||
`# ADR-NNNN — Title`, then `Status: Accepted · Date: YYYY-MM-DD`, then
|
`# ADR-NNNN — Title`, then `Status: Accepted · Date: YYYY-MM-DD`, then
|
||||||
Problem / Options / Decision / Consequences.
|
Problem / Options / Decision / Consequences.
|
||||||
- **WP** — `docs/project/backlog/WP-NN-<slug>.md`. Tracked work; use the template at the
|
- **WP** — `docs/project/archive/backlog/WP-NN-<slug>.md`. Tracked work; use the template at the
|
||||||
bottom of `docs/project/backlog/README.md`.
|
bottom of `docs/project/archive/backlog/README.md`.
|
||||||
|
|
||||||
Then **add the index row**: `docs/README.md` table for a reference doc/ADR,
|
Then **add the index row**: `docs/README.md` table for a reference doc/ADR,
|
||||||
`docs/project/backlog/README.md` for a WP. A doc with no index row is invisible.
|
`docs/project/archive/backlog/README.md` for a WP. A doc with no index row is invisible.
|
||||||
|
|
||||||
## Adjusted feature → update what already describes it
|
## Adjusted feature → update what already describes it
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ Work through that checklist, keeping the GREEN gate below passing at every step.
|
|||||||
re-target it at an arbitrary new context, so it's deleted alongside `registratie`, along
|
re-target it at an arbitrary new context, so it's deleted alongside `registratie`, along
|
||||||
with its three wiring lines in `shell.component.ts` (import, `imports:` entry, template tag).
|
with its three wiring lines in `shell.component.ts` (import, `imports:` entry, template tag).
|
||||||
- The `dashboard` route is **not** deleted even though it currently imports
|
- The `dashboard` route is **not** deleted even though it currently imports
|
||||||
`@registratie/ui/dashboard.page` — too much else hardcodes `/dashboard` (login's post-auth
|
`@overzicht/ui/overzicht.page` — too much else hardcodes `/dashboard` (login's post-auth
|
||||||
redirect, `authGuard`'s fallback, header nav/logo, breadcrumb trail, several stories/specs).
|
redirect, `authGuard`'s fallback, header nav/logo, breadcrumb trail, several stories/specs).
|
||||||
The script rewrites its `loadComponent` to point at the freshly scaffolded `--context` page
|
The script rewrites its `loadComponent` to point at the freshly scaffolded `--context` page
|
||||||
instead (a `TODO(create-frontend)` stopgap landing page, not a real overview).
|
instead (a `TODO(create-frontend)` stopgap landing page, not a real overview).
|
||||||
@@ -85,8 +85,8 @@ Work through that checklist, keeping the GREEN gate below passing at every step.
|
|||||||
placeholder path and creates an empty `public/<name>-huisstijl/` — it cannot generate a
|
placeholder path and creates an empty `public/<name>-huisstijl/` — it cannot generate a
|
||||||
real house style. Vendor your CSS there, then re-point the `--rhc-*` bridge in
|
real house style. Vendor your CSS there, then re-point the `--rhc-*` bridge in
|
||||||
`src/styles.scss` (ADR-0003 pattern: bridge, don't rewrite tokens), then `npm run check:tokens`.
|
`src/styles.scss` (ADR-0003 pattern: bridge, don't rewrite tokens), then `npm run check:tokens`.
|
||||||
- `docs/project/backlog/` WPs, PRDs, and memory-specific docs — new portal, new backlog
|
- `docs/project/archive/backlog/` WPs, PRDs, and memory-specific docs — new portal, new backlog
|
||||||
(keep `docs/project/backlog/README.md`'s WP process/template if you like the workflow).
|
(keep `docs/project/archive/backlog/README.md`'s WP process/template if you like the workflow).
|
||||||
`docs/reference/scaffolding.md` also names `BigRegister.Api` in prose — update by hand.
|
`docs/reference/scaffolding.md` also names `BigRegister.Api` in prose — update by hand.
|
||||||
- `e2e/*.spec.ts` (`smoke.spec.ts`, `brief-v2.spec.ts`, `error-state.spec.ts`): full
|
- `e2e/*.spec.ts` (`smoke.spec.ts`, `brief-v2.spec.ts`, `error-state.spec.ts`): full
|
||||||
BIG-register user-flow tests (BSN login → registration wizard → submission assertions).
|
BIG-register user-flow tests (BSN login → registration wizard → submission assertions).
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ No `TestBed` for domain. Never assert on user-facing copy.
|
|||||||
only for wiring axe can't see.
|
only for wiring axe can't see.
|
||||||
- **Never assert on `$localize` copy.** It changes per locale/edit — assert on the
|
- **Never assert on `$localize` copy.** It changes per locale/edit — assert on the
|
||||||
`Result`, the value object, or the message id.
|
`Result`, the value object, or the message id.
|
||||||
|
- **Fixtures go through the production door, never a hand-built literal** (ADR-0006).
|
||||||
|
Replay real `Msg`s through the real `reduce` (`given(reduce, initial)(...msgs)`) for a
|
||||||
|
state machine; `unwrapOk(parseX(raw))` for a value object; a type-state builder
|
||||||
|
(`Given.Concept().Submitted()...`) for a backend aggregate with an ordered lifecycle.
|
||||||
|
The one deliberate exception is a trust-boundary `parse*` spec, below — there the fixture
|
||||||
|
must be a raw, possibly-malformed literal, because the test's whole point is "what if this
|
||||||
|
shape is wrong." See ADR-0006's decision table for which idiom fits which test type.
|
||||||
|
|
||||||
## Skeleton
|
## Skeleton
|
||||||
|
|
||||||
@@ -54,9 +61,13 @@ describe('parseThing', () => {
|
|||||||
|
|
||||||
## Worked examples
|
## Worked examples
|
||||||
|
|
||||||
- `src/app/registratie/domain/value-objects/postcode.spec.ts` — parser style.
|
- `apps/ssp/src/app/registratie/domain/value-objects/postcode.spec.ts` — parser style.
|
||||||
- `src/app/registratie/infrastructure/brp.adapter.spec.ts` — trust boundary (`null`/`{}`).
|
- `apps/ssp/src/app/registratie/infrastructure/brp.adapter.spec.ts` — trust boundary (`null`/`{}`).
|
||||||
- `src/app/registratie/domain/registratie-wizard.machine.spec.ts` — pure reducer.
|
- `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts` — pure reducer.
|
||||||
|
- `libs/shared/src/testing/{machine,remote-data,value-object}.ts` — the shared fixture
|
||||||
|
helpers (ADR-0006); `apps/ssp/src/app/herregistratie/domain/intake.testing.ts` — a
|
||||||
|
per-context wrapper (`givenIntake = given(reduce, initial)`); `intake.acceptance.spec.ts`
|
||||||
|
— a full journey expressed as one replayed message sequence.
|
||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ description: Add a shared UI building block (atom, molecule, organism) with its
|
|||||||
|
|
||||||
# UI component (atom / molecule / organism)
|
# UI component (atom / molecule / organism)
|
||||||
|
|
||||||
First: check `shared/ui/` and `shared/layout/` — a new page should be composition of
|
First: check `libs/shared/src/ui/` and `libs/shared/src/layout/` — a new page should be
|
||||||
existing blocks. Only add a block when nothing fits.
|
composition of existing blocks. Only add a block when nothing fits.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- **Folder = atomic layer**: `shared/ui/` atoms → molecules → organisms;
|
- **Folder = atomic layer**: `libs/shared/src/ui/` atoms → molecules → organisms;
|
||||||
`shared/layout/` templates. Each level only uses levels below.
|
`libs/shared/src/layout/` templates. Each level only uses levels below.
|
||||||
- Standalone component, **English name** (shared = language-agnostic), signal
|
- Standalone component, **English name** (shared = language-agnostic), signal
|
||||||
`input()`s only, `inject()` over constructor DI.
|
`input()`s only, `inject()` over constructor DI.
|
||||||
- **Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2) classes** (`btn`,
|
- **Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2) classes** (`btn`,
|
||||||
@@ -21,7 +21,8 @@ existing blocks. Only add a block when nothing fits.
|
|||||||
- **Tokens only** — `var(--rhc-*)` / `var(--app-*)`, never hardcoded colors
|
- **Tokens only** — `var(--rhc-*)` / `var(--app-*)`, never hardcoded colors
|
||||||
(`npm run check:tokens` fails the build; escape hatch: `token-ok` marker + reason).
|
(`npm run check:tokens` fails the build; escape hatch: `token-ok` marker + reason).
|
||||||
- **No hardcoded Dutch** in shared components — expose copy as `input()`s with
|
- **No hardcoded Dutch** in shared components — expose copy as `input()`s with
|
||||||
`$localize` defaults; the domain caller supplies the text (see `shared/ui/async`).
|
`$localize` defaults; the domain caller supplies the text (see
|
||||||
|
`libs/shared/src/ui/molecules/async`).
|
||||||
- Components with content-projected slots export a spread constant so callers import
|
- Components with content-projected slots export a spread constant so callers import
|
||||||
one thing: `export const ASYNC = [AsyncComponent, AsyncLoadedDirective, …] as const;`
|
one thing: `export const ASYNC = [AsyncComponent, AsyncLoadedDirective, …] as const;`
|
||||||
|
|
||||||
@@ -41,9 +42,10 @@ UI test surface.
|
|||||||
|
|
||||||
## Worked examples
|
## Worked examples
|
||||||
|
|
||||||
- Atom: `src/app/shared/ui/button/` — typed variant API over `btn` classes.
|
- Atom: `libs/shared/src/ui/atoms/button/` — typed variant API over `btn` classes.
|
||||||
- Molecule: `src/app/shared/ui/async/` — slot directives, localizable input defaults, spread constant.
|
- Molecule: `libs/shared/src/ui/molecules/async/` — slot directives, localizable input
|
||||||
- Template: `src/app/shared/layout/wizard-shell/` — the canonical wizard outline.
|
defaults, spread constant.
|
||||||
|
- Template: `libs/shared/src/layout/wizard-shell/` — the canonical wizard outline.
|
||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// Dependency-cruiser (WP-38, generalized for the WP-67 monorepo split): the single
|
||||||
|
// declarative source for the bounded-context + atomic-layer boundaries. Each app
|
||||||
|
// (apps/ssp, apps/behandelportal) is cruised SEPARATELY against its own tsconfig.json
|
||||||
|
// (see .dependency-cruiser.<app>.js) — a single merged tsconfig can't resolve both
|
||||||
|
// apps' `@auth/*` alias at once, since each points at a different physical directory.
|
||||||
|
// This file is the shared rule *factory*; it never runs standalone.
|
||||||
|
//
|
||||||
|
// libs/shared and libs/beheer are cross-app libraries, not per-app feature contexts:
|
||||||
|
// any app context may import either; neither may import an app's feature context;
|
||||||
|
// libs/shared may not import libs/beheer (shared stays the base, beheer a peer leaf).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, string[] | null>} contextAllowed context name -> the OTHER
|
||||||
|
* app-local contexts it may additionally import (besides itself + libs/shared|beheer).
|
||||||
|
* `null` = unrestricted (showcase, the sanctioned teaching page).
|
||||||
|
* @param {string} appName the apps/<appName> directory this config cruises.
|
||||||
|
* @param {string} tsConfigFileName this app's own tsconfig.json (resolves its aliases).
|
||||||
|
*/
|
||||||
|
module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName) {
|
||||||
|
const FEATURES = Object.keys(contextAllowed).join('|');
|
||||||
|
const appRoot = `apps/${appName}/src/app`;
|
||||||
|
|
||||||
|
const contextRule = (from) => {
|
||||||
|
const allowed = contextAllowed[from];
|
||||||
|
if (allowed === null) return null;
|
||||||
|
const forbidden = Object.keys(contextAllowed)
|
||||||
|
.filter((name) => name !== from && !allowed.includes(name))
|
||||||
|
.join('|');
|
||||||
|
return {
|
||||||
|
name: `${appName}-${from}-scope`,
|
||||||
|
comment: `${from} may depend only on its allowed contexts (+ libs/shared|beheer). See CLAUDE.md §1.`,
|
||||||
|
severity: 'error',
|
||||||
|
from: { path: `^${appRoot}/${from}/` },
|
||||||
|
to: { path: `^${appRoot}/(${forbidden})/` },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Atomic-layer rules apply uniformly across this app's tree AND both libraries.
|
||||||
|
const anyRoot = `(${appRoot}|libs/shared/src|libs/beheer/src)`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
forbidden: [
|
||||||
|
// --- Bounded-context direction (the "dependencies point inward" spine) ---
|
||||||
|
{
|
||||||
|
name: 'shared-no-features',
|
||||||
|
comment: 'libs/shared is the base — it must not import any app feature context.',
|
||||||
|
severity: 'error',
|
||||||
|
from: { path: '^libs/shared/src/' },
|
||||||
|
to: { path: `^${appRoot}/(${FEATURES})/` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'beheer-no-features',
|
||||||
|
comment: 'libs/beheer is a cross-app library — it must not import any app feature context.',
|
||||||
|
severity: 'error',
|
||||||
|
from: { path: '^libs/beheer/src/' },
|
||||||
|
to: { path: `^${appRoot}/(${FEATURES})/` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'shared-no-beheer',
|
||||||
|
comment: 'libs/shared stays the base — it must not depend on the beheer library.',
|
||||||
|
severity: 'error',
|
||||||
|
from: { path: '^libs/shared/src/' },
|
||||||
|
to: { path: '^libs/beheer/src/' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `${appName}-no-other-app`,
|
||||||
|
comment: "An app may not import another app's source directly.",
|
||||||
|
severity: 'error',
|
||||||
|
from: { path: `^${appRoot}/` },
|
||||||
|
to: { path: '^apps/(?!' + appName + '/)' },
|
||||||
|
},
|
||||||
|
...Object.keys(contextAllowed).map(contextRule).filter(Boolean),
|
||||||
|
|
||||||
|
// --- Atomic-layer rules (dependencies point inward: ui → application → domain) ---
|
||||||
|
{
|
||||||
|
name: 'domain-is-pure',
|
||||||
|
comment: 'domain/ is framework-free business logic — no Angular.',
|
||||||
|
severity: 'error',
|
||||||
|
from: { path: `^${anyRoot}/.*/domain/` },
|
||||||
|
to: { path: 'node_modules/@angular/' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'contracts-import-nothing',
|
||||||
|
comment: 'contracts/ are pure wire DTO shapes — they import nothing (ADR-0001).',
|
||||||
|
severity: 'error',
|
||||||
|
from: { path: `^${anyRoot}/.*/contracts/` },
|
||||||
|
to: { pathNot: '/contracts/', path: `^(${anyRoot}/|node_modules/@angular/)` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ui-not-infrastructure',
|
||||||
|
comment:
|
||||||
|
'ui/ + layout/ reach data through an application store/command, never infrastructure directly (type-only DTO imports allowed).',
|
||||||
|
severity: 'error',
|
||||||
|
from: {
|
||||||
|
path: `^${anyRoot}/.*(/ui/|/layout/)`,
|
||||||
|
pathNot: '\\.stories\\.ts$|\\.spec\\.ts$',
|
||||||
|
},
|
||||||
|
to: { path: '/infrastructure/', dependencyTypesNot: ['type-only'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'apiclient-infrastructure-only',
|
||||||
|
comment:
|
||||||
|
'The generated ApiClient is a value only inside infrastructure/; elsewhere type-only.',
|
||||||
|
severity: 'error',
|
||||||
|
from: { pathNot: '/infrastructure/' },
|
||||||
|
to: {
|
||||||
|
path: '^libs/shared/src/infrastructure/api-client\\.ts$',
|
||||||
|
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',
|
||||||
|
comment:
|
||||||
|
'Test-only fixture helpers (libs/shared/src/testing/** and any *.testing.ts) are reached from specs/stories only — production code gets its data through the real domain/application doors (ADR-0006), never the test escape hatch.',
|
||||||
|
severity: 'error',
|
||||||
|
from: {
|
||||||
|
pathNot: '\\.(spec|stories)\\.ts$|\\.testing\\.ts$|^libs/shared/src/testing/',
|
||||||
|
},
|
||||||
|
to: { path: '^libs/shared/src/testing/|\\.testing\\.ts$' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Hygiene (cheap wins a graph makes obvious) ---
|
||||||
|
{
|
||||||
|
name: 'no-circular',
|
||||||
|
comment: 'No cyclic dependencies.',
|
||||||
|
severity: 'error',
|
||||||
|
from: {},
|
||||||
|
to: { circular: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
options: {
|
||||||
|
doNotFollow: { path: 'node_modules' },
|
||||||
|
tsConfig: { fileName: tsConfigFileName },
|
||||||
|
tsPreCompilationDeps: true, // needed so `type-only` imports are distinguished
|
||||||
|
enhancedResolveOptions: {
|
||||||
|
exportsFields: ['exports'],
|
||||||
|
conditionNames: ['import', 'require', 'node', 'default'],
|
||||||
|
},
|
||||||
|
reporterOptions: {
|
||||||
|
archi: { collapsePattern: '^(apps/[^/]+/src/app|libs/[^/]+/src)/[^/]+' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// behandelportal's own context boundaries.
|
||||||
|
module.exports = require('./.dependency-cruiser.base.js')(
|
||||||
|
{
|
||||||
|
auth: [],
|
||||||
|
behandeling: [],
|
||||||
|
},
|
||||||
|
'behandelportal',
|
||||||
|
'apps/behandelportal/tsconfig.json',
|
||||||
|
);
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
// Dependency-cruiser (WP-38): the single declarative source for the app's bounded-context
|
|
||||||
// + atomic-layer boundaries — and the graph you can SEE (`npm run dep:graph`). Replaces the
|
|
||||||
// hand-duplicated `no-restricted-imports` blocks that had to be copied per context (and that
|
|
||||||
// left `herregistratie` without one). ESLint keeps only the rules dep-cruiser can't express
|
|
||||||
// (no-explicit-any, template a11y).
|
|
||||||
//
|
|
||||||
// Contexts: shared (base) · auth · registratie · herregistratie · brief · beheer · showcase.
|
|
||||||
// Allowed cross-context edges: everyone → shared; herregistratie → registratie; showcase → *
|
|
||||||
// (the sanctioned teaching page). Nobody imports showcase.
|
|
||||||
|
|
||||||
// Single source of truth for bounded-context boundaries: each entry maps a context name to the
|
|
||||||
// OTHER contexts it may additionally import (besides itself + shared). `showcase` maps to `null`
|
|
||||||
// — sanctioned to import every context (the teaching page); nothing else may import it. Add a
|
|
||||||
// context here — nowhere else — when scaffolding one (see `gen:context`, WP-44); FEATURES and
|
|
||||||
// every contextRule below are derived from this object.
|
|
||||||
const CONTEXT_ALLOWED = {
|
|
||||||
auth: [],
|
|
||||||
registratie: [],
|
|
||||||
herregistratie: ['registratie'], // the one sanctioned cross-feature edge
|
|
||||||
brief: [],
|
|
||||||
beheer: [],
|
|
||||||
showcase: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const FEATURES = Object.keys(CONTEXT_ALLOWED).join('|');
|
|
||||||
|
|
||||||
/** A context may import shared + itself + its allowed list; forbidden = every other context. */
|
|
||||||
const contextRule = (from) => {
|
|
||||||
const allowed = CONTEXT_ALLOWED[from];
|
|
||||||
if (allowed === null) return null; // unrestricted (showcase) — no rule to generate
|
|
||||||
const forbidden = Object.keys(CONTEXT_ALLOWED)
|
|
||||||
.filter((name) => name !== from && !allowed.includes(name))
|
|
||||||
.join('|');
|
|
||||||
return {
|
|
||||||
name: `${from}-scope`,
|
|
||||||
comment: `${from} may depend only on its allowed contexts (+ shared). See CLAUDE.md §1.`,
|
|
||||||
severity: 'error',
|
|
||||||
from: { path: `^src/app/${from}/` },
|
|
||||||
to: { path: `^src/app/(${forbidden})/` },
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
forbidden: [
|
|
||||||
// --- Bounded-context direction (the "dependencies point inward" spine) ---
|
|
||||||
{
|
|
||||||
name: 'shared-no-features',
|
|
||||||
comment: 'shared/ is the base — it must not import any feature context.',
|
|
||||||
severity: 'error',
|
|
||||||
from: { path: '^src/app/shared/', pathNot: '^src/app/shared/ui/debug-state/' },
|
|
||||||
to: { path: `^src/app/(${FEATURES})/` },
|
|
||||||
},
|
|
||||||
...Object.keys(CONTEXT_ALLOWED).map(contextRule).filter(Boolean),
|
|
||||||
// showcase/ is exempt (reads every context by design); nothing imports it — covered by the
|
|
||||||
// rules above each forbidding `→ showcase`.
|
|
||||||
|
|
||||||
// --- Atomic-layer rules (dependencies point inward: ui → application → domain) ---
|
|
||||||
{
|
|
||||||
name: 'domain-is-pure',
|
|
||||||
comment: 'domain/ is framework-free business logic — no Angular.',
|
|
||||||
severity: 'error',
|
|
||||||
from: { path: '/domain/' },
|
|
||||||
to: { path: 'node_modules/@angular/' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'contracts-import-nothing',
|
|
||||||
comment: 'contracts/ are pure wire DTO shapes — they import nothing (ADR-0001).',
|
|
||||||
severity: 'error',
|
|
||||||
from: { path: '/contracts/' },
|
|
||||||
to: { pathNot: '/contracts/', path: '^(src/app/|node_modules/@angular/)' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ui-not-infrastructure',
|
|
||||||
comment:
|
|
||||||
'ui/ + layout/ reach data through an application store/command, never infrastructure directly (type-only DTO imports allowed).',
|
|
||||||
severity: 'error',
|
|
||||||
from: {
|
|
||||||
path: '(/ui/|/layout/)',
|
|
||||||
pathNot: '\\.stories\\.ts$|\\.spec\\.ts$|^src/app/shared/ui/debug-state/',
|
|
||||||
},
|
|
||||||
to: { path: '/infrastructure/', dependencyTypesNot: ['type-only'] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'apiclient-infrastructure-only',
|
|
||||||
comment:
|
|
||||||
'The generated ApiClient is a value only inside infrastructure/ (+ shared/upload); elsewhere type-only.',
|
|
||||||
severity: 'error',
|
|
||||||
from: { pathNot: '/infrastructure/|^src/app/shared/upload/' },
|
|
||||||
to: {
|
|
||||||
path: '^src/app/shared/infrastructure/api-client\\.ts$',
|
|
||||||
dependencyTypesNot: ['type-only'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Hygiene (cheap wins a graph makes obvious) ---
|
|
||||||
{
|
|
||||||
name: 'no-circular',
|
|
||||||
comment: 'No cyclic dependencies.',
|
|
||||||
severity: 'error',
|
|
||||||
from: {},
|
|
||||||
to: { circular: true },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
options: {
|
|
||||||
doNotFollow: { path: 'node_modules' },
|
|
||||||
tsConfig: { fileName: 'tsconfig.json' }, // resolves @shared/@registratie/… path aliases
|
|
||||||
tsPreCompilationDeps: true, // needed so `type-only` imports are distinguished
|
|
||||||
enhancedResolveOptions: {
|
|
||||||
exportsFields: ['exports'],
|
|
||||||
conditionNames: ['import', 'require', 'node', 'default'],
|
|
||||||
},
|
|
||||||
reporterOptions: {
|
|
||||||
// Context-level architecture graph for `npm run dep:graph` (mermaid — no graphviz needed).
|
|
||||||
archi: { collapsePattern: '^src/app/[^/]+/[^/]+' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// The SSP's own context boundaries. Add a context here — nowhere else — when
|
||||||
|
// scaffolding one (see `gen:context`, WP-44).
|
||||||
|
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: [],
|
||||||
|
registratie: [],
|
||||||
|
herregistratie: ['registratie'],
|
||||||
|
brief: [],
|
||||||
|
showcase: null, // unrestricted — the sanctioned teaching page; nothing imports it
|
||||||
|
},
|
||||||
|
'ssp',
|
||||||
|
'apps/ssp/tsconfig.json',
|
||||||
|
);
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
.git
|
.git
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
storybook-static
|
storybook-static*
|
||||||
backend/**/bin
|
backend/**/bin
|
||||||
backend/**/obj
|
backend/**/obj
|
||||||
backend/**/bigregister.db*
|
backend/**/bigregister.db*
|
||||||
|
|||||||
+38
-13
@@ -35,16 +35,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
frontend:
|
frontend:
|
||||||
- 'src/**'
|
- 'apps/**'
|
||||||
|
- 'libs/**'
|
||||||
- 'public/**'
|
- 'public/**'
|
||||||
- 'e2e/**'
|
- 'e2e/**'
|
||||||
- 'scripts/**'
|
- 'scripts/**'
|
||||||
- 'angular.json'
|
- 'angular.json'
|
||||||
- 'package*.json'
|
- 'package*.json'
|
||||||
- 'tsconfig*.json'
|
- 'tsconfig*.json'
|
||||||
- '.storybook/**'
|
- '.storybook*/**'
|
||||||
- 'eslint.config.mjs'
|
- 'eslint.config.mjs'
|
||||||
- '.dependency-cruiser.js'
|
- '.dependency-cruiser*.js'
|
||||||
- '.github/workflows/**'
|
- '.github/workflows/**'
|
||||||
backend:
|
backend:
|
||||||
- 'backend/**'
|
- 'backend/**'
|
||||||
@@ -74,10 +75,14 @@ jobs:
|
|||||||
if: needs.changes.outputs.frontend == 'true' && steps.node-modules-cache.outputs.cache-hit != 'true'
|
if: needs.changes.outputs.frontend == 'true' && steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
if: needs.changes.outputs.frontend == 'true'
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
|
- run: npm run typecheck
|
||||||
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
- run: npm run format:check
|
- run: npm run format:check
|
||||||
if: needs.changes.outputs.frontend == 'true'
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
- run: npm run check:tokens
|
- run: npm run check:tokens
|
||||||
if: needs.changes.outputs.frontend == 'true'
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
|
- run: npm run check:seam
|
||||||
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
needs: changes
|
needs: changes
|
||||||
@@ -104,19 +109,30 @@ jobs:
|
|||||||
- run: npm run dep:check
|
- run: npm run dep:check
|
||||||
if: needs.changes.outputs.frontend == 'true'
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
# Showcase snippets must match their real source regions (WP-39, no drift).
|
# Showcase snippets must match their real source regions (WP-39, no drift).
|
||||||
- run: npm run gen:snippets && git diff --exit-code src/app/showcase/snippets.generated.ts
|
- run: npm run gen:snippets && git diff --exit-code apps/ssp/src/app/showcase/snippets.generated.ts
|
||||||
if: needs.changes.outputs.frontend == 'true'
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
# Runs the full suite AND reports coverage (WP-46, report-only — no thresholds, so
|
# Behaviour spec must match the real test names it's generated from (WP-71, no drift).
|
||||||
# it can't fail on coverage; it still fails on a failing test, like `npm test` did).
|
- run: npm run gen:behaviour-spec && git diff --exit-code libs/shared/docs/behaviour-spec.mdx
|
||||||
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
|
# Runs the full suite (both apps + both shared libraries, WP-67) AND reports coverage
|
||||||
|
# (WP-46, report-only — no thresholds, so it can't fail on coverage; it still fails on
|
||||||
|
# a failing test, like `npm test` did).
|
||||||
- run: npm run test:coverage
|
- run: npm run test:coverage
|
||||||
if: needs.changes.outputs.frontend == 'true'
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
# --localize builds every configured locale (nl + en, angular.json's i18n
|
# --localize builds every configured locale (nl + en, angular.json's i18n block) in one
|
||||||
# block) in one pass; i18nMissingTranslation:"error" (angular.json) fails
|
# pass per app; i18nMissingTranslation:"error" (angular.json) fails this step if either
|
||||||
# this step if messages.en.xlf is missing a unit the source (WP-20) gains.
|
# app's messages.en.xlf is missing a unit its source (WP-20) or libs/shared gains.
|
||||||
- run: npx ng build --localize
|
- run: npx ng build ssp --localize && npx ng build behandelportal --localize
|
||||||
if: needs.changes.outputs.frontend == 'true'
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
# The shipped bundle must stay clean; dev-only advisories are excluded.
|
# The shipped bundle must stay clean; dev-only advisories are excluded. The gate is
|
||||||
- run: npm audit --omit=dev
|
# `high`, not the default `low`, because two moderate Angular advisories stay open
|
||||||
|
# while we hold at 22.0.5: GHSA-p297-fm68-3q8c and GHSA-hh8m-fm6v-7cvg. Neither is
|
||||||
|
# reachable — the app calls no `withRequestsMadeViaParent` and no
|
||||||
|
# `provideClientHydration`, and binds no untrusted value into a directive host
|
||||||
|
# binding. The fix is Angular 22.1.x, which emits `var(--%NS%name)` and breaks every
|
||||||
|
# `--rhc-*` token. Restore `low` after an Angular release substitutes the
|
||||||
|
# placeholder; verify with `grep -rl '%NS%' dist/` after `npm run build`.
|
||||||
|
- run: npm audit --omit=dev --audit-level=high
|
||||||
if: needs.changes.outputs.frontend == 'true'
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
|
|
||||||
storybook-a11y:
|
storybook-a11y:
|
||||||
@@ -166,6 +182,10 @@ jobs:
|
|||||||
if: needs.changes.outputs.frontend == 'true'
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
- run: npm run test-storybook:ci
|
- run: npm run test-storybook:ci
|
||||||
if: needs.changes.outputs.frontend == 'true'
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
|
- run: npm run build-storybook:behandelportal
|
||||||
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
|
- run: npm run test-storybook:ci:behandelportal
|
||||||
|
if: needs.changes.outputs.frontend == 'true'
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
needs: changes
|
needs: changes
|
||||||
@@ -189,6 +209,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
|
||||||
@@ -300,5 +325,5 @@ jobs:
|
|||||||
if: (needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true') && steps.node-modules-cache.outputs.cache-hit != 'true'
|
if: (needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true') && steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||||
- run: npm run gen:api
|
- run: npm run gen:api
|
||||||
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true'
|
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true'
|
||||||
- run: git diff --exit-code src/app/shared/infrastructure/api-client.ts backend/swagger.json
|
- run: git diff --exit-code libs/shared/src/infrastructure/api-client.ts backend/swagger.json
|
||||||
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true'
|
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true'
|
||||||
|
|||||||
+6
-1
@@ -44,7 +44,8 @@ __screenshots__/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
*storybook.log
|
*storybook.log
|
||||||
storybook-static
|
storybook-static*
|
||||||
|
documentation.json
|
||||||
|
|
||||||
# Playwright e2e
|
# Playwright e2e
|
||||||
/test-results
|
/test-results
|
||||||
@@ -57,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/
|
||||||
|
|||||||
+13
-3
@@ -1,16 +1,22 @@
|
|||||||
# Build output & caches
|
# Build output & caches
|
||||||
dist/
|
dist/
|
||||||
storybook-static/
|
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
|
||||||
|
|
||||||
# Generated — owned by their generators, not prettier
|
# Generated — owned by their generators, not prettier
|
||||||
documentation.json
|
documentation.json
|
||||||
src/app/shared/infrastructure/api-client.ts
|
libs/shared/src/infrastructure/api-client.ts
|
||||||
src/app/showcase/snippets.generated.ts
|
apps/ssp/src/app/showcase/snippets.generated.ts
|
||||||
|
libs/shared/docs/behaviour-spec.mdx
|
||||||
|
|
||||||
# Vendored design system (CIBG Huisstijl)
|
# Vendored design system (CIBG Huisstijl)
|
||||||
public/cibg-huisstijl/
|
public/cibg-huisstijl/
|
||||||
@@ -20,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/archive/refactor-backlog-setup/agents/
|
||||||
|
docs/project/archive/refactor-backlog-setup/refactor-backlog/final-prompts/
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { StorybookConfig } from '@storybook/angular';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
// WP-67: see .storybook-ssp/main.ts's comment for why this is a separate config dir.
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: [
|
||||||
|
'../apps/behandelportal/src/**/*.mdx',
|
||||||
|
'../libs/shared/**/*.mdx',
|
||||||
|
'../libs/beheer/**/*.mdx',
|
||||||
|
'../apps/behandelportal/src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
|
||||||
|
'../libs/shared/**/*.stories.@(js|jsx|mjs|ts|tsx)',
|
||||||
|
'../libs/beheer/**/*.stories.@(js|jsx|mjs|ts|tsx)',
|
||||||
|
],
|
||||||
|
addons: [
|
||||||
|
'@storybook/addon-a11y',
|
||||||
|
{
|
||||||
|
name: '@storybook/addon-docs',
|
||||||
|
options: { mdxPluginOptions: { mdxCompileOptions: { remarkPlugins: [remarkGfm] } } },
|
||||||
|
},
|
||||||
|
'@storybook/addon-onboarding',
|
||||||
|
],
|
||||||
|
framework: '@storybook/angular',
|
||||||
|
staticDirs: ['../public'],
|
||||||
|
webpackFinal: async (config) => {
|
||||||
|
config.optimization = { ...config.optimization, nodeEnv: false };
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Preview } from '@storybook/angular';
|
||||||
|
import { componentWrapperDecorator } from '@storybook/angular';
|
||||||
|
import { setCompodocJson } from '@storybook/addon-docs/angular';
|
||||||
|
import docJson from './documentation.json';
|
||||||
|
setCompodocJson(docJson);
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') document.body.classList.add('brand--cibg');
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
decorators: [componentWrapperDecorator((story) => `<div style="padding:1.5rem">${story}</div>`)],
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded',
|
||||||
|
a11y: {
|
||||||
|
config: { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] } },
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/i,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Sidebar tells the DDD seam: reusable design system, then domain contexts. The
|
||||||
|
// "Foundations" MDX pages live in libs/shared/docs and apply to either app's Storybook.
|
||||||
|
options: {
|
||||||
|
storySort: {
|
||||||
|
order: [
|
||||||
|
'Foundations',
|
||||||
|
[
|
||||||
|
'Overview',
|
||||||
|
'Learning Path',
|
||||||
|
'Domain-Driven Design',
|
||||||
|
'Atomic Design',
|
||||||
|
'FP in the UI',
|
||||||
|
'State Machines (TEA)',
|
||||||
|
'RemoteData & Async',
|
||||||
|
"Parse, don't validate",
|
||||||
|
'Design Tokens',
|
||||||
|
'CIBG Gap Register',
|
||||||
|
'Accessibility',
|
||||||
|
'Testing strategy',
|
||||||
|
'BDD',
|
||||||
|
'Internationalization',
|
||||||
|
],
|
||||||
|
'Design System',
|
||||||
|
['Atoms', 'Molecules', 'Organisms', 'Templates', 'Devtools'],
|
||||||
|
'Domein',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default preview;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// This tsconfig is used by Compodoc to generate the documentation for the project.
|
||||||
|
// If Compodoc is not used, this file can be deleted.
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": [
|
||||||
|
"../apps/behandelportal/src/test.ts",
|
||||||
|
"../apps/behandelportal/src/**/*.spec.ts",
|
||||||
|
"../apps/behandelportal/src/**/*.stories.ts",
|
||||||
|
"../libs/shared/**/*.spec.ts",
|
||||||
|
"../libs/shared/**/*.stories.ts",
|
||||||
|
"../libs/beheer/**/*.spec.ts",
|
||||||
|
"../libs/beheer/**/*.stories.ts"
|
||||||
|
],
|
||||||
|
"include": ["../apps/behandelportal/src/**/*", "../libs/shared/**/*", "../libs/beheer/**/*"],
|
||||||
|
"files": ["./typings.d.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "../apps/behandelportal/tsconfig.app.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node", "@angular/localize"],
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
},
|
||||||
|
"exclude": ["../apps/behandelportal/src/test.ts", "../apps/behandelportal/src/**/*.spec.ts"],
|
||||||
|
"include": [
|
||||||
|
"../apps/behandelportal/src/**/*.stories.*",
|
||||||
|
"../libs/shared/**/*.stories.*",
|
||||||
|
"../libs/beheer/**/*.stories.*",
|
||||||
|
"./preview.ts"
|
||||||
|
],
|
||||||
|
"files": ["./typings.d.ts"]
|
||||||
|
}
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import type { StorybookConfig } from '@storybook/angular';
|
import type { StorybookConfig } from '@storybook/angular';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
// WP-67: one config dir per app — a single merged tsconfig can't resolve both apps' @auth/*
|
||||||
|
// alias at once (each points at a different physical directory), so this Storybook instance
|
||||||
|
// only ever compiles under the ssp project's own browserTarget/tsconfig (see angular.json's
|
||||||
|
// ssp:storybook target) and only globs ssp's own stories + the shared libraries'.
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
stories: [
|
||||||
|
'../apps/ssp/src/**/*.mdx',
|
||||||
|
'../libs/shared/**/*.mdx',
|
||||||
|
'../libs/beheer/**/*.mdx',
|
||||||
|
'../apps/ssp/src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
|
||||||
|
'../libs/shared/**/*.stories.@(js|jsx|mjs|ts|tsx)',
|
||||||
|
'../libs/beheer/**/*.stories.@(js|jsx|mjs|ts|tsx)',
|
||||||
|
],
|
||||||
addons: [
|
addons: [
|
||||||
'@storybook/addon-a11y',
|
'@storybook/addon-a11y',
|
||||||
// remark-gfm so GFM pipe tables in *.mdx docs actually render (addon-docs
|
// remark-gfm so GFM pipe tables in *.mdx docs actually render (addon-docs
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- CIBG Huisstijl (customized Bootstrap 5.2), served from the vendored public/ staticDir.
|
||||||
|
Mirrors src/index.html so stories match the app. System font per styles.scss. -->
|
||||||
|
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
|
||||||
|
<!-- Letter-rendering contract (WP-24), mirrors index.html. -->
|
||||||
|
<link rel="stylesheet" href="letter.css" />
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Preview } from '@storybook/angular';
|
import type { Preview } from '@storybook/angular';
|
||||||
import { componentWrapperDecorator } from '@storybook/angular';
|
import { componentWrapperDecorator } from '@storybook/angular';
|
||||||
import { setCompodocJson } from '@storybook/addon-docs/angular';
|
import { setCompodocJson } from '@storybook/addon-docs/angular';
|
||||||
import docJson from '../documentation.json';
|
import docJson from './documentation.json';
|
||||||
setCompodocJson(docJson);
|
setCompodocJson(docJson);
|
||||||
|
|
||||||
// Activate CIBG's official palette in the story iframe. The override is `body.brand--cibg`,
|
// Activate CIBG's official palette in the story iframe. The override is `body.brand--cibg`,
|
||||||
@@ -26,7 +26,7 @@ const preview: Preview = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Sidebar tells the DDD seam: reusable design system, then domain contexts.
|
// Sidebar tells the DDD seam: reusable design system, then domain contexts.
|
||||||
// See src/docs/layers.mdx.
|
// See libs/shared/docs/layers.mdx.
|
||||||
options: {
|
options: {
|
||||||
storySort: {
|
storySort: {
|
||||||
order: [
|
order: [
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { TestRunnerConfig } from '@storybook/test-runner';
|
||||||
|
import { getStoryContext } from '@storybook/test-runner';
|
||||||
|
import { injectAxe, checkA11y } from 'axe-playwright';
|
||||||
|
|
||||||
|
const config: TestRunnerConfig = {
|
||||||
|
async preVisit(page) {
|
||||||
|
await injectAxe(page);
|
||||||
|
},
|
||||||
|
async postVisit(page, context) {
|
||||||
|
const storyContext = await getStoryContext(page, context);
|
||||||
|
if (storyContext.parameters?.a11y?.disable) return;
|
||||||
|
|
||||||
|
await checkA11y(page, '#storybook-root', {
|
||||||
|
axeOptions: storyContext.parameters?.a11y?.config,
|
||||||
|
detailedReport: true,
|
||||||
|
detailedReportOptions: { html: true },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -3,8 +3,16 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
// Exclude all files that are not needed for documentation generation.
|
// Exclude all files that are not needed for documentation generation.
|
||||||
"exclude": ["../src/test.ts", "../src/**/*.spec.ts", "../src/**/*.stories.ts"],
|
"exclude": [
|
||||||
|
"../apps/ssp/src/test.ts",
|
||||||
|
"../apps/ssp/src/**/*.spec.ts",
|
||||||
|
"../apps/ssp/src/**/*.stories.ts",
|
||||||
|
"../libs/shared/**/*.spec.ts",
|
||||||
|
"../libs/shared/**/*.stories.ts",
|
||||||
|
"../libs/beheer/**/*.spec.ts",
|
||||||
|
"../libs/beheer/**/*.stories.ts"
|
||||||
|
],
|
||||||
// Please make sure to include all files from which Compodoc should generate documentation.
|
// Please make sure to include all files from which Compodoc should generate documentation.
|
||||||
"include": ["../src/**/*"],
|
"include": ["../apps/ssp/src/**/*", "../libs/shared/**/*", "../libs/beheer/**/*"],
|
||||||
"files": ["./typings.d.ts"]
|
"files": ["./typings.d.ts"]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "../apps/ssp/tsconfig.app.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node", "@angular/localize"],
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
},
|
||||||
|
"exclude": ["../apps/ssp/src/test.ts", "../apps/ssp/src/**/*.spec.ts"],
|
||||||
|
"include": [
|
||||||
|
"../apps/ssp/src/**/*.stories.*",
|
||||||
|
"../libs/shared/**/*.stories.*",
|
||||||
|
"../libs/beheer/**/*.stories.*",
|
||||||
|
"./preview.ts"
|
||||||
|
],
|
||||||
|
"files": ["./typings.d.ts"]
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module '*.md' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../tsconfig.app.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"types": ["node", "@angular/localize"],
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"moduleResolution": "bundler"
|
|
||||||
},
|
|
||||||
"exclude": ["../src/test.ts", "../src/**/*.spec.ts"],
|
|
||||||
"include": ["../src/**/*.stories.*", "./preview.ts"],
|
|
||||||
"files": ["./typings.d.ts"]
|
|
||||||
}
|
|
||||||
@@ -12,22 +12,37 @@ signals. Auth is faked; **data and business rules are served by a minimal ASP.NE
|
|||||||
Core backend** (`backend/`, see its README) and consumed through an NSwag-generated
|
Core backend** (`backend/`, see its README) and consumed through an NSwag-generated
|
||||||
typed client. The FE renders the backend's decisions. Reference data mimicking
|
typed client. The FE renders the backend's decisions. Reference data mimicking
|
||||||
BRP/DUO (`Data/SeedData.cs`) is in-memory; applications, documents and the brief
|
BRP/DUO (`Data/SeedData.cs`) is in-memory; applications, documents and the brief
|
||||||
persist to a SQLite file via EF Core (WP-22) — `docs/project/backlog/WP-22-durable-persistence.md`.
|
persist to a SQLite file via EF Core (WP-22) — `docs/project/archive/backlog/WP-22-durable-persistence.md`.
|
||||||
|
|
||||||
|
**Monorepo (WP-67):** two Angular projects share one backend + one shared library —
|
||||||
|
`apps/ssp` (Zorgverlener self-service, this doc's main subject) and `apps/behandelportal`
|
||||||
|
(Behandelaar backoffice, ADR-0002). Both import `libs/shared` (design system + kernel +
|
||||||
|
generated API client) and `libs/beheer` (the admin/stamdata context, used identically by
|
||||||
|
both). `backend/` is unowned by either — a genuinely shared dependency.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start # ng serve (proxies /api → backend) → http://localhost:4200
|
npm start # ng serve ssp (proxies /api → backend) → http://localhost:4200
|
||||||
npm test # vitest
|
npm run start:behandelportal # ng serve behandelportal → http://localhost:4201
|
||||||
npm run lint # eslint — enforces `any`-free code + import/layer boundaries
|
npm test # vitest — both apps + both shared libraries (ssp, behandelportal, shared, beheer)
|
||||||
npm run build # ng build (must stay green)
|
npm run lint # eslint — enforces `any`-free code + import/layer boundaries
|
||||||
npm run storybook # component library by atomic layer
|
npm run build # ng build ssp && ng build behandelportal (must stay green)
|
||||||
npm run gen:api # regenerate the typed client from the backend OpenAPI doc
|
npm run storybook # ssp's component library by atomic layer
|
||||||
npm run ci # run the CI gate locally BEFORE pushing (mirrors ci.yml); `npm run ci --full` adds storybook-a11y
|
npm run storybook:behandelportal # behandelportal's own instance (see "Monorepo" note below)
|
||||||
docker compose up # run FE + backend together (Swagger at :5000/swagger)
|
npm run gen:api # regenerate the ONE typed client (libs/shared) from the backend OpenAPI doc
|
||||||
cd backend && dotnet test # backend rule + endpoint tests
|
npm run ci # run the CI gate locally BEFORE pushing (mirrors ci.yml); `npm run ci --full` adds storybook-a11y
|
||||||
|
docker compose up # run both FE apps + backend together (Swagger at :5000/swagger)
|
||||||
|
cd backend && dotnet test # backend rule + endpoint tests
|
||||||
|
task # list every task (a thin facade over the commands above)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Two Storybook instances, not one:** `apps/ssp` and `apps/behandelportal` each have their own
|
||||||
|
`auth` context at the same `@auth/*` alias pointing at different physical directories — a single
|
||||||
|
merged tsconfig can't resolve both at once, so `.storybook-ssp/` and `.storybook-behandelportal/`
|
||||||
|
are separate config dirs (`npm run storybook[:behandelportal]` / `build-storybook[:behandelportal]`),
|
||||||
|
each globbing its own app's stories + both shared libraries'.
|
||||||
|
|
||||||
**Run `npm run ci` before every push** (`scripts/ci-local.sh`) — it runs the same jobs
|
**Run `npm run ci` before every push** (`scripts/ci-local.sh`) — it runs the same jobs
|
||||||
Gitea CI does (lint, format:check, check:tokens, test, `ng build --localize`, audit, backend
|
Gitea CI does (lint, format:check, check:tokens, test, `ng build --localize`, audit, backend
|
||||||
format+test, api-client drift), so a red build is caught locally. Two ways to make it
|
format+test, api-client drift), so a red build is caught locally. Two ways to make it
|
||||||
@@ -43,7 +58,10 @@ catches a miss before CI does.
|
|||||||
|
|
||||||
`.npmrc` sets `legacy-peer-deps=true` (Storybook's peer range lags Angular 22).
|
`.npmrc` sets `legacy-peer-deps=true` (Storybook's peer range lags Angular 22).
|
||||||
Do not run `npm audit fix --force` — it downgrades Angular 22→21. Dev-only
|
Do not run `npm audit fix --force` — it downgrades Angular 22→21. Dev-only
|
||||||
advisories are pinned via `package.json` `overrides`; the shipped bundle audits clean.
|
advisories are pinned via `package.json` `overrides`. Angular is pinned to the exact
|
||||||
|
version 22.0.5: 22.1.x emits `var(--%NS%name)` and breaks every `--rhc-*` token, so the
|
||||||
|
two moderate advisories it fixes stay open. Neither is reachable, so the audit gate runs
|
||||||
|
at `--audit-level=high` (see the comment in `ci.yml`).
|
||||||
|
|
||||||
## Model routing for agent delegation
|
## Model routing for agent delegation
|
||||||
|
|
||||||
@@ -58,16 +76,25 @@ so this doesn't depend on a human remembering to run `/model` at the right momen
|
|||||||
`git status`/`grep`, verifying a file exists. No Edit/Write access.
|
`git status`/`grep`, verifying a file exists. No Edit/Write access.
|
||||||
|
|
||||||
Delegate to the matching agent only when the _current_ session isn't already on that
|
Delegate to the matching agent only when the _current_ session isn't already on that
|
||||||
model — don't add indirection for its own sake. `docs/project/backlog/README.md`'s
|
model — don't add indirection for its own sake. `docs/project/archive/backlog/README.md`'s
|
||||||
session protocol is the worked example of this in practice.
|
session protocol is the worked example of this in practice.
|
||||||
|
|
||||||
## The decisions (non-negotiable working agreements)
|
## The decisions (non-negotiable working agreements)
|
||||||
|
|
||||||
### 1. DDD: contexts then layers, dependencies point inward
|
### 1. DDD: contexts then layers, dependencies point inward
|
||||||
|
|
||||||
`src/app/<context>/<layer>/`. Contexts: `shared`, `auth`, `registratie`,
|
`apps/<app>/src/app/<context>/<layer>/` for an app-local context; `libs/<lib>/src/<layer>/`
|
||||||
`herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching
|
for a cross-app library (WP-67). Two apps today: `apps/ssp` (Zorgverlener self-service —
|
||||||
page, not a feature; **sanctioned** to read every context — nothing imports it).
|
contexts `auth`, `overzicht` (the portal home; composes `registratie`'s dashboard sections
|
||||||
|
plus its own cross-context nav sections), `registratie`, `herregistratie`, `brief`
|
||||||
|
(letter-composition teaching slice), `showcase` (teaching page, not a feature; **sanctioned**
|
||||||
|
to read every context in its own app — nothing imports it)) and `apps/behandelportal`
|
||||||
|
(Behandelaar backoffice, ADR-0002 — contexts `auth`, `behandeling`). Two cross-app libraries: `libs/shared` (the
|
||||||
|
design system + kernel + generated API client — no business logic) and `libs/beheer` (the
|
||||||
|
admin/stamdata context, identical for both apps today — WP-67 folded a silently-diverging
|
||||||
|
duplicate copy back into one). `auth` is deliberately **not** shared even though today it's
|
||||||
|
near-identical in both apps — ADR-0002 models Zorgverlener/Medewerker as different
|
||||||
|
`Principal` variants with different login flows; the two copies are expected to diverge.
|
||||||
|
|
||||||
| Layer | Job | Angular allowed? |
|
| Layer | Job | Angular allowed? |
|
||||||
| ----------------- | ----------------------------------------- | -------------------------------- |
|
| ----------------- | ----------------------------------------- | -------------------------------- |
|
||||||
@@ -77,31 +104,57 @@ page, not a feature; **sanctioned** to read every context — nothing imports it
|
|||||||
| `contracts/` | wire DTOs (the FE⇄BE seam) | no |
|
| `contracts/` | wire DTOs (the FE⇄BE seam) | no |
|
||||||
| `ui/` | how it looks (components, pages) | yes |
|
| `ui/` | how it looks (components, pages) | yes |
|
||||||
|
|
||||||
**Dependencies only point inward**: `ui → application → domain`; everyone may use
|
**Dependencies only point inward**: `ui → application → domain`; every context in either
|
||||||
`shared`; never the reverse. `ui`/`layout` never import `infrastructure` directly
|
app may use `libs/shared` and `libs/beheer`; never the reverse (`libs/shared` may not
|
||||||
(reach data through an application store/command) — lint-enforced. Cross-context only
|
depend on `libs/beheer` either — it stays the base). `ui`/`layout` never import
|
||||||
`herregistratie → registratie → shared`, `auth → shared`, `brief → shared`. Imports use
|
`infrastructure` directly (reach data through an application store/command) —
|
||||||
aliases as direction statements: `@shared/* @auth/* @registratie/* @herregistratie/*
|
lint-enforced (per app, since each app is cruised against its own tsconfig — WP-67's
|
||||||
@brief/*`. `domain/` imports nothing from Angular.
|
`.dependency-cruiser.base.js` + one thin `.dependency-cruiser.<app>.js` per app). An app
|
||||||
|
may not import the other app's source directly. Cross-context only
|
||||||
|
`overzicht → registratie → libs/shared|beheer`, `herregistratie → registratie →
|
||||||
|
libs/shared|beheer`, `auth → libs/shared|beheer`, `brief → libs/shared|beheer` (ssp);
|
||||||
|
`behandeling → libs/shared|beheer`, `auth → libs/shared|beheer` (behandelportal). Imports use
|
||||||
|
aliases as direction statements:
|
||||||
|
`@shared/* @beheer/* @auth/* @overzicht/* @registratie/* @herregistratie/* @brief/*` (ssp) —
|
||||||
|
`@shared/* @beheer/* @auth/* @behandeling/*` (behandelportal); each app's own
|
||||||
|
`tsconfig.json` declares its full map (the root `tsconfig.json` intentionally has no
|
||||||
|
`paths` — see its comment). `domain/` imports nothing from Angular.
|
||||||
|
|
||||||
### 2. Atomic design: folder = layer
|
### 2. Atomic design: folder = layer
|
||||||
|
|
||||||
`shared/ui` atoms → molecules → organisms; `shared/layout` templates (`shell`,
|
`libs/shared/ui` atoms → molecules → organisms; `libs/shared/layout` templates (`shell`,
|
||||||
`page-shell`); context `ui/` pages. Each level only uses levels below. A new page
|
`page-shell`); each app's own context `ui/` pages. Each level only uses levels below,
|
||||||
|
and a shared component takes nav/copy as `input()`s or an injection token (e.g.
|
||||||
|
`HEADER_NAV_ITEMS`/`HEADER_ADMIN_LINKS`, `DEBUG_PANEL` in `shell.component.ts`) rather
|
||||||
|
than hardcoding one app's content — the two apps' primary nav genuinely differs. A new page
|
||||||
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.)
|
||||||
|
|
||||||
|
**The step-component contract.** A wizard step follows the same rule as
|
||||||
|
`address-fields.component.ts`: values in, events out, no internal state. Three clauses:
|
||||||
|
|
||||||
|
1. **Inputs down.** A step reads its data only from `input()`s the container passes it.
|
||||||
|
2. **One narrow output up.** A step emits one specific event, not the container's whole
|
||||||
|
`dispatch`.
|
||||||
|
3. **`dispatch` is never passed down.** The container owns the Model and decides what a
|
||||||
|
step's event means; a step never calls `dispatch` itself.
|
||||||
|
|
||||||
|
Corollary: a step gets **no** story of its own. The wizard's own story already mounts every
|
||||||
|
step, because it seeds the machine.
|
||||||
|
|
||||||
### 3. State: make illegal states unrepresentable
|
### 3. State: make illegal states unrepresentable
|
||||||
|
|
||||||
Default reflex — **if you're about to add a second/third boolean to track state,
|
Default reflex — **if you're about to add a second/third boolean to track state,
|
||||||
model a discriminated union instead.** Three tools, all in `shared/application`:
|
model a discriminated union instead.** Three tools, all in `libs/shared/src/application`:
|
||||||
|
|
||||||
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`.
|
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`.
|
||||||
Combine sources with `map`/`map2`/`andThen` (Failure > Loading > Success).
|
Combine sources with `map`/`map2`/`andThen` (Failure > Loading > Success).
|
||||||
Render it via the `<app-async>` molecule (`shared/ui/async`) — one of four
|
Render it via the `<app-async>` molecule (`libs/shared/src/ui/molecules/async`) — one of four
|
||||||
templates, mutually exclusive by construction. Default loading spinner/skeleton
|
templates, mutually exclusive by construction. Default loading spinner/skeleton
|
||||||
is delay-gated (~250ms) so fast connections don't flash.
|
is delay-gated (~250ms) so fast connections don't flash.
|
||||||
- **Elm-style store** (`store.ts` → `createStore(initial, reduce)`) — all state in
|
- **Elm-style store** (`store.ts` → `createStore(initial, reduce)`) — all state in
|
||||||
@@ -116,8 +169,9 @@ model a discriminated union instead.** Three tools, all in `shared/application`:
|
|||||||
see `upload.machine.ts`) — prefixing there avoids alias noise at the
|
see `upload.machine.ts`) — prefixing there avoids alias noise at the
|
||||||
composition site.
|
composition site.
|
||||||
- **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a
|
- **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a
|
||||||
branded type only via a parser returning `Result` (`registratie/domain/value-objects/`:
|
branded type only via a parser returning `Result` (ssp's
|
||||||
`Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it.
|
`registratie/domain/value-objects/`: `Postcode`, `Uren`, `BigNummer`). Once you hold
|
||||||
|
the type, never re-check it.
|
||||||
|
|
||||||
**Derive, don't store** what you can compute — e.g. the wizard's visible steps are
|
**Derive, don't store** what you can compute — e.g. the wizard's visible steps are
|
||||||
`visibleSteps(answers)`, not a stored field (`intake.machine.ts`).
|
`visibleSteps(answers)`, not a stored field (`intake.machine.ts`).
|
||||||
@@ -141,29 +195,43 @@ 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
|
||||||
|
|
||||||
Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
|
Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
|
||||||
spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters).
|
spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters).
|
||||||
Test the pure function directly — no Angular TestBed for domain. UI is exercised via
|
Test the pure function directly — no Angular TestBed for domain. UI is exercised via
|
||||||
Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests.
|
Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests —
|
||||||
**Story titles mirror the sidebar's Design System/Domein split** (see
|
each app has its **own Storybook instance** (`.storybook-ssp/`, `.storybook-behandelportal/`,
|
||||||
`src/docs/layers.mdx`): a `shared/ui`/`shared/layout` component is titled
|
WP-67 — a single merged tsconfig can't resolve both apps' `@auth/*` at once), each globbing
|
||||||
`Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`; a component in a
|
its own app's stories plus both shared libraries'. **Story titles mirror the sidebar's
|
||||||
context's `ui/` is titled `Domein/<Context>/<Name>` — full stop, regardless of which
|
Design System/Domein split** (see `libs/shared/docs/layers.mdx`): a `libs/shared/ui|layout`
|
||||||
atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
|
component is titled `Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`;
|
||||||
|
a component in an app context's `ui/`, or in `libs/beheer/ui`, is titled
|
||||||
|
`Domein/<Context>/<Name>` — full stop, regardless of which atomic layer it is (a context
|
||||||
|
organism doesn't get its own `Organisms/` bucket).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
@@ -176,12 +244,23 @@ atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
|
|||||||
- **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
|
||||||
translation file, not a code change (the seam). Shared/English components must **not**
|
translation file, not a code change (the seam). Shared/English components must **not**
|
||||||
hardcode Dutch — expose copy as `input()`s with localizable defaults; the domain caller
|
hardcode Dutch — expose copy as `input()`s with localizable defaults; the domain caller
|
||||||
supplies the text (see `shared/ui/async`). Format-validation messages in
|
supplies the text (see `libs/shared/src/ui/molecules/async`). Format-validation messages in
|
||||||
`domain/value-objects/` stay co-located but are still `$localize`-wrapped.
|
`domain/value-objects/` stay co-located but are still `$localize`-wrapped.
|
||||||
- **Forms = one idiom.** Any form with validation or submission uses a `*.machine.ts`
|
- **Forms = one idiom.** Any form with validation or submission uses a `*.machine.ts`
|
||||||
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
|
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
|
||||||
@@ -190,12 +269,13 @@ atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
|
|||||||
- **Dates: `DatePipe` in templates, `formatDatumNl` in pure TS.** A template formats a
|
- **Dates: `DatePipe` in templates, `formatDatumNl` in pure TS.** A template formats a
|
||||||
date with Angular's `DatePipe` (`| date: 'longDate'`); pure TS that can't reach a pipe
|
date with Angular's `DatePipe` (`| date: 'longDate'`); pure TS that can't reach a pipe
|
||||||
(a domain function, a `$localize` string) uses the one hand-written
|
(a domain function, a `$localize` string) uses the one hand-written
|
||||||
`formatDatumNl` (`shared/kernel/datum.ts`). Never a third hand-rolled
|
`formatDatumNl` (`libs/shared/src/kernel/datum.ts`). Never a third hand-rolled
|
||||||
`toLocaleDateString` call.
|
`toLocaleDateString` call.
|
||||||
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate:
|
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent (`libs/shared`),
|
||||||
[authGuard]` on protected routes (`app.routes.ts`).
|
`canActivate: [authGuard]` on protected routes (each app's own `app.routes.ts`).
|
||||||
- Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under
|
- Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under
|
||||||
`public/cibg-huisstijl/` and loaded via a `<link>` in `index.html`; `src/styles.scss` holds a
|
`public/cibg-huisstijl/` and loaded via a `<link>` in each app's `index.html`;
|
||||||
|
`libs/shared/styles.scss` (one copy, both apps' `angular.json` point at it — WP-67) holds a
|
||||||
**token bridge** mapping the app's `--rhc-*` token vocabulary onto CIBG/`--bs-*` values (so
|
**token bridge** mapping the app's `--rhc-*` token vocabulary onto CIBG/`--bs-*` values (so
|
||||||
components keep referencing tokens). System-font stack (licensed RO/Rijks fonts not shipped). See ADR-0003.
|
components keep referencing tokens). System-font stack (licensed RO/Rijks fonts not shipped). See ADR-0003.
|
||||||
- Scenario toggle (**dev-only**, not wired in prod builds): `?scenario=slow|loading|empty|error`
|
- Scenario toggle (**dev-only**, not wired in prod builds): `?scenario=slow|loading|empty|error`
|
||||||
@@ -209,18 +289,25 @@ atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
|
|||||||
`/beheer/stamdata`, `/beheer/zaken`, `/beheer/audit`, `/beheer/functies`.
|
`/beheer/stamdata`, `/beheer/zaken`, `/beheer/audit`, `/beheer/functies`.
|
||||||
- Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`,
|
- Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`,
|
||||||
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
|
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
|
||||||
- **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`) fails the build
|
- **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`, scoped to
|
||||||
on `any` and on illegal imports — `domain/` importing Angular, or a context importing
|
`{apps,libs}/**`) fails the build on `any`; the same config's `max-lines` rule caps every
|
||||||
"upward" (the `herregistratie → registratie → shared`, `auth → shared` direction).
|
`{apps,libs}/**/*.{page,component,section,step}.ts` file at 250 lines
|
||||||
CI (`.github/workflows/ci.yml`) runs lint + `check:tokens` + test + build, backend
|
(`skipBlankLines: true`, `skipComments: true`). 250 is reachable, not a style-guide
|
||||||
`dotnet test`, and an API-client drift check.
|
default — the dashboard page lands at 42 lines. `npm run dep:check`
|
||||||
|
(`.dependency-cruiser.base.js` + one `.dependency-cruiser.<app>.js` per app, WP-67) fails
|
||||||
|
on illegal imports — `domain/` importing Angular, a context importing "upward" (the
|
||||||
|
`herregistratie → registratie → shared`, `auth → shared` direction), an app importing the
|
||||||
|
other app's source, or `libs/shared` depending on `libs/beheer`. CI
|
||||||
|
(`.github/workflows/ci.yml`) runs lint + `dep:check` + `check:tokens` + test (both apps +
|
||||||
|
both libraries) + build (both apps), backend `dotnet test`, and an API-client drift check
|
||||||
|
(one generated client, `libs/shared/src/infrastructure/api-client.ts`).
|
||||||
|
|
||||||
## Adding a feature (recipe)
|
## Adding a feature (recipe)
|
||||||
|
|
||||||
Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter:
|
Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter:
|
||||||
`httpResource` or command returning `Result`) → application (store if shared state;
|
`httpResource` or command returning `Result`) → application (store if shared state;
|
||||||
union + pure reduce) → UI last (compose `shared/ui` atoms, wrap async in `<app-async>`,
|
union + pure reduce) → UI last (compose `libs/shared/ui` atoms, wrap async in
|
||||||
dispatch messages). Worked example: the intake wizard (`herregistratie/`).
|
`<app-async>`, dispatch messages). Worked example: the SSP's intake wizard (`herregistratie/`).
|
||||||
|
|
||||||
The recipes are also invocable skills in `.claude/skills/`: `new-feature`,
|
The recipes are also invocable skills in `.claude/skills/`: `new-feature`,
|
||||||
`new-context`, `value-object`, `form-machine`, `bff-endpoint`, `mutation-command`,
|
`new-context`, `value-object`, `form-machine`, `bff-endpoint`, `mutation-command`,
|
||||||
|
|||||||
@@ -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/archive/backlog/README.md](docs/project/archive/backlog/README.md) |
|
||||||
|
| build a feature the house way | [`.claude/skills/`](.claude/skills/) — invocable recipes (`new-feature`, `form-machine`, …) |
|
||||||
|
| 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.
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# ponytail: a thin façade over the npm scripts + dotnet + docker, NOT a second copy of
|
||||||
|
# package.json. `task` (no args) lists everything runnable; the logic stays where it is —
|
||||||
|
# `task ci` shells to scripts/ci-local.sh, which remains the gate. npm keeps working
|
||||||
|
# exactly as before, and CI does not depend on `task` being installed.
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
default:
|
||||||
|
silent: true
|
||||||
|
cmd: task --list
|
||||||
|
|
||||||
|
deps:
|
||||||
|
internal: true
|
||||||
|
status:
|
||||||
|
- test -d node_modules
|
||||||
|
cmd: npm ci
|
||||||
|
|
||||||
|
dev:
|
||||||
|
desc: Serve the SSP on :4200 (proxies /api to the backend)
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm start
|
||||||
|
|
||||||
|
dev:bp:
|
||||||
|
desc: Serve the behandelportal on :4201
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm run start:behandelportal
|
||||||
|
|
||||||
|
api:
|
||||||
|
desc: Run the backend on :5000 (Swagger at /swagger)
|
||||||
|
dir: backend
|
||||||
|
cmd: dotnet run --project src/BigRegister.Api
|
||||||
|
|
||||||
|
test:
|
||||||
|
desc: Run the frontend tests (both apps + both shared libraries)
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm test
|
||||||
|
|
||||||
|
test:api:
|
||||||
|
desc: Run the backend rule + endpoint tests (skips the OpenZaak integration tests)
|
||||||
|
dir: backend
|
||||||
|
# Same filter as scripts/ci-local.sh: the Integration tests need a running OpenZaak
|
||||||
|
# container (scripts/openzaak-ui-up.sh), so they are not part of the default gate.
|
||||||
|
cmd: dotnet test BigRegister.slnx --filter "Category!=Integration"
|
||||||
|
|
||||||
|
lint:
|
||||||
|
desc: Lint the frontend (no `any`, import/layer boundaries)
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm run lint
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
desc: Format the frontend with prettier and the backend with dotnet format
|
||||||
|
deps: [deps]
|
||||||
|
cmds:
|
||||||
|
- npm run format
|
||||||
|
- dotnet format backend/BigRegister.slnx
|
||||||
|
|
||||||
|
build:
|
||||||
|
desc: Build both Angular apps
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm run build
|
||||||
|
|
||||||
|
ci:
|
||||||
|
desc: Run the CI gate locally before pushing (scripts/ci-local.sh)
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm run ci
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
desc: Run the Playwright end-to-end tests (starts its own servers)
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm run e2e
|
||||||
|
|
||||||
|
sb:
|
||||||
|
desc: Run the SSP Storybook instance
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm run storybook
|
||||||
|
|
||||||
|
sb:bp:
|
||||||
|
desc: Run the behandelportal Storybook instance
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm run storybook:behandelportal
|
||||||
|
|
||||||
|
gen:api:
|
||||||
|
desc: Regenerate the typed API client from the backend OpenAPI document
|
||||||
|
deps: [deps]
|
||||||
|
cmd: npm run gen:api
|
||||||
|
|
||||||
|
up:
|
||||||
|
desc: Start both apps + the backend with docker compose
|
||||||
|
cmd: docker compose up
|
||||||
|
|
||||||
|
down:
|
||||||
|
desc: Stop the docker compose stack
|
||||||
|
cmd: docker compose down
|
||||||
+233
-20
@@ -7,21 +7,21 @@
|
|||||||
},
|
},
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
"atomic-design-poc": {
|
"ssp": {
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"schematics": {
|
"schematics": {
|
||||||
"@schematics/angular:component": {
|
"@schematics/angular:component": {
|
||||||
"style": "scss"
|
"style": "scss"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "",
|
"root": "apps/ssp",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "apps/ssp/src",
|
||||||
"prefix": "app",
|
"prefix": "app",
|
||||||
"i18n": {
|
"i18n": {
|
||||||
"sourceLocale": { "code": "nl", "subPath": "" },
|
"sourceLocale": { "code": "nl", "subPath": "" },
|
||||||
"locales": {
|
"locales": {
|
||||||
"en": {
|
"en": {
|
||||||
"translation": "src/locale/messages.en.xlf"
|
"translation": "apps/ssp/src/locale/messages.en.xlf"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -29,8 +29,8 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular/build:application",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
"browser": "src/main.ts",
|
"browser": "apps/ssp/src/main.ts",
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "apps/ssp/tsconfig.app.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
"assets": [
|
"assets": [
|
||||||
{
|
{
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"input": "public"
|
"input": "public"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": ["src/styles.scss"],
|
"styles": ["libs/shared/styles.scss"],
|
||||||
"polyfills": ["@angular/localize/init"],
|
"polyfills": ["@angular/localize/init"],
|
||||||
"i18nMissingTranslation": "error"
|
"i18nMissingTranslation": "error"
|
||||||
},
|
},
|
||||||
@@ -59,8 +59,8 @@
|
|||||||
"outputHashing": "all",
|
"outputHashing": "all",
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
{
|
{
|
||||||
"replace": "src/environments/environment.ts",
|
"replace": "libs/shared/src/environments/environment.ts",
|
||||||
"with": "src/environments/environment.prod.ts"
|
"with": "libs/shared/src/environments/environment.prod.ts"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -77,21 +77,27 @@
|
|||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular/build:dev-server",
|
"builder": "@angular/build:dev-server",
|
||||||
|
"options": {
|
||||||
|
"proxyConfig": "apps/ssp/proxy.conf.json"
|
||||||
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "atomic-design-poc:build:production"
|
"buildTarget": "ssp:build:production"
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"buildTarget": "atomic-design-poc:build:development"
|
"buildTarget": "ssp:build:development"
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
"buildTarget": "atomic-design-poc:build:development,en"
|
"buildTarget": "ssp:build:development,en"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "development"
|
"defaultConfiguration": "development"
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular/build:unit-test",
|
"builder": "@angular/build:unit-test",
|
||||||
|
"options": {
|
||||||
|
"tsConfig": "apps/ssp/tsconfig.spec.json"
|
||||||
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"coverage": {
|
"coverage": {
|
||||||
"coverage": true,
|
"coverage": true,
|
||||||
@@ -100,9 +106,9 @@
|
|||||||
"**/*.spec.ts",
|
"**/*.spec.ts",
|
||||||
"**/*.stories.ts",
|
"**/*.stories.ts",
|
||||||
"**/contracts/**",
|
"**/contracts/**",
|
||||||
"src/app/shared/infrastructure/api-client.ts",
|
"**/infrastructure/api-client.ts",
|
||||||
"src/main.ts",
|
"apps/ssp/src/main.ts",
|
||||||
"src/test-setup.ts",
|
"**/*.testing.ts",
|
||||||
"**/*.d.ts"
|
"**/*.d.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -111,8 +117,8 @@
|
|||||||
"storybook": {
|
"storybook": {
|
||||||
"builder": "@storybook/angular:start-storybook",
|
"builder": "@storybook/angular:start-storybook",
|
||||||
"options": {
|
"options": {
|
||||||
"configDir": ".storybook",
|
"configDir": ".storybook-ssp",
|
||||||
"browserTarget": "atomic-design-poc:build",
|
"browserTarget": "ssp:build",
|
||||||
"compodoc": false,
|
"compodoc": false,
|
||||||
"port": 6006
|
"port": 6006
|
||||||
}
|
}
|
||||||
@@ -120,14 +126,221 @@
|
|||||||
"build-storybook": {
|
"build-storybook": {
|
||||||
"builder": "@storybook/angular:build-storybook",
|
"builder": "@storybook/angular:build-storybook",
|
||||||
"options": {
|
"options": {
|
||||||
"configDir": ".storybook",
|
"configDir": ".storybook-ssp",
|
||||||
"browserTarget": "atomic-design-poc:build",
|
"browserTarget": "ssp:build",
|
||||||
"compodoc": true,
|
"compodoc": true,
|
||||||
"compodocArgs": ["-e", "json", "-d", "."],
|
"compodocArgs": ["-e", "json", "-d", ".storybook-ssp"],
|
||||||
"outputDir": "storybook-static"
|
"outputDir": "storybook-static"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"behandelportal": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "apps/behandelportal",
|
||||||
|
"sourceRoot": "apps/behandelportal/src",
|
||||||
|
"prefix": "app",
|
||||||
|
"i18n": {
|
||||||
|
"sourceLocale": { "code": "nl", "subPath": "" },
|
||||||
|
"locales": {
|
||||||
|
"en": {
|
||||||
|
"translation": "apps/behandelportal/src/locale/messages.en.xlf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular/build:application",
|
||||||
|
"options": {
|
||||||
|
"browser": "apps/behandelportal/src/main.ts",
|
||||||
|
"tsConfig": "apps/behandelportal/tsconfig.app.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": ["libs/shared/styles.scss"],
|
||||||
|
"polyfills": ["@angular/localize/init"],
|
||||||
|
"i18nMissingTranslation": "error"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "1.5MB",
|
||||||
|
"maximumError": "2MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "4kB",
|
||||||
|
"maximumError": "8kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all",
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "libs/shared/src/environments/environment.ts",
|
||||||
|
"with": "libs/shared/src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"localize": ["en"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular/build:dev-server",
|
||||||
|
"options": {
|
||||||
|
"port": 4201,
|
||||||
|
"proxyConfig": "apps/behandelportal/proxy.conf.json"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "behandelportal:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "behandelportal:build:development"
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"buildTarget": "behandelportal:build:development,en"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular/build:unit-test",
|
||||||
|
"options": {
|
||||||
|
"tsConfig": "apps/behandelportal/tsconfig.spec.json"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"coverage": {
|
||||||
|
"coverage": true,
|
||||||
|
"coverageReporters": ["text-summary", "html", "lcov"],
|
||||||
|
"coverageExclude": [
|
||||||
|
"**/*.spec.ts",
|
||||||
|
"**/*.stories.ts",
|
||||||
|
"**/contracts/**",
|
||||||
|
"**/infrastructure/api-client.ts",
|
||||||
|
"apps/behandelportal/src/main.ts",
|
||||||
|
"**/*.testing.ts",
|
||||||
|
"**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storybook": {
|
||||||
|
"builder": "@storybook/angular:start-storybook",
|
||||||
|
"options": {
|
||||||
|
"configDir": ".storybook-behandelportal",
|
||||||
|
"browserTarget": "behandelportal:build",
|
||||||
|
"compodoc": false,
|
||||||
|
"port": 6007
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"build-storybook": {
|
||||||
|
"builder": "@storybook/angular:build-storybook",
|
||||||
|
"options": {
|
||||||
|
"configDir": ".storybook-behandelportal",
|
||||||
|
"browserTarget": "behandelportal:build",
|
||||||
|
"compodoc": true,
|
||||||
|
"compodocArgs": ["-e", "json", "-d", ".storybook-behandelportal"],
|
||||||
|
"outputDir": "storybook-static-behandelportal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shared": {
|
||||||
|
"projectType": "library",
|
||||||
|
"root": "libs/shared",
|
||||||
|
"sourceRoot": "libs/shared/src",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular/build:application",
|
||||||
|
"options": {
|
||||||
|
"browser": "libs/shared/src/test-entry.ts",
|
||||||
|
"tsConfig": "libs/shared/tsconfig.app.json"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"development": {}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular/build:unit-test",
|
||||||
|
"options": {
|
||||||
|
"tsConfig": "libs/shared/tsconfig.spec.json"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"coverage": {
|
||||||
|
"coverage": true,
|
||||||
|
"coverageReporters": ["text-summary", "html", "lcov"],
|
||||||
|
"coverageExclude": [
|
||||||
|
"**/*.spec.ts",
|
||||||
|
"**/*.stories.ts",
|
||||||
|
"**/contracts/**",
|
||||||
|
"**/infrastructure/api-client.ts",
|
||||||
|
"src/test-entry.ts",
|
||||||
|
"**/*.testing.ts",
|
||||||
|
"**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"beheer": {
|
||||||
|
"projectType": "library",
|
||||||
|
"root": "libs/beheer",
|
||||||
|
"sourceRoot": "libs/beheer/src",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular/build:application",
|
||||||
|
"options": {
|
||||||
|
"browser": "libs/beheer/src/test-entry.ts",
|
||||||
|
"tsConfig": "libs/beheer/tsconfig.app.json"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"development": {}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular/build:unit-test",
|
||||||
|
"options": {
|
||||||
|
"tsConfig": "libs/beheer/tsconfig.spec.json"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"coverage": {
|
||||||
|
"coverage": true,
|
||||||
|
"coverageReporters": ["text-summary", "html", "lcov"],
|
||||||
|
"coverageExclude": [
|
||||||
|
"**/*.spec.ts",
|
||||||
|
"**/*.stories.ts",
|
||||||
|
"**/contracts/**",
|
||||||
|
"**/infrastructure/api-client.ts",
|
||||||
|
"src/test-entry.ts",
|
||||||
|
"**/*.testing.ts",
|
||||||
|
"**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,15 @@ 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 { 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';
|
||||||
import { provideRouteFocus } from '@shared/layout/route-focus';
|
import { provideRouteFocus } from '@shared/layout/route-focus';
|
||||||
import { provideUnloadFlush } from '@shared/application/pending-saves';
|
import { provideUnloadFlush } from '@shared/application/pending-saves';
|
||||||
|
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from '@shared/layout/site-header/nav-config';
|
||||||
|
import { ADMIN_LINKS, NAV_ITEMS } from './shell/nav.config';
|
||||||
|
|
||||||
// Both locales' data so DatePipe/number pipes work for whichever bundle is active.
|
// Both locales' data so DatePipe/number pipes work for whichever bundle is active.
|
||||||
registerLocaleData(localeNl);
|
registerLocaleData(localeNl);
|
||||||
@@ -50,7 +54,13 @@ 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, medewerkerInterceptor]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
),
|
||||||
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
|
||||||
@@ -59,5 +69,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
{ provide: LOCALE_ID, useFactory: () => $localize.locale ?? 'nl' },
|
{ provide: LOCALE_ID, useFactory: () => $localize.locale ?? 'nl' },
|
||||||
provideRouteFocus(),
|
provideRouteFocus(),
|
||||||
provideUnloadFlush(),
|
provideUnloadFlush(),
|
||||||
|
{ provide: HEADER_NAV_ITEMS, useValue: NAV_ITEMS },
|
||||||
|
{ provide: HEADER_ADMIN_LINKS, useValue: ADMIN_LINKS },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { ShellComponent } from '@shared/layout/shell/shell.component';
|
||||||
|
import { authGuard, capabilityGuard } from '@auth/auth.guard';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: ShellComponent, // persistent header/footer; only children swap
|
||||||
|
children: [
|
||||||
|
{ path: '', pathMatch: 'full', redirectTo: 'login' },
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
loadComponent: () => import('@auth/ui/login.page').then((m) => m.LoginPage),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
canActivate: [authGuard],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('@behandeling/ui/werkvoorraad.page').then((m) => m.WerkvoorraadPage),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'aanvraag/:id',
|
||||||
|
// Same capability the werkvoorraad list itself is gated by — the
|
||||||
|
// detail page is reachable only from a row already filtered to that capability.
|
||||||
|
canActivate: [capabilityGuard('aanvraag:beoordelen')],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('@behandeling/ui/beoordeling.page').then((m) => m.BeoordelingPage),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'beheer/stamdata',
|
||||||
|
// Admin-only stamdata maintenance editor (ADR-0004): capabilityGuard denies-by-default
|
||||||
|
// unless GET /me resolved `stamdata:edit` (Admin role). Backend re-enforces via the
|
||||||
|
// StamdataAdmin gate — the guard just avoids loading a page that would 403.
|
||||||
|
canActivate: [capabilityGuard('stamdata:edit')],
|
||||||
|
loadComponent: () => import('@beheer/ui/stamdata.page').then((m) => m.StamdataPage),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'beheer/audit',
|
||||||
|
// Admin-only authz/PII-reveal audit trail. capabilityGuard denies-by-default
|
||||||
|
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
|
||||||
|
canActivate: [capabilityGuard('cases:manage')],
|
||||||
|
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'beheer/functies',
|
||||||
|
// Admin-only feature-flag toggles, gated by `flags:manage`.
|
||||||
|
canActivate: [capabilityGuard('flags:manage')],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
|
||||||
|
},
|
||||||
|
{ path: '**', redirectTo: 'login' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||||
|
import { Principal, parseStoredPrincipal } from '../domain/principal';
|
||||||
|
import { MedewerkerAdapter } from '../infrastructure/medewerker.adapter';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'session-v1';
|
||||||
|
|
||||||
|
/** Restore a persisted principal (best-effort; corrupt entry → logged out).
|
||||||
|
The shape validation (G2 — there is no BSN here, so no G1 to enforce) lives in
|
||||||
|
`parseStoredPrincipal` (`../domain/principal`) — pure, spec'd, and testable
|
||||||
|
without stubbing `localStorage`; this just supplies the raw value. */
|
||||||
|
function restore(): Principal | null {
|
||||||
|
return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the current medewerker principal for the whole backoffice app. One
|
||||||
|
* `providedIn: 'root'` instance, so logging in is instantly visible everywhere
|
||||||
|
* (the guard, the header). Persisted to localStorage — a refresh or the
|
||||||
|
* cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
|
||||||
|
* which is safe to do verbatim here: a medewerker principal carries no BSN or
|
||||||
|
* other national identifier, unlike the SSP's `SessionStore`, whose equivalent
|
||||||
|
* comment explains why *that* app strips a field before writing. A real
|
||||||
|
* deployment keeps auth in an httpOnly cookie/token, not web storage, regardless.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SessionStore {
|
||||||
|
private medewerker = inject(MedewerkerAdapter);
|
||||||
|
private _session = signal<Principal | null>(restore());
|
||||||
|
|
||||||
|
readonly session = this._session.asReadonly();
|
||||||
|
readonly isAuthenticated = computed(() => this._session() !== null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const p = this._session();
|
||||||
|
if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
|
||||||
|
else localStorage.removeItem(STORAGE_KEY);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effectful command: authenticate via the SSO stand-in, then store the
|
||||||
|
resulting principal. No credential to pass in, and nothing that can fail
|
||||||
|
today — see `MedewerkerAdapter`. */
|
||||||
|
async login(): Promise<Principal> {
|
||||||
|
const p = await this.medewerker.authenticate();
|
||||||
|
this._session.set(p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this._session.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes —
|
||||||
|
* `Principal`, the login flow, `SessionStore`. A guard is neither.
|
||||||
|
*/
|
||||||
|
export { authGuard, capabilityGuard } from '@shared/application/auth.guard';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
import { MEDEWERKER_ID, currentRollen } from './medewerker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev-only: stamps every API request as the fixed stand-in medewerker (`X-Medewerker`/
|
||||||
|
* `X-Rollen`), so `StubIdentityProvider` resolves a `MedewerkerCaller` instead of falling
|
||||||
|
* through to its zorgverlener default. Unlike `roleInterceptor`'s allow-listed endpoints,
|
||||||
|
* this is the app's whole identity — every request needs it, since this app has no
|
||||||
|
* citizen-scoped screens to keep separate (see `CallerIdentity.Zorgverlener()`'s guard: a
|
||||||
|
* medewerker hitting a citizen-scoped SSP endpoint would 500, but no such endpoint exists
|
||||||
|
* here). Real employee-SSO login is out of scope for this POC (ADR-0002 §3 — the two
|
||||||
|
* apps' login flows are expected to diverge; this stand-in is that flow's placeholder).
|
||||||
|
*/
|
||||||
|
export const medewerkerInterceptor: HttpInterceptorFn = (req, next) =>
|
||||||
|
req.url.includes('/api/v1/')
|
||||||
|
? next(
|
||||||
|
req.clone({ setHeaders: { 'X-Medewerker': MEDEWERKER_ID, 'X-Rollen': currentRollen() } }),
|
||||||
|
)
|
||||||
|
: next(req);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Dev-only medewerker rollen stand-in (the reading MECHANISM — mirrors
|
||||||
|
* `@shared/infrastructure/role.ts`'s `?role=` idiom, but app-local: `auth` is
|
||||||
|
* deliberately not shared between ssp and behandelportal, ADR-0002 §3). Until a real
|
||||||
|
* employee-SSO login exists, every request from this app identifies as one fixed
|
||||||
|
* medewerker; `?rollen=` lets a dev exercise the deny path (`?rollen=geen`) the same
|
||||||
|
* way `?role=` exercises ssp's role-gated pages.
|
||||||
|
*
|
||||||
|
* **Sticky within the tab (sessionStorage)**, same reasoning as `currentRole()`: a
|
||||||
|
* plain in-app navigation drops the query param, which would silently revert to the
|
||||||
|
* default and mask a deliberately-chosen `?rollen=geen`.
|
||||||
|
*/
|
||||||
|
const STORAGE_KEY = 'dev-rollen';
|
||||||
|
export const MEDEWERKER_ID = 'medewerker-1';
|
||||||
|
|
||||||
|
export function currentRollen(): string {
|
||||||
|
const fromUrl = new URLSearchParams(window.location.search).get('rollen');
|
||||||
|
if (fromUrl !== null) {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, fromUrl);
|
||||||
|
return fromUrl;
|
||||||
|
}
|
||||||
|
return sessionStorage.getItem(STORAGE_KEY) ?? 'behandelaar';
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Component, output } from '@angular/core';
|
||||||
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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({
|
||||||
|
selector: 'app-login-form',
|
||||||
|
imports: [ButtonComponent],
|
||||||
|
template: `
|
||||||
|
<div class="form-horizontal">
|
||||||
|
<p i18n="@@login.ssoExplainer">
|
||||||
|
U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig.
|
||||||
|
</p>
|
||||||
|
<app-button type="button" variant="primary" (click)="submitted.emit()" i18n="@@login.submit">
|
||||||
|
Inloggen met SSO
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class LoginFormComponent {
|
||||||
|
submitted = output<void>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
|
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
|
||||||
|
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({
|
||||||
|
selector: 'app-login-page',
|
||||||
|
imports: [PageShellComponent, LoginFormComponent],
|
||||||
|
template: `
|
||||||
|
<app-page-shell
|
||||||
|
i18n-heading="@@login.heading"
|
||||||
|
heading="Inloggen bij het behandelportal"
|
||||||
|
width="narrow"
|
||||||
|
i18n-intro="@@login.intro"
|
||||||
|
intro="Voor medewerkers die aanvragen beoordelen."
|
||||||
|
>
|
||||||
|
<app-login-form (submitted)="login()" />
|
||||||
|
</app-page-shell>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class LoginPage {
|
||||||
|
private store = inject(SessionStore);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
async login() {
|
||||||
|
await this.store.login();
|
||||||
|
this.router.navigate(['/dashboard']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { RemoteData } from '@shared/application/remote-data';
|
||||||
|
import { BeoordelingView } from '@behandeling/domain/beoordeling';
|
||||||
|
import {
|
||||||
|
BeoordelingAdapter,
|
||||||
|
parseBeoordelingView,
|
||||||
|
} from '@behandeling/infrastructure/beoordeling.adapter';
|
||||||
|
|
||||||
|
type Err = Error | undefined;
|
||||||
|
|
||||||
|
/** One aanvraag's beoordeling detail — a root singleton like `WerkvoorraadStore`.
|
||||||
|
Keyed by id: navigating to a different case resets to Loading. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class BeoordelingStore {
|
||||||
|
private adapter = inject(BeoordelingAdapter);
|
||||||
|
|
||||||
|
private id: string | undefined;
|
||||||
|
private state = signal<RemoteData<Err, BeoordelingView>>({ tag: 'Loading' });
|
||||||
|
readonly view = this.state.asReadonly();
|
||||||
|
|
||||||
|
async load(id: string) {
|
||||||
|
if (this.id !== id) this.state.set({ tag: 'Loading' });
|
||||||
|
this.id = id;
|
||||||
|
try {
|
||||||
|
const parsed = parseBeoordelingView(await this.adapter.get(id));
|
||||||
|
// A navigation to a different case may have started while this one was in flight.
|
||||||
|
if (this.id !== id) return;
|
||||||
|
this.state.set(
|
||||||
|
parsed.ok
|
||||||
|
? { tag: 'Success', value: parsed.value }
|
||||||
|
: { tag: 'Failure', error: new Error(parsed.error) },
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (this.id !== id) return;
|
||||||
|
this.state.set({ tag: 'Failure', error: e as Error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reload() {
|
||||||
|
if (this.id) void this.load(this.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { Result } from '@shared/kernel/fp';
|
||||||
|
import { Valid } from '@behandeling/domain/besluit.machine';
|
||||||
|
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
||||||
|
import { BesluitAdapter } from '@behandeling/infrastructure/besluit.adapter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command factory: binds the besluit adapter in an injection context and returns the
|
||||||
|
* submit function the decision form calls. Same field-initializer shape as
|
||||||
|
* `createStore` — the UI holds an application command, never the network client.
|
||||||
|
*/
|
||||||
|
export function createSubmitBesluit() {
|
||||||
|
const adapter = inject(BesluitAdapter);
|
||||||
|
return (id: string, data: Valid): Promise<Result<string, void>> =>
|
||||||
|
runSubmit(() => adapter.besluit(id, data), SUBMIT_FAILED);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { RemoteData } from '@shared/application/remote-data';
|
||||||
|
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
|
||||||
|
import {
|
||||||
|
WerkvoorraadAdapter,
|
||||||
|
parseWerkvoorraad,
|
||||||
|
} from '@behandeling/infrastructure/werkvoorraad.adapter';
|
||||||
|
|
||||||
|
type Err = Error | undefined;
|
||||||
|
|
||||||
|
/** The behandelaar's queue — a root singleton like `AdminCasesStore`'s ssp
|
||||||
|
counterpart. Fetch + parse at the trust boundary, publish as RemoteData. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class WerkvoorraadStore {
|
||||||
|
private adapter = inject(WerkvoorraadAdapter);
|
||||||
|
|
||||||
|
private state = signal<RemoteData<Err, WerkvoorraadItem[]>>({ tag: 'Loading' });
|
||||||
|
readonly items = this.state.asReadonly();
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
|
||||||
|
try {
|
||||||
|
const parsed = parseWerkvoorraad(await this.adapter.list());
|
||||||
|
this.state.set(
|
||||||
|
parsed.ok
|
||||||
|
? { tag: 'Success', value: parsed.value }
|
||||||
|
: { tag: 'Failure', error: new Error(parsed.error) },
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
this.state.set({ tag: 'Failure', error: e as Error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reload() {
|
||||||
|
void this.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { statusLabel, detailRows, TYPE_LABELS } from './beoordeling-view';
|
||||||
|
import { BeoordelingView } from './beoordeling';
|
||||||
|
|
||||||
|
const base: Omit<BeoordelingView, 'status'> = {
|
||||||
|
id: '1',
|
||||||
|
type: 'herregistratie',
|
||||||
|
owner: '*****2333',
|
||||||
|
submittedAt: '2024-05-12',
|
||||||
|
documenten: [],
|
||||||
|
canBesluiten: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('statusLabel', () => {
|
||||||
|
it('labels every tag distinctly', () => {
|
||||||
|
const labels = [
|
||||||
|
statusLabel({ tag: 'Ingediend', referentie: 'R1' }),
|
||||||
|
statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }),
|
||||||
|
statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: true }),
|
||||||
|
statusLabel({ tag: 'MeerInfoGevraagd', referentie: 'R1', reden: 'x' }),
|
||||||
|
statusLabel({ tag: 'Goedgekeurd', referentie: 'R1' }),
|
||||||
|
statusLabel({ tag: 'Afgewezen', referentie: 'R1', reden: 'x' }),
|
||||||
|
];
|
||||||
|
expect(new Set(labels).size).toBe(labels.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detailRows', () => {
|
||||||
|
it('lists soort/status/referentie/eigenaar/ingediend', () => {
|
||||||
|
// Given a case InBehandeling.
|
||||||
|
// When its detail rows are derived...
|
||||||
|
const rows = detailRows({
|
||||||
|
...base,
|
||||||
|
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
|
||||||
|
});
|
||||||
|
const values = rows.map((r) => r.value);
|
||||||
|
|
||||||
|
// Then the type label (via TYPE_LABELS, not a literal), the reference, and the
|
||||||
|
// owner all appear as rows.
|
||||||
|
expect(values).toContain(TYPE_LABELS.herregistratie);
|
||||||
|
expect(values).toContain('R1');
|
||||||
|
expect(values).toContain(base.owner);
|
||||||
|
expect(rows.length).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// One behaviour ("a reden row is added exactly for the two statuses that carry a
|
||||||
|
// reden") checked as a truth table over three statuses — kept together per
|
||||||
|
// bdd.mdx's "truth-table of one rule" exception, rather than split apart.
|
||||||
|
//
|
||||||
|
// `reden` itself is raw domain data (a free-text field on the status union, not an
|
||||||
|
// enum), passed through `detailRows` unchanged and never wrapped by `$localize` —
|
||||||
|
// there is no reason-code/tag to assert on instead; the value under test IS the
|
||||||
|
// string the Given supplied, so checking it reappears in the Then is a
|
||||||
|
// pass-through check, not a translated-copy assertion.
|
||||||
|
it('a reden row is present only for Afgewezen and MeerInfoGevraagd', () => {
|
||||||
|
// Given three cases: rejected, more-info-requested, and approved.
|
||||||
|
// When their detail rows are derived...
|
||||||
|
const afgewezen = detailRows({
|
||||||
|
...base,
|
||||||
|
status: { tag: 'Afgewezen', referentie: 'R1', reden: 'Onvoldoende uren' },
|
||||||
|
});
|
||||||
|
const meerInfo = detailRows({
|
||||||
|
...base,
|
||||||
|
status: { tag: 'MeerInfoGevraagd', referentie: 'R1', reden: 'Diploma ontbreekt' },
|
||||||
|
});
|
||||||
|
const goedgekeurd = detailRows({ ...base, status: { tag: 'Goedgekeurd', referentie: 'R1' } });
|
||||||
|
|
||||||
|
// Then only the rejected and more-info-requested cases gain a reden row (carrying
|
||||||
|
// the reason through unchanged); the approved case does not.
|
||||||
|
expect(afgewezen.length).toBe(6);
|
||||||
|
expect(afgewezen.map((r) => r.value)).toContain('Onvoldoende uren');
|
||||||
|
expect(meerInfo.length).toBe(6);
|
||||||
|
expect(goedgekeurd.length).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { formatDatumNl } from '@shared/kernel/datum';
|
||||||
|
import { AanvraagType } from './werkvoorraad-item';
|
||||||
|
import { BeoordelingStatus, BeoordelingView } from './beoordeling';
|
||||||
|
|
||||||
|
/** View-model mapping shared by the werkvoorraad list and the beoordeling
|
||||||
|
detail screen: type/status → labels. Pure, no Angular. Lives here (not in
|
||||||
|
`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. */
|
||||||
|
|
||||||
|
export const TYPE_LABELS: Record<AanvraagType, string> = {
|
||||||
|
registratie: $localize`:@@werkvoorraad.type.registratie:Inschrijving`,
|
||||||
|
herregistratie: $localize`:@@werkvoorraad.type.herregistratie:Herregistratie`,
|
||||||
|
intake: $localize`:@@werkvoorraad.type.intake:Herregistratie-intake`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function statusLabel(status: BeoordelingStatus): string {
|
||||||
|
switch (status.tag) {
|
||||||
|
case 'Ingediend':
|
||||||
|
return $localize`:@@werkvoorraad.status.ingediend:Ingediend`;
|
||||||
|
case 'InBehandeling':
|
||||||
|
return status.manual
|
||||||
|
? $localize`:@@werkvoorraad.status.inBehandelingHandmatig:In behandeling (handmatig)`
|
||||||
|
: $localize`:@@werkvoorraad.status.inBehandeling:In behandeling`;
|
||||||
|
case 'MeerInfoGevraagd':
|
||||||
|
return $localize`:@@beoordeling.status.meerInfoGevraagd:Meer informatie gevraagd`;
|
||||||
|
case 'Goedgekeurd':
|
||||||
|
return $localize`:@@beoordeling.status.goedgekeurd:Goedgekeurd`;
|
||||||
|
case 'Afgewezen':
|
||||||
|
return $localize`:@@beoordeling.status.afgewezen:Afgewezen`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Key/value rows for the beoordeling detail page (CIBG Datablock). */
|
||||||
|
export function detailRows(view: BeoordelingView): { key: string; value: string }[] {
|
||||||
|
const s = view.status;
|
||||||
|
const rows = [
|
||||||
|
{ key: $localize`:@@beoordeling.detail.soort:Soort aanvraag`, value: TYPE_LABELS[view.type] },
|
||||||
|
{ key: $localize`:@@beoordeling.detail.status:Status`, value: statusLabel(s) },
|
||||||
|
{ key: $localize`:@@beoordeling.detail.referentie:Referentie`, value: s.referentie },
|
||||||
|
{ key: $localize`:@@beoordeling.detail.eigenaar:Eigenaar (BSN)`, value: view.owner },
|
||||||
|
{
|
||||||
|
key: $localize`:@@beoordeling.detail.ingediend:Ingediend op`,
|
||||||
|
value: view.submittedAt ? formatDatumNl(view.submittedAt) : '—',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (s.tag === 'Afgewezen' || s.tag === 'MeerInfoGevraagd') {
|
||||||
|
rows.push({ key: $localize`:@@beoordeling.detail.reden:Reden`, value: s.reden });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { AanvraagType } from './werkvoorraad-item';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A case's full status lifecycle as the beoordeling detail screen sees it —
|
||||||
|
* 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`
|
||||||
|
* — the detail endpoint 404s a Concept, it isn't a case a behandelaar can treat yet).
|
||||||
|
*/
|
||||||
|
export type BeoordelingStatus =
|
||||||
|
| { tag: 'Ingediend'; referentie: string }
|
||||||
|
| { tag: 'InBehandeling'; referentie: string; manual: boolean }
|
||||||
|
| { tag: 'MeerInfoGevraagd'; referentie: string; reden: string }
|
||||||
|
| { tag: 'Goedgekeurd'; referentie: string }
|
||||||
|
| { tag: 'Afgewezen'; referentie: string; reden: string };
|
||||||
|
|
||||||
|
export interface BeoordelingDocument {
|
||||||
|
documentId: string;
|
||||||
|
categoryId: string;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BeoordelingView {
|
||||||
|
id: string;
|
||||||
|
type: AanvraagType;
|
||||||
|
status: BeoordelingStatus;
|
||||||
|
/** The BSN of the citizen the aanvraag belongs to — masked by the server. */
|
||||||
|
owner: string;
|
||||||
|
submittedAt?: string;
|
||||||
|
documenten: BeoordelingDocument[];
|
||||||
|
/** Decision flag (ADR-0001): the server computes whether a decision may be recorded;
|
||||||
|
the FE renders it, it never recomputes the lifecycle. */
|
||||||
|
canBesluiten: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { expectTag } from '@shared/testing/expect-tag';
|
||||||
|
import { reduce, initial } from './besluit.machine';
|
||||||
|
import { givenBesluit } from './besluit.testing';
|
||||||
|
|
||||||
|
describe('besluit reduce', () => {
|
||||||
|
it('SetField updates the draft while editing', () => {
|
||||||
|
const s = reduce(initial, { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' });
|
||||||
|
expect(expectTag(s, 'Editing').draft.besluit).toBe('Goedkeuren');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Submit with no besluit chosen stays Editing and reports a field error', () => {
|
||||||
|
const s = reduce(initial, { tag: 'Submit' });
|
||||||
|
expect(expectTag(s, 'Editing').errors.besluit).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Submit Afwijzen without a toelichting stays Editing and reports a field error', () => {
|
||||||
|
const editingAfwijzen = givenBesluit({ tag: 'SetField', key: 'besluit', value: 'Afwijzen' });
|
||||||
|
const s = reduce(editingAfwijzen, { tag: 'Submit' });
|
||||||
|
expect(expectTag(s, 'Editing').errors.toelichting).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Submit Goedkeuren with no toelichting moves to Submitting (optional there)', () => {
|
||||||
|
const editingGoedkeuren = givenBesluit({
|
||||||
|
tag: 'SetField',
|
||||||
|
key: 'besluit',
|
||||||
|
value: 'Goedkeuren',
|
||||||
|
});
|
||||||
|
const s = reduce(editingGoedkeuren, { tag: 'Submit' });
|
||||||
|
expect(expectTag(s, 'Submitting').data).toEqual({
|
||||||
|
besluit: 'Goedkeuren',
|
||||||
|
toelichting: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Submit Afwijzen with a toelichting moves to Submitting with the trimmed value', () => {
|
||||||
|
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({
|
||||||
|
besluit: 'Afwijzen',
|
||||||
|
toelichting: 'niet erkend',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SubmitConfirmed maps Submitting to Submitted', () => {
|
||||||
|
const submitting = givenBesluit(
|
||||||
|
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
|
||||||
|
{ tag: 'Submit' },
|
||||||
|
);
|
||||||
|
expect(reduce(submitting, { tag: 'SubmitConfirmed' }).tag).toBe('Submitted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SubmitFailed maps Submitting to Failed with the error', () => {
|
||||||
|
const submitting = givenBesluit(
|
||||||
|
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
|
||||||
|
{ tag: 'Submit' },
|
||||||
|
);
|
||||||
|
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
|
||||||
|
expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Retry re-submits a failure', () => {
|
||||||
|
const submitting = givenBesluit(
|
||||||
|
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
|
||||||
|
{ tag: 'Submit' },
|
||||||
|
);
|
||||||
|
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
|
||||||
|
expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Reset returns to the initial editing state', () => {
|
||||||
|
const submitting = givenBesluit(
|
||||||
|
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
|
||||||
|
{ tag: 'Submit' },
|
||||||
|
);
|
||||||
|
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Result, assertNever } from '@shared/kernel/fp';
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
enum — see `RecordBesluitRequest`). */
|
||||||
|
const BESLUIT_TAGS = ['Goedkeuren', 'Afwijzen', 'MeerInfoOpvragen'] as const;
|
||||||
|
export type BesluitTag = (typeof BESLUIT_TAGS)[number];
|
||||||
|
|
||||||
|
function isBesluitTag(v: string): v is BesluitTag {
|
||||||
|
return (BESLUIT_TAGS as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the user picked (raw, possibly empty while nothing is selected yet). */
|
||||||
|
export interface Draft {
|
||||||
|
besluit: string;
|
||||||
|
toelichting: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After parsing — besluit is the narrow tag; toelichting is present only when given
|
||||||
|
(required for Afwijzen/MeerInfoOpvragen, optional for Goedkeuren — enforced by validate). */
|
||||||
|
export interface Valid {
|
||||||
|
besluit: BesluitTag;
|
||||||
|
toelichting?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Errors = Partial<Record<keyof Draft, string>>;
|
||||||
|
|
||||||
|
/** The decision form as one tagged union — same idiom as every other form in this
|
||||||
|
house (form-machine skill), single-step. draft/errors exist only while Editing. */
|
||||||
|
export type BesluitState =
|
||||||
|
| { tag: 'Editing'; draft: Draft; errors: Errors }
|
||||||
|
| { tag: 'Submitting'; data: Valid }
|
||||||
|
| { tag: 'Submitted'; data: Valid }
|
||||||
|
| { tag: 'Failed'; data: Valid; error: string };
|
||||||
|
|
||||||
|
export const initial: BesluitState = {
|
||||||
|
tag: 'Editing',
|
||||||
|
draft: { besluit: '', toelichting: '' },
|
||||||
|
errors: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
function validate(draft: Draft): Result<Errors, Valid> {
|
||||||
|
if (!isBesluitTag(draft.besluit)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { besluit: $localize`:@@besluit.error.verplicht:Kies een besluit.` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const toelichting = draft.toelichting.trim();
|
||||||
|
if (draft.besluit !== 'Goedkeuren' && toelichting === '') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
toelichting: $localize`:@@besluit.error.toelichtingVerplicht:Geef een toelichting.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, value: { besluit: draft.besluit, toelichting: toelichting || undefined } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BesluitMsg =
|
||||||
|
| { tag: 'SetField'; key: keyof Draft; value: string }
|
||||||
|
| { tag: 'Submit' }
|
||||||
|
| { tag: 'Retry' }
|
||||||
|
| { tag: 'SubmitConfirmed' }
|
||||||
|
| { tag: 'SubmitFailed'; error: string }
|
||||||
|
| { tag: 'Reset' }
|
||||||
|
| { tag: 'Seed'; state: BesluitState }; // mount a specific state (stories/tests)
|
||||||
|
|
||||||
|
export function reduce(s: BesluitState, m: BesluitMsg): BesluitState {
|
||||||
|
switch (m.tag) {
|
||||||
|
case 'SetField':
|
||||||
|
return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;
|
||||||
|
case 'Submit': {
|
||||||
|
if (s.tag !== 'Editing') return s;
|
||||||
|
const r = validate(s.draft);
|
||||||
|
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
|
||||||
|
}
|
||||||
|
case 'Retry':
|
||||||
|
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||||
|
case 'SubmitConfirmed':
|
||||||
|
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
|
||||||
|
case 'SubmitFailed':
|
||||||
|
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
|
||||||
|
case 'Reset':
|
||||||
|
return initial;
|
||||||
|
case 'Seed':
|
||||||
|
return m.state;
|
||||||
|
default:
|
||||||
|
return assertNever(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { werkvoorraadRow, statusLabel, TYPE_LABELS } from './werkvoorraad-item-view';
|
||||||
|
import { WerkvoorraadItem } from './werkvoorraad-item';
|
||||||
|
|
||||||
|
const base: Omit<WerkvoorraadItem, 'status'> = {
|
||||||
|
id: '1',
|
||||||
|
type: 'herregistratie',
|
||||||
|
owner: '111222333',
|
||||||
|
submittedAt: '2024-05-12',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('werkvoorraadRow', () => {
|
||||||
|
it('heading is the type, subtitle carries the owner BSN', () => {
|
||||||
|
const row = werkvoorraadRow({
|
||||||
|
...base,
|
||||||
|
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
|
||||||
|
});
|
||||||
|
expect(row.heading).toBe(TYPE_LABELS.herregistratie);
|
||||||
|
expect(row.subtitle).toContain('111222333');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('status line carries the status label, reference and submit date', () => {
|
||||||
|
const row = werkvoorraadRow({
|
||||||
|
...base,
|
||||||
|
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
|
||||||
|
});
|
||||||
|
expect(row.status).toContain(
|
||||||
|
statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }),
|
||||||
|
);
|
||||||
|
expect(row.status).toContain('R1');
|
||||||
|
expect(row.status).toContain('12 mei 2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manual review is called out distinctly from an automatic InBehandeling', () => {
|
||||||
|
const manual = statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: true });
|
||||||
|
const auto = statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false });
|
||||||
|
expect(manual).not.toBe(auto);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a missing submit date leaves no dangling separator', () => {
|
||||||
|
const row = werkvoorraadRow({
|
||||||
|
...base,
|
||||||
|
submittedAt: undefined,
|
||||||
|
status: { tag: 'Ingediend', referentie: 'R9' },
|
||||||
|
});
|
||||||
|
expect(row.status).toBe(`${statusLabel({ tag: 'Ingediend', referentie: 'R9' })} · R9`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { formatDatumNl } from '@shared/kernel/datum';
|
||||||
|
import { WerkvoorraadItem } from './werkvoorraad-item';
|
||||||
|
import { TYPE_LABELS, statusLabel } from './beoordeling-view';
|
||||||
|
|
||||||
|
/** View-model mapping for a queue row: type/status → the fields for a CIBG
|
||||||
|
"aanvragen" row. Pure, no Angular — the UI renders these, it does not derive them.
|
||||||
|
`TYPE_LABELS`/`statusLabel` live in `./beoordeling-view` (the wider status union) and
|
||||||
|
are re-exported here so existing imports of this file keep working. */
|
||||||
|
export { TYPE_LABELS, statusLabel };
|
||||||
|
|
||||||
|
export interface WerkvoorraadRow {
|
||||||
|
heading: string;
|
||||||
|
subtitle: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields for one queue row: type as heading, owner (BSN) as subtitle, status +
|
||||||
|
reference + submit date as the status line. */
|
||||||
|
export function werkvoorraadRow(item: WerkvoorraadItem): WerkvoorraadRow {
|
||||||
|
const parts = [statusLabel(item.status), item.status.referentie];
|
||||||
|
if (item.submittedAt) {
|
||||||
|
parts.push(
|
||||||
|
$localize`:@@werkvoorraad.row.ingediend:ingediend op ${formatDatumNl(item.submittedAt)}:datum:`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
heading: TYPE_LABELS[item.type],
|
||||||
|
subtitle: $localize`:@@werkvoorraad.row.bsn:BSN ${item.owner}:bsn:`,
|
||||||
|
status: parts.join(' · '),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* The status union is narrower than the SSP's full `AanvraagStatus` (ssp's
|
||||||
|
* `registratie/domain/aanvraag.ts`): the backend only ever puts a case in the queue
|
||||||
|
* while it is still open (`Ingediend`/`InBehandeling`), so a queue item literally
|
||||||
|
* cannot be `Concept`/`Goedgekeurd`/`Afgewezen` — illegal states unrepresentable.
|
||||||
|
*/
|
||||||
|
export type AanvraagType = 'registratie' | 'herregistratie' | 'intake';
|
||||||
|
|
||||||
|
export type WerkvoorraadStatus =
|
||||||
|
| { tag: 'Ingediend'; referentie: string }
|
||||||
|
| { tag: 'InBehandeling'; referentie: string; manual: boolean };
|
||||||
|
|
||||||
|
export interface WerkvoorraadItem {
|
||||||
|
id: string;
|
||||||
|
type: AanvraagType;
|
||||||
|
status: WerkvoorraadStatus;
|
||||||
|
/** The BSN of the citizen the aanvraag belongs to — always populated (cross-owner list). */
|
||||||
|
owner: string;
|
||||||
|
submittedAt?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseBeoordelingStatus, parseBeoordelingView } from './beoordeling.adapter';
|
||||||
|
|
||||||
|
const view = {
|
||||||
|
aanvraag: {
|
||||||
|
id: 'a1',
|
||||||
|
type: 'registratie',
|
||||||
|
status: { tag: 'InBehandeling', referentie: 'BIG-1', manual: true },
|
||||||
|
documentIds: ['d1'],
|
||||||
|
createdAt: '2026-07-01T10:00:00Z',
|
||||||
|
updatedAt: '2026-07-01T10:05:00Z',
|
||||||
|
submittedAt: '2026-07-01T10:05:00Z',
|
||||||
|
owner: '*****2333',
|
||||||
|
},
|
||||||
|
documenten: [{ documentId: 'd1', categoryId: 'diploma', fileName: 'diploma.pdf' }],
|
||||||
|
decisions: { canBesluiten: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('parseBeoordelingStatus', () => {
|
||||||
|
it('parses each tag with its required fields', () => {
|
||||||
|
expect(parseBeoordelingStatus({ tag: 'Ingediend', referentie: 'BIG-1' }).ok).toBe(true);
|
||||||
|
expect(
|
||||||
|
parseBeoordelingStatus({ tag: 'InBehandeling', referentie: 'BIG-1', manual: false }).ok,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
parseBeoordelingStatus({ tag: 'MeerInfoGevraagd', referentie: 'BIG-1', reden: 'x' }).ok,
|
||||||
|
).toBe(true);
|
||||||
|
expect(parseBeoordelingStatus({ tag: 'Goedgekeurd', referentie: 'BIG-1' }).ok).toBe(true);
|
||||||
|
expect(parseBeoordelingStatus({ tag: 'Afgewezen', referentie: 'BIG-1', reden: 'x' }).ok).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// One behaviour ("rejects a malformed status") checked over several malformed
|
||||||
|
// shapes — a loop asserting one rule over many inputs, kept together per bdd.mdx.
|
||||||
|
it('rejects a malformed status', () => {
|
||||||
|
// Given a status that is missing entirely, has an unknown tag, or is missing a
|
||||||
|
// required field for its tag.
|
||||||
|
// When each is parsed...
|
||||||
|
// Then all are rejected.
|
||||||
|
expect(parseBeoordelingStatus(undefined).ok).toBe(false);
|
||||||
|
expect(parseBeoordelingStatus({ tag: 'Concept' } as never).ok).toBe(false);
|
||||||
|
expect(parseBeoordelingStatus({ tag: 'InBehandeling', referentie: 'BIG-1' }).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseBeoordelingView', () => {
|
||||||
|
it('maps a valid DTO to domain', () => {
|
||||||
|
const r = parseBeoordelingView(view);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.value.type).toBe('registratie');
|
||||||
|
expect(r.value.documenten).toEqual([
|
||||||
|
{ documentId: 'd1', categoryId: 'diploma', fileName: 'diploma.pdf' },
|
||||||
|
]);
|
||||||
|
expect(r.value.canBesluiten).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// One behaviour ("rejects a malformed view") checked over several malformed
|
||||||
|
// shapes — a loop asserting one rule over many inputs, kept together per bdd.mdx.
|
||||||
|
it('rejects a malformed view', () => {
|
||||||
|
// Given a view that is a non-object, missing the owner, has an unknown aanvraag
|
||||||
|
// type, or is missing decisions.
|
||||||
|
// When each is parsed...
|
||||||
|
// Then all are rejected.
|
||||||
|
expect(parseBeoordelingView(null).ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
parseBeoordelingView({ ...view, aanvraag: { ...view.aanvraag, owner: undefined } }).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
parseBeoordelingView({ ...view, aanvraag: { ...view.aanvraag, type: 'onbekend' } }).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(parseBeoordelingView({ ...view, decisions: {} }).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults an absent documenten list to empty', () => {
|
||||||
|
const r = parseBeoordelingView({ ...view, documenten: undefined });
|
||||||
|
expect(r.ok && r.value.documenten).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
|
import {
|
||||||
|
ApiClient,
|
||||||
|
BeoordelingViewDto,
|
||||||
|
AanvraagStatusDto,
|
||||||
|
} from '@shared/infrastructure/api-client';
|
||||||
|
import {
|
||||||
|
BeoordelingView,
|
||||||
|
BeoordelingStatus,
|
||||||
|
BeoordelingDocument,
|
||||||
|
} from '@behandeling/domain/beoordeling';
|
||||||
|
import { AanvraagType } from '@behandeling/domain/werkvoorraad-item';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infrastructure adapter for the beoordeling detail read — the only place its
|
||||||
|
* HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response is validated +
|
||||||
|
* mapped to domain by the parse* boundary below.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class BeoordelingAdapter {
|
||||||
|
private client = inject(ApiClient);
|
||||||
|
|
||||||
|
get(id: string): Promise<BeoordelingViewDto> {
|
||||||
|
return this.client.beoordeling(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
|
||||||
|
|
||||||
|
export function parseBeoordelingStatus(
|
||||||
|
s: AanvraagStatusDto | undefined,
|
||||||
|
): Result<string, BeoordelingStatus> {
|
||||||
|
if (!s || typeof s.tag !== 'string') return err('beoordeling: missing status');
|
||||||
|
switch (s.tag) {
|
||||||
|
case 'Ingediend':
|
||||||
|
if (typeof s.referentie !== 'string') return err('beoordeling: bad Ingediend status');
|
||||||
|
return ok({ tag: 'Ingediend', referentie: s.referentie });
|
||||||
|
case 'InBehandeling':
|
||||||
|
if (typeof s.referentie !== 'string' || typeof s.manual !== 'boolean')
|
||||||
|
return err('beoordeling: bad InBehandeling status');
|
||||||
|
return ok({ tag: 'InBehandeling', referentie: s.referentie, manual: s.manual });
|
||||||
|
case 'MeerInfoGevraagd':
|
||||||
|
if (typeof s.referentie !== 'string' || typeof s.reden !== 'string')
|
||||||
|
return err('beoordeling: bad MeerInfoGevraagd status');
|
||||||
|
return ok({ tag: 'MeerInfoGevraagd', referentie: s.referentie, reden: s.reden });
|
||||||
|
case 'Goedgekeurd':
|
||||||
|
if (typeof s.referentie !== 'string') return err('beoordeling: bad Goedgekeurd status');
|
||||||
|
return ok({ tag: 'Goedgekeurd', referentie: s.referentie });
|
||||||
|
case 'Afgewezen':
|
||||||
|
if (typeof s.referentie !== 'string' || typeof s.reden !== 'string')
|
||||||
|
return err('beoordeling: bad Afgewezen status');
|
||||||
|
return ok({ tag: 'Afgewezen', referentie: s.referentie, reden: s.reden });
|
||||||
|
default:
|
||||||
|
return err(`beoordeling: unknown status tag ${s.tag}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDocument(json: unknown): Result<string, BeoordelingDocument> {
|
||||||
|
if (typeof json !== 'object' || json === null) return err('beoordeling: document not an object');
|
||||||
|
const d = json as { documentId?: unknown; categoryId?: unknown; fileName?: unknown };
|
||||||
|
if (typeof d.documentId !== 'string') return err('beoordeling: document missing documentId');
|
||||||
|
if (typeof d.categoryId !== 'string') return err('beoordeling: document missing categoryId');
|
||||||
|
if (typeof d.fileName !== 'string') return err('beoordeling: document missing fileName');
|
||||||
|
return ok({ documentId: d.documentId, categoryId: d.categoryId, fileName: d.fileName });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBeoordelingView(json: unknown): Result<string, BeoordelingView> {
|
||||||
|
if (typeof json !== 'object' || json === null) return err('beoordeling: not an object');
|
||||||
|
const dto = json as BeoordelingViewDto;
|
||||||
|
const a = dto.aanvraag;
|
||||||
|
if (!a || typeof a.id !== 'string') return err('beoordeling: missing aanvraag.id');
|
||||||
|
if (typeof a.type !== 'string' || !AANVRAAG_TYPES.includes(a.type))
|
||||||
|
return err(`beoordeling: bad type ${a.type}`);
|
||||||
|
if (typeof a.owner !== 'string' || !a.owner) return err('beoordeling: missing owner');
|
||||||
|
|
||||||
|
const status = parseBeoordelingStatus(a.status);
|
||||||
|
if (!status.ok) return status;
|
||||||
|
|
||||||
|
const documenten: BeoordelingDocument[] = [];
|
||||||
|
for (const item of dto.documenten ?? []) {
|
||||||
|
const parsed = parseDocument(item);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
documenten.push(parsed.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof dto.decisions?.canBesluiten !== 'boolean')
|
||||||
|
return err('beoordeling: missing decisions.canBesluiten');
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
id: a.id,
|
||||||
|
type: a.type as AanvraagType,
|
||||||
|
status: status.value,
|
||||||
|
owner: a.owner,
|
||||||
|
submittedAt: a.submittedAt,
|
||||||
|
documenten,
|
||||||
|
canBesluiten: dto.decisions.canBesluiten,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
|
import { Valid } from '@behandeling/domain/besluit.machine';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infrastructure adapter for recording a behandelaar's decision — the single
|
||||||
|
* 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
|
||||||
|
* server, not this adapter, re-validates and is the authority).
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class BesluitAdapter {
|
||||||
|
private client = inject(ApiClient);
|
||||||
|
|
||||||
|
async besluit(id: string, data: Valid): Promise<void> {
|
||||||
|
await this.client.besluit(id, { besluit: data.besluit, toelichting: data.toelichting });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseWerkvoorraadItem, parseWerkvoorraad } from './werkvoorraad.adapter';
|
||||||
|
|
||||||
|
const inBehandeling = {
|
||||||
|
id: 'a1',
|
||||||
|
type: 'herregistratie',
|
||||||
|
status: { tag: 'InBehandeling', referentie: 'BIG-1', manual: false },
|
||||||
|
documentIds: [],
|
||||||
|
createdAt: '2026-07-01T10:00:00Z',
|
||||||
|
updatedAt: '2026-07-01T10:05:00Z',
|
||||||
|
owner: '111222333',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('parseWerkvoorraadItem', () => {
|
||||||
|
it('parses Ingediend and InBehandeling', () => {
|
||||||
|
expect(parseWerkvoorraadItem(inBehandeling).ok).toBe(true);
|
||||||
|
expect(
|
||||||
|
parseWerkvoorraadItem({ ...inBehandeling, status: { tag: 'Ingediend', referentie: 'BIG-2' } })
|
||||||
|
.ok,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a case whose status is not an open queue tag', () => {
|
||||||
|
expect(
|
||||||
|
parseWerkvoorraadItem({
|
||||||
|
...inBehandeling,
|
||||||
|
status: { tag: 'Goedgekeurd', referentie: 'BIG-1' },
|
||||||
|
}).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
parseWerkvoorraadItem({
|
||||||
|
...inBehandeling,
|
||||||
|
status: { tag: 'Concept', stepIndex: 0, stepCount: 1 },
|
||||||
|
}).ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing owner, bad type, and non-objects', () => {
|
||||||
|
expect(parseWerkvoorraadItem({ ...inBehandeling, owner: undefined }).ok).toBe(false);
|
||||||
|
expect(parseWerkvoorraadItem({ ...inBehandeling, type: 'onbekend' }).ok).toBe(false);
|
||||||
|
expect(parseWerkvoorraadItem(null).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseWerkvoorraad', () => {
|
||||||
|
it('parses a list and fails fast on a bad element', () => {
|
||||||
|
expect(parseWerkvoorraad([inBehandeling, inBehandeling]).ok).toBe(true);
|
||||||
|
expect(parseWerkvoorraad([inBehandeling, { ...inBehandeling, owner: undefined }]).ok).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(parseWerkvoorraad({}).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
|
import { ApiClient, AanvraagSummaryDto } from '@shared/infrastructure/api-client';
|
||||||
|
import {
|
||||||
|
WerkvoorraadItem,
|
||||||
|
WerkvoorraadStatus,
|
||||||
|
AanvraagType,
|
||||||
|
} from '@behandeling/domain/werkvoorraad-item';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infrastructure adapter for the behandelportal's queue read — the only
|
||||||
|
* 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
|
||||||
|
* below; a case whose status isn't `Ingediend`/`InBehandeling` is a parse error, not
|
||||||
|
* a silently-rendered row — the endpoint's own filter is a guarantee this boundary enforces.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class WerkvoorraadAdapter {
|
||||||
|
private client = inject(ApiClient);
|
||||||
|
|
||||||
|
list(): Promise<AanvraagSummaryDto[]> {
|
||||||
|
return this.client.werkvoorraad();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
|
||||||
|
|
||||||
|
function parseWerkvoorraadStatus(
|
||||||
|
s: AanvraagSummaryDto['status'] | undefined,
|
||||||
|
): Result<string, WerkvoorraadStatus> {
|
||||||
|
if (!s || typeof s.tag !== 'string') return err('werkvoorraad: missing status');
|
||||||
|
switch (s.tag) {
|
||||||
|
case 'Ingediend':
|
||||||
|
if (typeof s.referentie !== 'string') return err('werkvoorraad: bad Ingediend status');
|
||||||
|
return ok({ tag: 'Ingediend', referentie: s.referentie });
|
||||||
|
case 'InBehandeling':
|
||||||
|
if (typeof s.referentie !== 'string' || typeof s.manual !== 'boolean')
|
||||||
|
return err('werkvoorraad: bad InBehandeling status');
|
||||||
|
return ok({ tag: 'InBehandeling', referentie: s.referentie, manual: s.manual });
|
||||||
|
default:
|
||||||
|
return err(`werkvoorraad: a queue item cannot have status ${s.tag}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWerkvoorraadItem(json: unknown): Result<string, WerkvoorraadItem> {
|
||||||
|
if (typeof json !== 'object' || json === null) return err('werkvoorraad: not an object');
|
||||||
|
const dto = json as AanvraagSummaryDto;
|
||||||
|
if (typeof dto.id !== 'string') return err('werkvoorraad: missing id');
|
||||||
|
if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type))
|
||||||
|
return err(`werkvoorraad: bad type ${dto.type}`);
|
||||||
|
if (typeof dto.owner !== 'string' || !dto.owner) return err('werkvoorraad: missing owner');
|
||||||
|
const status = parseWerkvoorraadStatus(dto.status);
|
||||||
|
if (!status.ok) return status;
|
||||||
|
return ok({
|
||||||
|
id: dto.id,
|
||||||
|
type: dto.type as AanvraagType,
|
||||||
|
status: status.value,
|
||||||
|
owner: dto.owner,
|
||||||
|
submittedAt: dto.submittedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWerkvoorraad(json: unknown): Result<string, WerkvoorraadItem[]> {
|
||||||
|
if (!Array.isArray(json)) return err('werkvoorraad: not an array');
|
||||||
|
const out: WerkvoorraadItem[] = [];
|
||||||
|
for (const item of json) {
|
||||||
|
const parsed = parseWerkvoorraadItem(item);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
out.push(parsed.value);
|
||||||
|
}
|
||||||
|
return ok(out);
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
import { BeoordelingDocument } from '@behandeling/domain/beoordeling';
|
||||||
|
|
||||||
|
/** Organism: the documents linked to an aanvraag — plain links to the existing
|
||||||
|
(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. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-beoordeling-documenten',
|
||||||
|
template: `
|
||||||
|
@if (documenten().length === 0) {
|
||||||
|
<p class="app-text-subtle" i18n="@@beoordeling.documenten.leeg">Geen documenten.</p>
|
||||||
|
} @else {
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
@for (doc of documenten(); track doc.documentId) {
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
[href]="'/api/v1/uploads/' + doc.documentId + '/content'"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>{{ doc.fileName }}</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class BeoordelingDocumentenComponent {
|
||||||
|
documenten = input.required<BeoordelingDocument[]>();
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
|
import { BeoordelingDocumentenComponent } from './beoordeling-documenten.component';
|
||||||
|
|
||||||
|
const meta: Meta<BeoordelingDocumentenComponent> = {
|
||||||
|
title: 'Domein/Behandeling/Beoordeling Documenten',
|
||||||
|
component: BeoordelingDocumentenComponent,
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<BeoordelingDocumentenComponent>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
documenten: [
|
||||||
|
{ documentId: 'd1', categoryId: 'diploma', fileName: 'diploma.pdf' },
|
||||||
|
{ documentId: 'd2', categoryId: 'identiteit', fileName: 'paspoort.pdf' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
args: { documenten: [] },
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Component, computed, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { successOf } from '@shared/application/remote-data';
|
||||||
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
|
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
|
||||||
|
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
|
||||||
|
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
|
||||||
|
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||||
|
import { BeoordelingStore } from '@behandeling/application/beoordeling.store';
|
||||||
|
import { detailRows } from '@behandeling/domain/beoordeling-view';
|
||||||
|
import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component';
|
||||||
|
import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page: one aanvraag's beoordeling detail. The werkvoorraad list links
|
||||||
|
* here. `canBesluiten` (server-computed, ADR-0001) gates the decision form —
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-beoordeling-page',
|
||||||
|
imports: [
|
||||||
|
PageShellComponent,
|
||||||
|
AlertComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
SkeletonComponent,
|
||||||
|
DataBlockComponent,
|
||||||
|
DataRowComponent,
|
||||||
|
BeoordelingDocumentenComponent,
|
||||||
|
BesluitFormComponent,
|
||||||
|
...ASYNC,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<app-page-shell [heading]="heading" backLink="/dashboard">
|
||||||
|
<app-async [data]="store.view()" (retryClicked)="reload()">
|
||||||
|
<ng-template appAsyncLoading>
|
||||||
|
<app-skeleton height="2.5rem" [count]="5" />
|
||||||
|
</ng-template>
|
||||||
|
<ng-template appAsyncError>
|
||||||
|
<app-alert type="error">{{ failedText }}</app-alert>
|
||||||
|
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template appAsyncLoaded>
|
||||||
|
@if (view(); as v) {
|
||||||
|
<app-data-block [heading]="detailHeading" class="app-section">
|
||||||
|
@for (row of rows(v); track row.key) {
|
||||||
|
<div app-data-row [key]="row.key" [value]="row.value"></div>
|
||||||
|
}
|
||||||
|
</app-data-block>
|
||||||
|
<app-data-block [heading]="documentenHeading" class="app-section">
|
||||||
|
<app-beoordeling-documenten [documenten]="v.documenten" />
|
||||||
|
</app-data-block>
|
||||||
|
@if (v.canBesluiten) {
|
||||||
|
<div class="app-section">
|
||||||
|
<app-besluit-form [id]="v.id" (decided)="reload()" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
</app-async>
|
||||||
|
</app-page-shell>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class BeoordelingPage {
|
||||||
|
protected store = inject(BeoordelingStore);
|
||||||
|
private id = inject(ActivatedRoute).snapshot.paramMap.get('id') ?? '';
|
||||||
|
|
||||||
|
protected heading = $localize`:@@beoordeling.heading:Aanvraag`;
|
||||||
|
protected detailHeading = $localize`:@@beoordeling.detail.heading:Aanvraaggegevens`;
|
||||||
|
protected documentenHeading = $localize`:@@beoordeling.documenten.heading:Documenten`;
|
||||||
|
protected failedText = $localize`:@@beoordeling.failed:De aanvraag kon niet worden geladen.`;
|
||||||
|
protected retryText = $localize`:@@beoordeling.retry:Opnieuw proberen`;
|
||||||
|
|
||||||
|
protected rows = detailRows;
|
||||||
|
protected readonly view = computed(() => successOf(this.store.view()));
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
void this.store.load(this.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected reload() {
|
||||||
|
this.store.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
|
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||||
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
|
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
|
||||||
|
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.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 { whenTag } from '@shared/kernel/fp';
|
||||||
|
import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine';
|
||||||
|
import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* 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
|
||||||
|
* authority; on success this only emits `decided` — the page reloads the detail
|
||||||
|
* (BeoordelingStore.reload()), it doesn't guess the new state itself.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-besluit-form',
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
ButtonComponent,
|
||||||
|
HeadingComponent,
|
||||||
|
AlertComponent,
|
||||||
|
FormFieldComponent,
|
||||||
|
TextInputComponent,
|
||||||
|
RadioGroupComponent,
|
||||||
|
DataBlockComponent,
|
||||||
|
DataRowComponent,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
@if (state().tag === 'Submitted') {
|
||||||
|
<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 {
|
||||||
|
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
|
||||||
|
|
||||||
|
<form (ngSubmit)="onSubmit()" class="form-horizontal app-section">
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@besluit.besluitLabel"
|
||||||
|
label="Besluit"
|
||||||
|
fieldId="besluit-keuze"
|
||||||
|
required
|
||||||
|
[error]="errors().besluit"
|
||||||
|
>
|
||||||
|
<app-radio-group
|
||||||
|
name="besluit-keuze"
|
||||||
|
[options]="BESLUIT_OPTIONS"
|
||||||
|
[invalid]="!!errors().besluit"
|
||||||
|
[ngModel]="besluit()"
|
||||||
|
(ngModelChange)="dispatch({ tag: 'SetField', key: 'besluit', value: $event })"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@besluit.toelichtingLabel"
|
||||||
|
label="Toelichting"
|
||||||
|
fieldId="besluit-toelichting"
|
||||||
|
[error]="errors().toelichting"
|
||||||
|
>
|
||||||
|
<app-text-input
|
||||||
|
inputId="besluit-toelichting"
|
||||||
|
[invalid]="!!errors().toelichting"
|
||||||
|
[ngModel]="toelichting()"
|
||||||
|
(ngModelChange)="dispatch({ tag: 'SetField', key: 'toelichting', value: $event })"
|
||||||
|
name="toelichting"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
|
||||||
|
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
|
||||||
|
{{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}
|
||||||
|
</app-button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class BesluitFormComponent {
|
||||||
|
private submit = createSubmitBesluit();
|
||||||
|
// 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>();
|
||||||
|
decided = output<void>();
|
||||||
|
|
||||||
|
/** Optional seed so Storybook / tests can mount any state directly. */
|
||||||
|
seed = input<BesluitState>(initial);
|
||||||
|
|
||||||
|
readonly state = this.store.model;
|
||||||
|
protected dispatch = this.store.dispatch;
|
||||||
|
|
||||||
|
protected readonly BESLUIT_OPTIONS: RadioOption[] = [
|
||||||
|
{ value: 'Goedkeuren', label: $localize`:@@besluit.optie.goedkeuren:Goedkeuren` },
|
||||||
|
{ value: 'Afwijzen', label: $localize`:@@besluit.optie.afwijzen:Afwijzen` },
|
||||||
|
{
|
||||||
|
value: 'MeerInfoOpvragen',
|
||||||
|
label: $localize`:@@besluit.optie.meerInfoOpvragen:Meer informatie opvragen`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
protected readonly submitLabel = $localize`:@@besluit.submit:Besluit 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'));
|
||||||
|
protected errors = computed(() => this.editing()?.errors ?? {});
|
||||||
|
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
||||||
|
|
||||||
|
/** The value shown in the field — the live draft while editing, the parsed value
|
||||||
|
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() {
|
||||||
|
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit() {
|
||||||
|
this.dispatch({ tag: 'Submit' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
|
import { applicationConfig } from '@storybook/angular';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { BesluitFormComponent } from './besluit-form.component';
|
||||||
|
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||||
|
import { Valid } from '@behandeling/domain/besluit.machine';
|
||||||
|
|
||||||
|
const validData: Valid = { besluit: 'Afwijzen', toelichting: 'Diploma niet erkend' };
|
||||||
|
|
||||||
|
const meta: Meta<BesluitFormComponent> = {
|
||||||
|
title: 'Domein/Behandeling/Besluit Form',
|
||||||
|
component: BesluitFormComponent,
|
||||||
|
// The form injects ApiClient (over HttpClient) for the submit command.
|
||||||
|
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
|
||||||
|
args: { id: 'aanvraag-1' },
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<BesluitFormComponent>;
|
||||||
|
|
||||||
|
// One render per state of the machine.
|
||||||
|
export const Empty: Story = {
|
||||||
|
args: { seed: { tag: 'Editing', draft: { besluit: '', toelichting: '' }, errors: {} } },
|
||||||
|
};
|
||||||
|
export const WithErrors: Story = {
|
||||||
|
args: {
|
||||||
|
seed: {
|
||||||
|
tag: 'Editing',
|
||||||
|
draft: { besluit: 'Afwijzen', toelichting: '' },
|
||||||
|
errors: { toelichting: 'Geef een toelichting.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
|
||||||
|
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
|
||||||
|
export const Failed: Story = {
|
||||||
|
args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } },
|
||||||
|
};
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
|
||||||
|
import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
|
||||||
|
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
|
||||||
|
import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view';
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
beoordeling detail page. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-werkvoorraad-list',
|
||||||
|
imports: [ApplicationListComponent, ApplicationLinkComponent],
|
||||||
|
template: `
|
||||||
|
<app-application-list>
|
||||||
|
@for (item of items(); track item.id) {
|
||||||
|
@let row = row_(item);
|
||||||
|
<li
|
||||||
|
app-application-link
|
||||||
|
[heading]="row.heading"
|
||||||
|
[subtitle]="row.subtitle"
|
||||||
|
[status]="row.status"
|
||||||
|
[to]="'/aanvraag/' + item.id"
|
||||||
|
></li>
|
||||||
|
}
|
||||||
|
</app-application-list>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class WerkvoorraadListComponent {
|
||||||
|
items = input.required<WerkvoorraadItem[]>();
|
||||||
|
|
||||||
|
protected row_ = werkvoorraadRow;
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
|
import { applicationConfig } from '@storybook/angular';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { WerkvoorraadListComponent } from './werkvoorraad-list.component';
|
||||||
|
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
|
||||||
|
|
||||||
|
const items: WerkvoorraadItem[] = [
|
||||||
|
{
|
||||||
|
id: 'a1',
|
||||||
|
type: 'herregistratie',
|
||||||
|
owner: '111222333',
|
||||||
|
submittedAt: '2026-06-28T10:05:00Z',
|
||||||
|
status: { tag: 'InBehandeling', referentie: 'BIG-2026-456789', manual: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'a2',
|
||||||
|
type: 'registratie',
|
||||||
|
owner: '444555666',
|
||||||
|
submittedAt: '2026-06-27T09:00:00Z',
|
||||||
|
status: { tag: 'InBehandeling', referentie: 'BIG-2026-456790', manual: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'a3',
|
||||||
|
type: 'intake',
|
||||||
|
owner: '777888999',
|
||||||
|
status: { tag: 'Ingediend', referentie: 'BIG-2026-456791' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const meta: Meta<WerkvoorraadListComponent> = {
|
||||||
|
title: 'Domein/Behandeling/Werkvoorraad List',
|
||||||
|
component: WerkvoorraadListComponent,
|
||||||
|
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<WerkvoorraadListComponent>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: { items },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
args: { items: [] },
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Component, computed, effect, inject } from '@angular/core';
|
||||||
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||||
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||||
|
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
|
||||||
|
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||||
|
import { AccessStore } from '@shared/application/access.store';
|
||||||
|
import { successOr } from '@shared/application/remote-data';
|
||||||
|
import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store';
|
||||||
|
import { WerkvoorraadListComponent } from '@behandeling/ui/werkvoorraad-list/werkvoorraad-list.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page: the behandelaar's werkvoorraad — the behandelportal's landing page.
|
||||||
|
* 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
|
||||||
|
* a case's detail is out of scope here.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-werkvoorraad-page',
|
||||||
|
imports: [
|
||||||
|
PageShellComponent,
|
||||||
|
AlertComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
SkeletonComponent,
|
||||||
|
WerkvoorraadListComponent,
|
||||||
|
...ASYNC,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<app-page-shell [heading]="heading" [intro]="intro">
|
||||||
|
@if (!access.ready()) {
|
||||||
|
<!-- wait for /me before deciding — avoids flashing the denial to a behandelaar -->
|
||||||
|
} @else if (!canBeoordelen()) {
|
||||||
|
<app-alert type="error">{{ deniedText }}</app-alert>
|
||||||
|
} @else {
|
||||||
|
<app-async [data]="store.items()" (retryClicked)="store.reload()">
|
||||||
|
<ng-template appAsyncLoading>
|
||||||
|
<app-skeleton height="2.5rem" [count]="4" />
|
||||||
|
</ng-template>
|
||||||
|
<ng-template appAsyncError>
|
||||||
|
<app-alert type="error">{{ failedText }}</app-alert>
|
||||||
|
<app-button variant="secondary" (click)="store.reload()">{{ retryText }}</app-button>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template appAsyncLoaded>
|
||||||
|
@if (items().length === 0) {
|
||||||
|
<app-alert type="info">{{ emptyText }}</app-alert>
|
||||||
|
} @else {
|
||||||
|
<app-werkvoorraad-list [items]="items()" />
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
</app-async>
|
||||||
|
}
|
||||||
|
</app-page-shell>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class WerkvoorraadPage {
|
||||||
|
protected store = inject(WerkvoorraadStore);
|
||||||
|
protected access = inject(AccessStore);
|
||||||
|
|
||||||
|
protected canBeoordelen = computed(() => this.access.can('aanvraag:beoordelen'));
|
||||||
|
protected items = computed(() => successOr(this.store.items(), []));
|
||||||
|
|
||||||
|
protected heading = $localize`:@@werkvoorraad.heading:Werkvoorraad`;
|
||||||
|
protected intro = $localize`:@@werkvoorraad.intro:Aanvragen die op beoordeling wachten.`;
|
||||||
|
protected deniedText = $localize`:@@werkvoorraad.denied:U hebt geen rechten om de werkvoorraad te bekijken.`;
|
||||||
|
protected failedText = $localize`:@@werkvoorraad.failed:De werkvoorraad kon niet worden geladen.`;
|
||||||
|
protected emptyText = $localize`:@@werkvoorraad.empty:Er staan geen aanvragen open.`;
|
||||||
|
protected retryText = $localize`:@@werkvoorraad.retry:Opnieuw proberen`;
|
||||||
|
|
||||||
|
private loadRequested = false;
|
||||||
|
constructor() {
|
||||||
|
// Load once the capability resolves to allowed (a 403 GET would be wasted otherwise) —
|
||||||
|
// same guard-against-the-loop idiom as AdminCasesPage.
|
||||||
|
effect(() => {
|
||||||
|
if (this.canBeoordelen() && !this.loadRequested) {
|
||||||
|
this.loadRequested = true;
|
||||||
|
void this.store.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { AdminLink, HeaderNavItem } from '@shared/layout/site-header/nav-config';
|
||||||
|
|
||||||
|
/** This app's primary nav — provided to the shared site header via HEADER_NAV_ITEMS
|
||||||
|
(see app.config.ts). */
|
||||||
|
export const NAV_ITEMS: readonly HeaderNavItem[] = [
|
||||||
|
{ label: $localize`:@@header.nav.overzicht:Overzicht`, to: '/dashboard' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
the bootstrap trim, not revisited by this migration. */
|
||||||
|
export const ADMIN_LINKS: readonly AdminLink[] = [
|
||||||
|
{
|
||||||
|
label: $localize`:@@header.nav.stamdata:Stamdata`,
|
||||||
|
description: $localize`:@@admin.link.stamdata.desc:Business-tabellen onderhouden`,
|
||||||
|
to: '/beheer/stamdata',
|
||||||
|
cap: 'stamdata:edit',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $localize`:@@header.nav.audit:Auditlog`,
|
||||||
|
description: $localize`:@@admin.link.audit.desc:Toegangs- en inzagebeslissingen bekijken`,
|
||||||
|
to: '/beheer/audit',
|
||||||
|
cap: 'cases:manage',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $localize`:@@header.nav.functies:Functievlaggen`,
|
||||||
|
description: $localize`:@@admin.link.functies.desc:Functionaliteit aan- of uitzetten`,
|
||||||
|
to: '/beheer/functies',
|
||||||
|
cap: 'flags:manage',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Behandelportal</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
|
<!-- CIBG Huisstijl (customized Bootstrap 5.2). Loaded as a <link> so its relative
|
||||||
|
url(../fonts|icons|images) refs resolve against the vendored folder at runtime.
|
||||||
|
Licensed Rijksoverheid fonts are not used — styles.scss overrides the stack to system-ui. -->
|
||||||
|
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
|
||||||
|
</head>
|
||||||
|
<!-- brand--cibg activates CIBG's official palette: robijn layout chrome + lintblauw accents
|
||||||
|
(without it, --ro-layout falls back to the blue default). -->
|
||||||
|
<body class="brand--cibg">
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,980 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<file source-language="nl" datatype="plaintext" original="ng2.template">
|
||||||
|
<body>
|
||||||
|
<trans-unit id="form.verplichteVelden" datatype="html">
|
||||||
|
<source>* verplichte velden</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">15,18</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="login.bsnLabel" datatype="html">
|
||||||
|
<source>BSN</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/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>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/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>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">36,37</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="login.submit" datatype="html">
|
||||||
|
<source>Inloggen met DigiD</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">41,43</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="login.heading" datatype="html">
|
||||||
|
<source>Inloggen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/auth/ui/login.page.ts</context>
|
||||||
|
<context context-type="linenumber">14,16</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="login.intro" datatype="html">
|
||||||
|
<source>Log in op uw persoonlijke BIG-register omgeving.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/auth/ui/login.page.ts</context>
|
||||||
|
<context context-type="linenumber">17,19</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="werkvoorraad.type.registratie" datatype="html">
|
||||||
|
<source>Inschrijving</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
|
<context context-type="linenumber">11</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="werkvoorraad.type.herregistratie" datatype="html">
|
||||||
|
<source>Herregistratie</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
|
<context context-type="linenumber">12</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="werkvoorraad.type.intake" datatype="html">
|
||||||
|
<source>Herregistratie-intake</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
|
<context context-type="linenumber">13</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="werkvoorraad.status.ingediend" datatype="html">
|
||||||
|
<source>Ingediend</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
|
<context context-type="linenumber">19</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="werkvoorraad.status.inBehandelingHandmatig" datatype="html">
|
||||||
|
<source>In behandeling (handmatig)</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
|
<context context-type="linenumber">22</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="werkvoorraad.status.inBehandeling" datatype="html">
|
||||||
|
<source>In behandeling</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
|
<context context-type="linenumber">23</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beoordeling.status.meerInfoGevraagd" datatype="html">
|
||||||
|
<source>Meer informatie gevraagd</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
|
<context context-type="linenumber">25</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beoordeling.status.goedgekeurd" datatype="html">
|
||||||
|
<source>Goedgekeurd</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
|
<context context-type="linenumber">27</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beoordeling.status.afgewezen" datatype="html">
|
||||||
|
<source>Afgewezen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
|
<context context-type="linenumber">29</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beoordeling.detail.soort" datatype="html">
|
||||||
|
<source>Soort aanvraag</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
|
<context context-type="linenumber">37</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beoordeling.detail.status" datatype="html">
|
||||||
|
<source>Status</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
|
<context context-type="linenumber">38</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beoordeling.detail.referentie" datatype="html">
|
||||||
|
<source>Referentie</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
|
<context context-type="linenumber">39</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beoordeling.detail.eigenaar" datatype="html">
|
||||||
|
<source>Eigenaar (BSN)</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
|
<context context-type="linenumber">40</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beoordeling.detail.ingediend" datatype="html">
|
||||||
|
<source>Ingediend op</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
|
<context context-type="linenumber">42</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beoordeling.detail.reden" datatype="html">
|
||||||
|
<source>Reden</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
|
<context context-type="linenumber">47</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.error.verplicht" datatype="html">
|
||||||
|
<source>Kies een besluit.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts</context>
|
||||||
|
<context context-type="linenumber">46</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.error.toelichtingVerplicht" datatype="html">
|
||||||
|
<source>Geef een toelichting.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts</context>
|
||||||
|
<context context-type="linenumber">54</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="werkvoorraad.row.ingediend" datatype="html">
|
||||||
|
<source>ingediend op <x id="datum" equiv-text="formatDatumNl(item.submittedAt)"/></source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
|
||||||
|
<context context-type="linenumber">23</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="werkvoorraad.row.bsn" datatype="html">
|
||||||
|
<source>BSN <x id="bsn" equiv-text="item.owner"/></source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
|
||||||
|
<context context-type="linenumber">28</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beoordeling.documenten.leeg" datatype="html">
|
||||||
|
<source>Geen documenten.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.component.ts</context>
|
||||||
|
<context context-type="linenumber">11,13</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beoordeling.heading" datatype="html">
|
||||||
|
<source>Aanvraag</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
|
||||||
|
<context context-type="linenumber">69</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beoordeling.detail.heading" datatype="html">
|
||||||
|
<source>Aanvraaggegevens</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
|
||||||
|
<context context-type="linenumber">70</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beoordeling.documenten.heading" datatype="html">
|
||||||
|
<source>Documenten</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
|
||||||
|
<context context-type="linenumber">71</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beoordeling.failed" datatype="html">
|
||||||
|
<source>De aanvraag kon niet worden geladen.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
|
||||||
|
<context context-type="linenumber">72</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beoordeling.retry" datatype="html">
|
||||||
|
<source>Opnieuw proberen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
|
||||||
|
<context context-type="linenumber">73</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.success" datatype="html">
|
||||||
|
<source>Het besluit is vastgelegd.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">35,37</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.heading" datatype="html">
|
||||||
|
<source>Besluit vastleggen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">37,39</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.besluitLabel" datatype="html">
|
||||||
|
<source>Besluit</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">43</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.toelichtingLabel" datatype="html">
|
||||||
|
<source>Toelichting</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">59,60</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.failed" datatype="html">
|
||||||
|
<source>Het vastleggen is niet gelukt:</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">75,76</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.optie.goedkeuren" datatype="html">
|
||||||
|
<source>Goedkeuren</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">101</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.optie.afwijzen" datatype="html">
|
||||||
|
<source>Afwijzen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">102</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.optie.meerInfoOpvragen" datatype="html">
|
||||||
|
<source>Meer informatie opvragen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">105</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.submit" datatype="html">
|
||||||
|
<source>Besluit vastleggen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">109</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.submitBezig" datatype="html">
|
||||||
|
<source>Bezig met vastleggen…</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">110</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="werkvoorraad.heading" datatype="html">
|
||||||
|
<source>Werkvoorraad</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
|
||||||
|
<context context-type="linenumber">64</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="werkvoorraad.intro" datatype="html">
|
||||||
|
<source>Aanvragen die op beoordeling wachten.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
|
||||||
|
<context context-type="linenumber">65</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="werkvoorraad.denied" datatype="html">
|
||||||
|
<source>U hebt geen rechten om de werkvoorraad te bekijken.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
|
||||||
|
<context context-type="linenumber">66</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="werkvoorraad.failed" datatype="html">
|
||||||
|
<source>De werkvoorraad kon niet worden geladen.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
|
||||||
|
<context context-type="linenumber">67</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="werkvoorraad.empty" datatype="html">
|
||||||
|
<source>Er staan geen aanvragen open.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
|
||||||
|
<context context-type="linenumber">68</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="werkvoorraad.retry" datatype="html">
|
||||||
|
<source>Opnieuw proberen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
|
||||||
|
<context context-type="linenumber">69</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.nav.overzicht" datatype="html">
|
||||||
|
<source>Overzicht</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
|
||||||
|
<context context-type="linenumber">6</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.nav.stamdata" datatype="html">
|
||||||
|
<source>Stamdata</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
|
||||||
|
<context context-type="linenumber">14</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="admin.link.stamdata.desc" datatype="html">
|
||||||
|
<source>Business-tabellen onderhouden</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
|
||||||
|
<context context-type="linenumber">15</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.nav.audit" datatype="html">
|
||||||
|
<source>Auditlog</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
|
||||||
|
<context context-type="linenumber">20</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="admin.link.audit.desc" datatype="html">
|
||||||
|
<source>Toegangs- en inzagebeslissingen bekijken</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
|
||||||
|
<context context-type="linenumber">21</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.nav.functies" datatype="html">
|
||||||
|
<source>Functievlaggen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
|
||||||
|
<context context-type="linenumber">26</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="admin.link.functies.desc" datatype="html">
|
||||||
|
<source>Functionaliteit aan- of uitzetten</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
|
||||||
|
<context context-type="linenumber">27</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.noTables" datatype="html">
|
||||||
|
<source>Er is geen stamdata om te beheren.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/application/stamdata.store.ts</context>
|
||||||
|
<context context-type="linenumber">150</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.validation.key" datatype="html">
|
||||||
|
<source>Vul de sleutelkolom in.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/domain/stamdata.ts</context>
|
||||||
|
<context context-type="linenumber">68</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.validation.van" datatype="html">
|
||||||
|
<source>Vul een 'geldig van'-datum in.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/domain/stamdata.ts</context>
|
||||||
|
<context context-type="linenumber">72</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.validation.range" datatype="html">
|
||||||
|
<source>'Geldig tot' moet ná 'geldig van' liggen.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/domain/stamdata.ts</context>
|
||||||
|
<context context-type="linenumber">74</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.load.failed" datatype="html">
|
||||||
|
<source>De stamdata kon niet worden geladen.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/infrastructure/stamdata.adapter.ts</context>
|
||||||
|
<context context-type="linenumber">13</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="audit.heading" datatype="html">
|
||||||
|
<source>Auditlog</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
|
||||||
|
<context context-type="linenumber">102</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="audit.intro" datatype="html">
|
||||||
|
<source>Toegangs- en inzagebeslissingen (autorisatie en het tonen van afgeschermde gegevens). Vastgelegd zonder persoonsgegevens.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
|
||||||
|
<context context-type="linenumber">103</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="audit.denied" datatype="html">
|
||||||
|
<source>U hebt geen rechten om de auditlog te bekijken.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
|
||||||
|
<context context-type="linenumber">104</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="audit.failed" datatype="html">
|
||||||
|
<source>De auditlog kon niet worden geladen.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
|
||||||
|
<context context-type="linenumber">105</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="audit.empty" datatype="html">
|
||||||
|
<source>Nog geen auditregels.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
|
||||||
|
<context context-type="linenumber">106</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="audit.retry" datatype="html">
|
||||||
|
<source>Opnieuw proberen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
|
||||||
|
<context context-type="linenumber">107</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="audit.col.tijd" datatype="html">
|
||||||
|
<source>Tijd</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
|
||||||
|
<context context-type="linenumber">108</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="audit.col.actie" datatype="html">
|
||||||
|
<source>Actie</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
|
||||||
|
<context context-type="linenumber">109</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="audit.col.resource" datatype="html">
|
||||||
|
<source>Resource</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
|
||||||
|
<context context-type="linenumber">110</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="audit.col.besluit" datatype="html">
|
||||||
|
<source>Besluit</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
|
||||||
|
<context context-type="linenumber">111</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="audit.col.rol" datatype="html">
|
||||||
|
<source>Rol</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
|
||||||
|
<context context-type="linenumber">112</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="audit.col.cid" datatype="html">
|
||||||
|
<source>Correlatie-id</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
|
||||||
|
<context context-type="linenumber">113</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="flags.heading" datatype="html">
|
||||||
|
<source>Functievlaggen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
|
||||||
|
<context context-type="linenumber">82</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="flags.intro" datatype="html">
|
||||||
|
<source>Zet functionaliteit aan of uit tijdens runtime. De catalogus staat vast in code; hier beheert u de status.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
|
||||||
|
<context context-type="linenumber">83</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="flags.denied" datatype="html">
|
||||||
|
<source>U hebt geen rechten om functievlaggen te beheren.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
|
||||||
|
<context context-type="linenumber">84</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="flags.failed" datatype="html">
|
||||||
|
<source>De functievlaggen konden niet worden geladen.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
|
||||||
|
<context context-type="linenumber">85</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="flags.retry" datatype="html">
|
||||||
|
<source>Opnieuw proberen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
|
||||||
|
<context context-type="linenumber">86</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="flags.on" datatype="html">
|
||||||
|
<source>Aan</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
|
||||||
|
<context context-type="linenumber">87</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="flags.off" datatype="html">
|
||||||
|
<source>Uit</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
|
||||||
|
<context context-type="linenumber">88</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="flags.enable" datatype="html">
|
||||||
|
<source>Aanzetten</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
|
||||||
|
<context context-type="linenumber">89</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="flags.disable" datatype="html">
|
||||||
|
<source>Uitzetten</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
|
||||||
|
<context context-type="linenumber">90</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.added" datatype="html">
|
||||||
|
<source>toegevoegd</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">228</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.edited" datatype="html">
|
||||||
|
<source>gewijzigd</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">229</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.removed" datatype="html">
|
||||||
|
<source>verwijderd</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">230</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.table" datatype="html">
|
||||||
|
<source>Tabel</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">236</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.peildatum" datatype="html">
|
||||||
|
<source>Toon geldig op</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">237</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.showAll" datatype="html">
|
||||||
|
<source>Toon alles</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">238</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.previewNote" datatype="html">
|
||||||
|
<source>Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">239</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.actions" datatype="html">
|
||||||
|
<source>Acties</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">240</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.remove" datatype="html">
|
||||||
|
<source>Verwijderen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">241</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.expire" datatype="html">
|
||||||
|
<source>Sluiten per vandaag</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">242</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.removeConfirm" datatype="html">
|
||||||
|
<source>Rij verwijderen? Als andere gegevens ernaar verwijzen, faalt de build-controle (CI). Bij een tabel met een geldigheidsperiode kunt u de rij beter sluiten (geldig tot) in plaats van verwijderen.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">243</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.undo" datatype="html">
|
||||||
|
<source>Ongedaan maken</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">257</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.redo" datatype="html">
|
||||||
|
<source>Opnieuw uitvoeren</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">258</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.addRow" datatype="html">
|
||||||
|
<source>Rij toevoegen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">259</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.download" datatype="html">
|
||||||
|
<source>Download JSON</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">260</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.applyHint" datatype="html">
|
||||||
|
<source>Wijzigingen worden als JSON-bestand gedownload en via een pull request toegepast — de build (CI) controleert ze.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">261</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.page.heading" datatype="html">
|
||||||
|
<source>Stamdata onderhouden</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata.page.ts</context>
|
||||||
|
<context context-type="linenumber">72</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.page.intro" datatype="html">
|
||||||
|
<source>Beheer de business-tabellen die de registratie stuurt. Wijzigingen worden als JSON gedownload en via een pull request toegepast; de build blijft de bewaker.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata.page.ts</context>
|
||||||
|
<context context-type="linenumber">73</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.page.denied" datatype="html">
|
||||||
|
<source>U hebt geen rechten om stamdata te onderhouden.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata.page.ts</context>
|
||||||
|
<context context-type="linenumber">74</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.page.failed" datatype="html">
|
||||||
|
<source>De stamdata kon niet worden geladen.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata.page.ts</context>
|
||||||
|
<context context-type="linenumber">75</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="beheer.page.retry" datatype="html">
|
||||||
|
<source>Opnieuw proberen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/beheer/src/ui/stamdata.page.ts</context>
|
||||||
|
<context context-type="linenumber">76</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="submit.failed" datatype="html">
|
||||||
|
<source>Het indienen is niet gelukt. Probeer het later opnieuw.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/application/submit.ts</context>
|
||||||
|
<context context-type="linenumber">28</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="validation.bsn" datatype="html">
|
||||||
|
<source>Voer een geldig BSN van 9 cijfers in.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/kernel/bsn.ts</context>
|
||||||
|
<context context-type="linenumber">18</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="validation.bsnElfproef" datatype="html">
|
||||||
|
<source>Dit is geen geldig BSN (klopt niet met de elfproef).</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/kernel/bsn.ts</context>
|
||||||
|
<context context-type="linenumber">23</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="crumb.dashboard" datatype="html">
|
||||||
|
<source>Mijn overzicht</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
|
||||||
|
<context context-type="linenumber">12</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="crumb.registratie" datatype="html">
|
||||||
|
<source>Mijn gegevens</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
|
||||||
|
<context context-type="linenumber">13</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="crumb.registreren" datatype="html">
|
||||||
|
<source>Inschrijven</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
|
||||||
|
<context context-type="linenumber">14</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="crumb.herregistratie" datatype="html">
|
||||||
|
<source>Herregistratie</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
|
||||||
|
<context context-type="linenumber">16</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="crumb.intake" datatype="html">
|
||||||
|
<source>Herregistratie-intake</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
|
||||||
|
<context context-type="linenumber">19</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="crumb.concepts" datatype="html">
|
||||||
|
<source>Functionele patronen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
|
||||||
|
<context context-type="linenumber">20</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="breadcrumb.aria" datatype="html">
|
||||||
|
<source>Kruimelpad</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb.component.ts</context>
|
||||||
|
<context context-type="linenumber">27,28</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="breadcrumb.hier" datatype="html">
|
||||||
|
<source>U bevindt zich hier:</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb.component.ts</context>
|
||||||
|
<context context-type="linenumber">28,29</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="lang.navLabel" datatype="html">
|
||||||
|
<source>Taal / Language</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/language-switcher/language-switcher.component.ts</context>
|
||||||
|
<context context-type="linenumber">95</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="lang.heading" datatype="html">
|
||||||
|
<source>Kies een taal</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/language-switcher/language-switcher.component.ts</context>
|
||||||
|
<context context-type="linenumber">96</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="pageShell.backLabel" datatype="html">
|
||||||
|
<source>Terug naar overzicht</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/page-shell/page-shell.component.ts</context>
|
||||||
|
<context context-type="linenumber">49</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="shell.skipLink" datatype="html">
|
||||||
|
<source>Naar de inhoud</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/shell/shell.component.ts</context>
|
||||||
|
<context context-type="linenumber">62,63</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="footer.tagline" datatype="html">
|
||||||
|
<source>De Rijksoverheid. Voor Nederland.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
|
||||||
|
<context context-type="linenumber">85,86</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="footer.ministry" datatype="html">
|
||||||
|
<source> CIBG — Ministerie van Volksgezondheid, Welzijn en Sport </source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
|
||||||
|
<context context-type="linenumber">87,89</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="footer.overSiteAria" datatype="html">
|
||||||
|
<source>Over deze site</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
|
||||||
|
<context context-type="linenumber">90,91</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="footer.overSite" datatype="html">
|
||||||
|
<source>Over deze site</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
|
||||||
|
<context context-type="linenumber">91,92</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="footer.privacy" datatype="html">
|
||||||
|
<source>Privacy</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
|
||||||
|
<context context-type="linenumber">99,101</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="footer.cookies" datatype="html">
|
||||||
|
<source>Cookies</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
|
||||||
|
<context context-type="linenumber">108,110</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="footer.toegankelijkheid" datatype="html">
|
||||||
|
<source>Toegankelijkheid</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
|
||||||
|
<context context-type="linenumber">117,120</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="footer.demo" datatype="html">
|
||||||
|
<source>Demo / POC — geen echte gegevens.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
|
||||||
|
<context context-type="linenumber">122,124</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.sender" datatype="html">
|
||||||
|
<source>BIG-register</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/site-header/site-header.component.ts</context>
|
||||||
|
<context context-type="linenumber">44,45</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.ministry" datatype="html">
|
||||||
|
<source>Ministerie van Volksgezondheid, Welzijn en Sport</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/site-header/site-header.component.ts</context>
|
||||||
|
<context context-type="linenumber">46,48</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.uitloggen" datatype="html">
|
||||||
|
<source> Uitloggen </source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/site-header/site-header.component.ts</context>
|
||||||
|
<context context-type="linenumber">68,69</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.navAria" datatype="html">
|
||||||
|
<source>Hoofdnavigatie</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/layout/site-header/site-header.component.ts</context>
|
||||||
|
<context context-type="linenumber">76,77</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="alert.icon.info" datatype="html">
|
||||||
|
<source>Informatie</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/ui/alert/alert.component.ts</context>
|
||||||
|
<context context-type="linenumber">7</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="alert.icon.ok" datatype="html">
|
||||||
|
<source>Gelukt</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/ui/alert/alert.component.ts</context>
|
||||||
|
<context context-type="linenumber">8</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="alert.icon.warning" datatype="html">
|
||||||
|
<source>Waarschuwing</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/ui/alert/alert.component.ts</context>
|
||||||
|
<context context-type="linenumber">9</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="alert.icon.error" datatype="html">
|
||||||
|
<source>Foutmelding</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/ui/alert/alert.component.ts</context>
|
||||||
|
<context context-type="linenumber">10</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="async.error" datatype="html">
|
||||||
|
<source>Er ging iets mis bij het laden van de gegevens.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/ui/async/async.component.ts</context>
|
||||||
|
<context context-type="linenumber">105</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="async.retry" datatype="html">
|
||||||
|
<source>Opnieuw proberen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/ui/async/async.component.ts</context>
|
||||||
|
<context context-type="linenumber">106</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="async.empty" datatype="html">
|
||||||
|
<source>Geen gegevens gevonden.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/ui/async/async.component.ts</context>
|
||||||
|
<context context-type="linenumber">107</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="common.ja" datatype="html">
|
||||||
|
<source>Ja</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/ui/radio-group/radio-group.component.ts</context>
|
||||||
|
<context context-type="linenumber">12</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="common.nee" datatype="html">
|
||||||
|
<source>Nee</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/ui/radio-group/radio-group.component.ts</context>
|
||||||
|
<context context-type="linenumber">13</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="spinner.aria" datatype="html">
|
||||||
|
<source>Bezig met laden</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/ui/spinner/spinner.component.ts</context>
|
||||||
|
<context context-type="linenumber">36,40</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
</body>
|
||||||
|
</file>
|
||||||
|
</xliff>
|
||||||
@@ -7,5 +7,5 @@
|
|||||||
"types": ["@angular/localize"]
|
"types": ["@angular/localize"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["src/**/*.spec.ts", "src/**/*.stories.ts"]
|
"exclude": ["src/**/*.spec.ts", "src/**/*.stories.ts", "src/**/*.testing.ts"]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* Per-app base: adds this app's own alias map on top of the workspace-wide compiler
|
||||||
|
options in the root tsconfig.json. See that file's comment on why paths live here,
|
||||||
|
per-app, instead of at the root. */
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@shared/*": ["../../libs/shared/src/*"],
|
||||||
|
"@beheer/*": ["../../libs/beheer/src/*"],
|
||||||
|
"@auth/*": ["src/app/auth/*"],
|
||||||
|
"@behandeling/*": ["src/app/behandeling/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:5000",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
ApplicationConfig,
|
||||||
|
LOCALE_ID,
|
||||||
|
isDevMode,
|
||||||
|
provideBrowserGlobalErrorListeners,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { provideRouter, withInMemoryScrolling, withViewTransitions } from '@angular/router';
|
||||||
|
import type { ActivatedRouteSnapshot } from '@angular/router';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { registerLocaleData } from '@angular/common';
|
||||||
|
import localeNl from '@angular/common/locales/nl';
|
||||||
|
import localeEn from '@angular/common/locales/en';
|
||||||
|
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
|
||||||
|
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
|
||||||
|
import { subjectInterceptor } from '@shared/infrastructure/subject.interceptor';
|
||||||
|
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||||
|
import { SESSION_PORT } from '@shared/application/session.port';
|
||||||
|
import { SessionStore } from '@auth/application/session.store';
|
||||||
|
import { provideRouteFocus } from '@shared/layout/route-focus';
|
||||||
|
import { provideUnloadFlush } from '@shared/application/pending-saves';
|
||||||
|
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from '@shared/layout/site-header/nav-config';
|
||||||
|
import { DEBUG_PANEL } from '@shared/layout/shell/shell.component';
|
||||||
|
import { ADMIN_LINKS, NAV_ITEMS } from './shell/nav.config';
|
||||||
|
import { DebugStateComponent } from './shell/debug-state/debug-state.component';
|
||||||
|
|
||||||
|
// Both locales' data so DatePipe/number pipes work for whichever bundle is active.
|
||||||
|
registerLocaleData(localeNl);
|
||||||
|
registerLocaleData(localeEn);
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(
|
||||||
|
routes,
|
||||||
|
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
|
||||||
|
// Cross-fade page-to-page navigations only. A silent same-route nav — e.g.
|
||||||
|
// draft-sync stamping `?aanvraag=<id>` into the URL mid-wizard — must NOT
|
||||||
|
// animate: for the transition's duration Firefox's `::view-transition`
|
||||||
|
// overlay swallows pointer events (Chrome sets pointer-events:none, so it
|
||||||
|
// doesn't), which loses a click landing on it and makes the wizard's "next"
|
||||||
|
// button need a second click. Skip the transition when the route is unchanged.
|
||||||
|
withViewTransitions({
|
||||||
|
onViewTransitionCreated: ({ transition, from, to }) => {
|
||||||
|
// `from`/`to` are the ROOT snapshots (the shared shell), so descend to the
|
||||||
|
// leaf before comparing — otherwise every navigation looks "same route".
|
||||||
|
const leaf = (r: ActivatedRouteSnapshot) => {
|
||||||
|
while (r.firstChild) r = r.firstChild;
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
if (leaf(from).routeConfig === leaf(to).routeConfig) transition.skipTransition();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
// Dev-only: the ?scenario= toggle must never reach a production build, where
|
||||||
|
// a query param could otherwise force errors on the live app.
|
||||||
|
provideHttpClient(
|
||||||
|
withInterceptors(
|
||||||
|
isDevMode() ? [scenarioInterceptor, roleInterceptor, subjectInterceptor] : [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
provideApiClient(),
|
||||||
|
{ provide: SESSION_PORT, useExisting: SessionStore },
|
||||||
|
// Per-bundle locale: the localize build sets `$localize.locale` ('nl'/'en'); the
|
||||||
|
// non-localized dev/source build leaves it undefined → fall back to 'nl'. (Was hardcoded
|
||||||
|
// 'nl', which mis-formatted dates/numbers in the en bundle.)
|
||||||
|
{ provide: LOCALE_ID, useFactory: () => $localize.locale ?? 'nl' },
|
||||||
|
provideRouteFocus(),
|
||||||
|
provideUnloadFlush(),
|
||||||
|
{ provide: HEADER_NAV_ITEMS, useValue: NAV_ITEMS },
|
||||||
|
{ provide: HEADER_ADMIN_LINKS, useValue: ADMIN_LINKS },
|
||||||
|
{ provide: DEBUG_PANEL, useValue: DebugStateComponent },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
imports: [RouterOutlet],
|
||||||
|
template: '<router-outlet />',
|
||||||
|
})
|
||||||
|
export class App {}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||||
|
import { Result } from '@shared/kernel/fp';
|
||||||
|
import { Principal, parseStoredPrincipal } from '../domain/principal';
|
||||||
|
import { DigidAdapter } from '../infrastructure/digid.adapter';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'session-v1';
|
||||||
|
|
||||||
|
/** Restore a persisted principal (best-effort; corrupt entry → logged out).
|
||||||
|
The parse + shape validation (G1/G2) lives in `parseStoredPrincipal`
|
||||||
|
(`../domain/principal`) — pure, spec'd, and testable without stubbing
|
||||||
|
`localStorage`; this just supplies the raw value. */
|
||||||
|
function restore(): Principal | null {
|
||||||
|
return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the current zorgverlener principal for the whole SSP. One
|
||||||
|
* `providedIn: 'root'` instance, so logging in is instantly visible everywhere
|
||||||
|
* (the guard, the header). Persisted to localStorage — a refresh or the
|
||||||
|
* cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
|
||||||
|
* but never the BSN itself (G1 in the `effect` below): this principal carries a
|
||||||
|
* citizen's national identifier, which the behandelportal's equivalent store does
|
||||||
|
* not have to guard against, because its `medewerker` principal has no BSN.
|
||||||
|
* ponytail: localStorage, not sessionStorage — sessionStorage's per-tab clearing
|
||||||
|
* 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' })
|
||||||
|
export class SessionStore {
|
||||||
|
private digid = inject(DigidAdapter);
|
||||||
|
private _session = signal<Principal | null>(restore());
|
||||||
|
|
||||||
|
readonly session = this._session.asReadonly();
|
||||||
|
readonly isAuthenticated = computed(() => this._session() !== null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const p = this._session();
|
||||||
|
// G1: persist only `naam` — never write the BSN (national ID) to storage.
|
||||||
|
if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: p.naam }));
|
||||||
|
else localStorage.removeItem(STORAGE_KEY);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effectful command: authenticate, then store the principal on success. */
|
||||||
|
async login(bsn: string): Promise<Result<string, Principal>> {
|
||||||
|
const r = await this.digid.authenticate(bsn);
|
||||||
|
if (r.ok) this._session.set(r.value);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this._session.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes —
|
||||||
|
* `Principal`, the login flow, `SessionStore`. A guard is neither.
|
||||||
|
*/
|
||||||
|
export { authGuard, capabilityGuard } from '@shared/application/auth.guard';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-3
@@ -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({
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
|
import { LoginFormComponent } from './login-form.component';
|
||||||
|
|
||||||
|
const meta: Meta<LoginFormComponent> = {
|
||||||
|
title: 'Domein/Auth/Login Form',
|
||||||
|
component: LoginFormComponent,
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<LoginFormComponent>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
@@ -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';
|
||||||
|
|
||||||
+147
-35
@@ -1,10 +1,16 @@
|
|||||||
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 {
|
||||||
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
BRIEF_LOAD_FAILED,
|
||||||
|
BriefAdapter,
|
||||||
|
BriefLoadFailure,
|
||||||
|
BriefView,
|
||||||
|
} from '@brief/infrastructure/brief.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,38 +179,79 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('BriefStore undo/redo history', () => {
|
describe('BriefStore undo/redo history', () => {
|
||||||
it('records an edit, undoes and redoes it; buttons mirror; a no-op edit is not recorded', async () => {
|
it('starts with nothing to undo', async () => {
|
||||||
|
// Given a freshly loaded brief.
|
||||||
|
// When no edit has happened yet...
|
||||||
const store = await loadedStore();
|
const store = await loadedStore();
|
||||||
expect(store.canUndo()).toBe(false);
|
|
||||||
|
|
||||||
|
// Then there is nothing to undo.
|
||||||
|
expect(store.canUndo()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records an edit and makes it undoable', async () => {
|
||||||
|
// Given a loaded brief with one block.
|
||||||
|
const store = await loadedStore();
|
||||||
|
|
||||||
|
// When a block is removed...
|
||||||
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
|
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
|
||||||
|
|
||||||
|
// Then the block is gone and the edit becomes undoable.
|
||||||
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
|
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
|
||||||
expect(store.canUndo()).toBe(true);
|
expect(store.canUndo()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo reverts the edit and enables redo', async () => {
|
||||||
|
// Given a brief with one recorded edit (a removed block).
|
||||||
|
const store = await loadedStore();
|
||||||
|
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
|
||||||
|
|
||||||
|
// When the edit is undone...
|
||||||
store.undo();
|
store.undo();
|
||||||
|
|
||||||
|
// Then the block is back, and redo becomes available.
|
||||||
expect(loadedBrief(store).sections[0].blocks.length).toBe(1);
|
expect(loadedBrief(store).sections[0].blocks.length).toBe(1);
|
||||||
expect(store.canRedo()).toBe(true);
|
expect(store.canRedo()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redo reapplies the undone edit', async () => {
|
||||||
|
// Given an edit that was undone.
|
||||||
|
const store = await loadedStore();
|
||||||
|
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
|
||||||
|
store.undo();
|
||||||
|
|
||||||
|
// When it is redone...
|
||||||
store.redo();
|
store.redo();
|
||||||
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
|
|
||||||
|
|
||||||
// A no-op edit (unknown block) changes nothing → leaves no dead history step.
|
// Then the edit is reapplied.
|
||||||
|
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a no-op edit does not clear the redo future', async () => {
|
||||||
|
// Given an undone edit, with redo available.
|
||||||
|
const store = await loadedStore();
|
||||||
|
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
|
||||||
store.undo(); // back to 1 block, redo available
|
store.undo(); // back to 1 block, redo available
|
||||||
|
|
||||||
|
// When an edit that changes nothing (an unknown block) is applied...
|
||||||
store.edit({ tag: 'BlockRemoved', blockId: 'does-not-exist' });
|
store.edit({ tag: 'BlockRemoved', blockId: 'does-not-exist' });
|
||||||
expect(store.canRedo()).toBe(true); // future NOT cleared by a no-op
|
|
||||||
|
// Then the no-op leaves no dead history step — redo is still available.
|
||||||
|
expect(store.canRedo()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a new edit clears the redo future', async () => {
|
it('a new edit clears the redo future', async () => {
|
||||||
@@ -216,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),
|
||||||
@@ -238,42 +306,47 @@ 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: 'De voorvertoning kon niet worden geopend.',
|
error: PREVIEW_FAILED,
|
||||||
});
|
});
|
||||||
|
|
||||||
await store.previewLetter();
|
await store.previewLetter();
|
||||||
expect(open).not.toHaveBeenCalled();
|
expect(opened).toHaveLength(0);
|
||||||
expect(store.lastError()).toBe('De voorvertoning kon niet worden geopend.');
|
expect(store.lastError()).toBe(PREVIEW_FAILED);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -338,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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
+80
-41
@@ -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.history.clear();
|
this.hasRecoveredFromMissingBrief = true;
|
||||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
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.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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+48
-33
@@ -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):
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user