Compare commits

...

10 Commits

Author SHA1 Message Date
cf44bda0ce docs(skills): extract house recipes as Claude Code skills for SSP templating
Some checks failed
CI / frontend (push) Successful in 2m16s
CI / storybook-a11y (push) Successful in 4m6s
CI / backend (push) Failing after 48s
CI / api-client-drift (push) Successful in 1m32s
8 template-generic skills in .claude/skills/ (new-feature, new-context,
value-object, form-machine, bff-endpoint, mutation-command, ui-component,
new-ssp), condensed from CLAUDE.md/ARCHITECTURE/fp-tea/ADRs and pointing at
this repo's worked examples. CLAUDE.md gains a pointer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:10:38 +02:00
0f360c5939 fix(cibg): datablock row separators + filled Bestand-upload button
- Datablock entries had no separators: CIBG ships the border on dt/dd with a
  :last-of-type reset, but the one-row-per-<div> grouping (for axe) made every
  dt/dd a last-of-type, stripping them all. Carry the separator on the row
  (:host:not(:last-of-type)) in the CIBG line colour (#f1f5f9 = cool-grey-200).
- Upload "Bestand toevoegen" button read as a pale outline: btn-outline-primary
  forced a transparent background over .btn-upload's accent fill. Use
  btn-primary btn-upload (solid #01689b, white text, folder glyph) and drop the
  redundant centred background-image folder.

GREEN: lint, tokens, 183 tests, build, 137 axe stories. Verified via Storybook
screenshots (datablock separators, filled blue upload button).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 08:46:06 +02:00
05314afd98 docs(backlog): WP-11 + WP-12 done; note WP-13 upload correction
The CIBG UI fidelity pass completed WP-11 (aanvragen/application-link) and WP-12
(Datablock), and reworked the upload suite to wrap vendored CIBG classes rather
than mark it as a gap (WP-13's assumption corrected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:56:18 +02:00
ccc0184342 feat(aanvragen): richer rows (purpose + status) linking to a case-detail page
- Aanvragen rows now show what the aanvraag is for (purpose subtitle) and an
  explicit status label (In behandeling / Goedgekeurd / Afgewezen) alongside the
  reference + submit date, via an expanded aanvraag-view (purposeLabel,
  statusLabel, referentie, detailRows) + spec.
- Rows link to a new /aanvraag/:id case-detail page, so the CIBG chevron shows
  and each aanvraag opens as a (stub) case — it lists soort/waarvoor/status/
  referentie/ingediend in a Datablock, with a note that full handling is future.

GREEN: lint, tokens, 183 tests, build, 137 axe stories. Verified visually
(dashboard aanvragen rows, upload drop-zone, datablock) via Storybook screenshots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:55:21 +02:00
b5c5d30a65 feat(cibg): adopt the vendored CIBG Bestand-upload component
The upload suite now wraps the vendored CIBG file-upload classes instead of
hand-rolling from tokens:
- file-input becomes a .file-picker-drop-area with drag-and-drop + a real
  .btn-upload button and a visually-hidden, focusable <input>; renders an
  always-visible instruction (allowed types + max size) linked to the input via
  aria-describedby (pattern requirement).
- The file list is a semantic ul.file-list; single-upload is an
  li[app-single-upload] .file-container (native <li> child) with .actions
  (retry/.icon-remove) + progress; document-chip renders the .file block
  (status glyph, .file-name link, .file-meta size/status).
- Category validation moves ABOVE the block as .upload-validation > .feedback.
- Instruction text uses the default (not subtle) foreground for WCAG AA contrast
  on the grey drop-area.

DocumentUpload's public inputs/outputs are unchanged — both wizards keep working.
GREEN: lint, tokens, 181 tests, build, 137 axe stories.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:47:25 +02:00
98fd7e4bcd feat(cibg): WP-11 — render "Mijn aanvragen" as the CIBG Aanvragen component
- application-link switches to a `li[app-application-link]` attribute selector
  (native <li> child of the <ul> — axe-clean list) and drops the invented,
  dead `.application` / `.application-title` classes for the real vendored
  `.dashboard-block.applications li a` chain (h3.h3 / .subtitle / .status / .cta).
  Content stacks in a flex column; a non-navigating row mirrors the card surface
  from tokens. Re-enables a11y on the application-link/list stories.
- Dashboard "Mijn aanvragen" now renders through app-application-list +
  <li app-application-link> rows (was a keuzelijst), mapped by a new pure
  submittedRow() view helper (+ spec). Concepts stay the resumable melding.
- aanvraag-block is now concept-only (submitted mapping moved to aanvraag-view).

WP-11 grep gate clean. GREEN: lint, tokens, 181 tests, build, 136 axe stories.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:34:21 +02:00
82fc3c493d feat(cibg): WP-12 — CIBG Datablock for application data
Adopt the vendored CIBG Datablock (.data-block / .block-wrapper) as the way to
show application data:
- New app-data-block molecule (grey surface + white panel + projected rows,
  optional heading, stacked variant, aria-label) + stories.
- data-row switches to a `div[app-data-row]` attribute selector so the <dl>'s
  direct child is a native <div> (HTML5.1 dl > div > dt+dd). This makes the
  definition list axe-clean — a bare custom element between <dl> and its dt/dd
  trips axe's definition-list rule regardless of display:contents, a defect the
  dashboard shipped live. Re-enables a11y on the data-row / review-section /
  registration-summary stories (previously disabled pending this rework).
- review-section folds onto app-data-block (drops its hand-carried classes).
- registration-summary + dashboard "Persoonsgegevens (BRP)" drop app-card and
  render as datablocks; both wizards' review rows + the beroep row convert to
  the div selector.

GREEN: lint, check:tokens, 178 tests, build, build-storybook, 136 axe stories.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:25:57 +02:00
947d5fa90a docs(backlog): record WP-04 commit hash
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:01:49 +02:00
035e785c95 feat(boundaries): WP-04 — ui ↛ infrastructure + showcase sanction
Move the two wizard lookups behind application-layer facades so ui/ no longer
injects infrastructure adapters directly:
- RegistratieLookupStore (BRP address + DUO diplomas): owns the resources,
  runs the trust-boundary parse, exposes adresStatus/prefillAdres/duoLookup.
- IntakePolicyStore (scholing threshold): owns the policy resource, exposes
  the derived threshold.

Add the lint rule ui/ + layout/ ↛ **/infrastructure/** (@typescript-eslint
variant so it composes with the base direction rules; stories/specs exempted
as test scaffolding). Add the documented showcase sanction (may read every
context). Fix the docs' inventory: 6 contexts / 5 layers, +brief, +contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:01:42 +02:00
3a5c8f157a fix(wizard): don't animate silent ?aanvraag stamp — Firefox double-click on Next
The inschrijven/herregistratie wizards needed two clicks on "Volgende" to advance
in Firefox (and Zen). Root cause: picking a field (e.g. correspondentie) triggers
draft-sync's debounced create, which router.navigate()s to stamp ?aanvraag=<id>
into the URL. withViewTransitions() animated that same-route navigation, and for
the transition's duration Firefox's ::view-transition overlay swallows pointer
events (confirmed: elementFromPoint over the button returns the overlay, not the
button). Chrome sets pointer-events:none on the overlay so clicks pass through —
hence Firefox-only.

Fix: skip the view transition for same-route navigations (compare leaf routeConfig)
so the silent id-stamp doesn't animate; genuine page-to-page transitions still fade.

Verified in Firefox: page navs still animate (skipped:0), the ?aanvraag stamp is
skipped, and Post → single Next advances step 1 → 2. GREEN + a11y.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:51:11 +02:00
46 changed files with 4174 additions and 2221 deletions

View File

@@ -0,0 +1,57 @@
---
name: bff-endpoint
description: Add a screen-shaped read — backend endpoint returning a decision-enriched DTO, regenerated typed client, contracts DTO, adapter with parse boundary, application store, <app-async> in the page. Use for any new data a page needs.
---
# BFF-lite read endpoint (decision DTO)
One screen = one endpoint returning everything that screen needs, **decisions
included**. The FE renders the server's decisions; it never recomputes business
rules (ADR-0001). Per rule choose: **decision flag** (server computes the boolean)
or **config value** (server sends the threshold, FE applies it for instant feedback,
server re-validates as authority).
## Steps
1. **Backend** (`backend/src/BigRegister.Api/`): minimal-API endpoint under `/api/v1`,
wire DTO in `Contracts/`, rule in `Domain/` with a test in
`backend/tests/BigRegister.Tests/`. Rejections are ProblemDetails (RFC 7807, 422).
2. **Regenerate the client**: `npm run gen:api` — commits `backend/swagger.json` +
`src/app/shared/infrastructure/api-client.ts` (CI has a drift check; never edit the generated file).
3. **DTO** in `<context>/contracts/<name>.dto.ts` — plain interfaces/string-literal
unions. Contracts import **nothing** (lint-enforced): no Angular, no aliases, no relative imports.
4. **Adapter** in `<context>/infrastructure/<name>.adapter.ts` — the only place HTTP lives:
```ts
@Injectable({ providedIn: 'root' })
export class DashboardViewAdapter {
private client = inject(ApiClient);
viewResource() { return resource({ loader: () => this.client.dashboardView() }); }
}
// The trust boundary: validate the untrusted shape AND map DTO → domain.
export function parseDashboardView(json: unknown): Result<string, DashboardView> { … }
```
The mapping may read as an identity copy today — the point is the **types** differ,
so the compiler forces a real mapping the moment the wire diverges.
5. **Application store** exposes the resource as `RemoteData` (`fromResource`,
combine sources with `map`/`map2`/`andThen` from `@shared/application/remote-data`).
UI never imports infrastructure (lint-enforced).
6. **Page** renders through `<app-async>` (`shared/ui/async`) — one of
loading/empty/error/loaded by construction. Check all four states with the
dev-only `?scenario=slow|loading|empty|error` toggle.
## Worked example (trace one name through all layers)
`dashboard-view`: `backend/src/BigRegister.Api` endpoint →
`src/app/registratie/contracts/dashboard-view.dto.ts` →
`src/app/registratie/infrastructure/dashboard-view.adapter.ts` (+ spec on `parseDashboardView`) →
`src/app/registratie/application/big-profile.store.ts` → dashboard page.
## Verify
```bash
cd backend && dotnet test && cd ..
npm run gen:api && git diff --exit-code src/app/shared/infrastructure/api-client.ts
npm test && npm run lint && npm run build
```

View File

@@ -0,0 +1,70 @@
---
name: form-machine
description: Add a form or wizard as an Elm-style state machine (Model/Msg/pure reduce) — the one idiom for anything with validation or submission, one step or many. Use instead of hand-rolled mutable fields + ad-hoc error signals.
---
# Form machine (Model / Msg / reduce)
If you're about to add a second boolean to track state, stop — model a discriminated
union. Fields exist only in the states that need them, so illegal states are
unrepresentable.
## Skeleton
`<context>/domain/<name>.machine.ts` (pure TS, no Angular imports):
```ts
import { Result, assertNever } from '@shared/kernel/fp';
export interface Draft { postcode: string; uren: string } // raw strings as typed
export type StepErrors = Partial<Record<keyof Draft, string>>;
export interface Valid { postcode: Postcode; uren: Uren } // branded, proven valid
export type State =
| { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid }
| { tag: 'Failed'; data: Valid; error: string };
export type Msg =
| { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Next' } | { tag: 'Back' } | { tag: 'Submit' } | { tag: 'Retry' }
| { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error: string }
| { tag: 'Seed'; state: State }; // mount any state (stories, resume)
export const initial: State = { tag: 'Editing', step: 1, draft: emptyDraft, errors: {} };
export function reduce(s: State, m: Msg): State {
switch (m.tag) {
/* … pure transitions only … */
default: return assertNever(m); // exhaustiveness enforced
}
}
export function validate(draft: Draft): Result<StepErrors, Valid> { /* calls value-object parsers */ }
```
Rules:
- **Reducer stays pure.** HTTP lives in a command that dispatches `SubmitConfirmed` / `SubmitFailed` (see **mutation-command** skill).
- **Derive, don't store**: anything computable from answers is a pure function (`visibleSteps(answers)`), never a stored field.
- Server-owned thresholds arrive as config values; keep only an offline fallback constant (see `SCHOLING_THRESHOLD_DEFAULT` in the intake machine).
- Co-located `.machine.spec.ts` is **required**: drive `reduce` with messages, assert states. No TestBed.
## Wiring in the UI
The organism holds `createStore(initial, reduce)` (`@shared/application/store`) as a
field initializer, derives view state via `computed` + `whenTag(state, 'Editing')`,
and renders into `<app-wizard-shell>` (`shared/layout/wizard-shell`) — status, steps,
errors, and primary/back/retry outputs map 1:1 onto the machine.
## Worked examples
- `src/app/herregistratie/domain/herregistratie.machine.ts` — canonical multi-step + submit lifecycle.
- `src/app/herregistratie/domain/intake.machine.ts` — progressive disclosure, derive-don't-store, server-owned threshold.
- `src/app/herregistratie/ui/` — the wizard organism + page composition.
## Verify
```bash
npm test && npm run lint && npm run build
```

View File

@@ -0,0 +1,58 @@
---
name: mutation-command
description: Add a write/submit operation — infrastructure adapter method plus an application command factory returning Result, keeping the reducer pure. Use for any POST/PUT/DELETE a form or action triggers.
---
# Mutation command
Reducer = "what the new state is"; command = "go do it, then say what happened".
The UI holds an application command, never the network client (lint-enforced).
## Skeleton
**Adapter method** (`<context>/infrastructure/<name>.adapter.ts`) — takes the
machine's `Valid` type, returns the server's answer, no parse (server is authority):
```ts
@Injectable({ providedIn: 'root' })
export class ChangeRequestAdapter {
private client = inject(ApiClient);
async changeRequest(data: Valid): Promise<string> { /* → server reference */ }
}
```
**Command factory** (`<context>/application/submit-<name>.ts`) — binds the adapter in
an injection context; `runSubmit` is the one try/catch + ProblemDetails mapping:
```ts
export function createSubmitChangeRequest() {
const adapter = inject(ChangeRequestAdapter);
return (data: Valid): Promise<Result<string, string>> =>
runSubmit(() => adapter.changeRequest(data), SUBMIT_FAILED);
}
```
**Caller** (organism, field initializer — same shape as `createStore`): on the
machine's `Submitting` state, run the command, then dispatch
`SubmitConfirmed` / `SubmitFailed { error }`. Never throw into the template.
## Optimistic updates on shared state
When a root store's view must reflect the write before the server confirms:
`begin*` (flip pending) → `confirm*` (clear + `resource.reload()`) / `rollback*`
(undo). See `src/app/registratie/application/big-profile.store.ts`.
## Worked examples
- `src/app/registratie/infrastructure/change-request.adapter.ts`
- `src/app/registratie/application/submit-change-request.ts`
- `src/app/registratie/application/draft-sync.ts``createDraftSync(...)` bundles
draft persistence + submit for wizard flows; prefer it when the form has a draft.
- `src/app/shared/application/submit.ts``runSubmit`, `SUBMIT_FAILED`.
## Verify
```bash
npm test && npm run lint && npm run build
# exercise the failure path in the browser: ?scenario=error on the submitting page
```

View File

@@ -0,0 +1,51 @@
---
name: new-context
description: Scaffold a new DDD bounded context (folders, path alias, eslint boundary rules, lazy route). Use when adding a new business capability that doesn't belong in an existing context.
---
# New bounded context
A context is a business **capability**, not a user group — a user group is an actor
that may span contexts (ADR-0002). Check first whether the capability belongs in an
existing context; new contexts are rare.
Naming: domain contexts are **Dutch** (`registratie`, `herregistratie`); only
shared/reusable code is English. The context name is the ubiquitous language term.
## Steps
1. **Folders**`src/app/<ctx>/{domain,application,infrastructure,ui}` (`contracts/`
only once it gets a wire seam). Empty layers can wait; don't scaffold placeholders.
2. **Path alias** — add `"@<ctx>/*": ["src/app/<ctx>/*"]` to `tsconfig.json` `paths`.
Aliases are direction statements; always import cross-context via the alias.
3. **eslint boundaries** (`eslint.config.mjs`) — dependencies point inward and
toward `shared` only. Copy the existing per-context block (the `brief` block is
the minimal leaf-context example) and:
- add a block for `src/app/<ctx>/**/*.ts` banning imports from every context it
may **not** depend on;
- add `@<ctx>/*` to the ban lists of `shared/**` and every context that must not
depend on the new one (grep the config for `@brief/*` to find all lists);
- the generic blocks (`domain/**` framework-free, `contracts/**` import-nothing,
ApiClient confinement, `ui/**` never imports `infrastructure`) match by glob
and cover the new context automatically.
4. **Route** — lazy child under the persistent shell in `app.routes.ts`:
```ts
{ path: '<ctx>', canActivate: [authGuard],
loadComponent: () => import('@<ctx>/ui/<ctx>.page').then(m => m.CtxPage) }
```
5. Build the first feature slice with the **new-feature** skill.
## Worked example
`src/app/brief/` — an independent leaf context (depends only on shared): see its
folder layout and its eslint block in `eslint.config.mjs`.
## Verify
```bash
npm run lint && npm run build
# prove the fence works: add a forbidden import (e.g. new ctx → @herregistratie/*),
# confirm lint fails, remove it.
```

View File

@@ -0,0 +1,45 @@
---
name: new-feature
description: Add a feature (screen, flow, capability) to this Angular SSP following the house pipeline — domain first, then infrastructure, application, UI last. Use whenever building anything user-facing that has business rules or data.
---
# New feature (the pipeline)
Every feature is built inward-out. Never start with the component.
## Steps
1. **Domain** (`<context>/domain/`, pure TS, no Angular imports — lint-enforced)
- Data types + pure rules. Forms/wizards get a `*.machine.ts` → use the **form-machine** skill.
- Raw input that must be valid → branded value object → use the **value-object** skill.
- Co-locate a `.spec.ts`; test the pure functions directly, no TestBed.
- Server-owned rules stay here only as reference impl + test, marked server-owned; the FE renders the server's decision (ADR-0001).
2. **Infrastructure** (`<context>/infrastructure/`, the only layer that touches HTTP)
- Read → screen-shaped endpoint + adapter → use the **bff-endpoint** skill.
- Write → command adapter returning `Result` → use the **mutation-command** skill.
3. **Application** (`<context>/application/`)
- Shared cross-page state → `providedIn: 'root'` store exposing `RemoteData` signals.
- Page-local flow state → `createStore(initial, reduce)` from `@shared/application/store`, held in the component.
- Side effects live in commands (`submit-*.ts`), never in the reducer.
4. **UI last** (`<context>/ui/`)
- A page is **composition of existing blocks**`<app-page-shell>`, `<app-async>`, atoms/molecules from `shared/ui`. Adding a new building block is the exception → **ui-component** skill.
- Templates dispatch messages, never mutate. Async data always renders through `<app-async>`.
- All copy via `$localize` with stable `@@context.key` ids.
- Route: lazy `loadComponent` under the `ShellComponent` parent in `app.routes.ts`, `canActivate: [authGuard]` when protected.
## Worked example
The intake wizard slice, end to end:
- `src/app/herregistratie/domain/intake.machine.ts` (+ spec)
- `src/app/herregistratie/infrastructure/`
- `src/app/herregistratie/ui/` (wizard component + page)
## Verify (GREEN gate)
```bash
npm run lint && npm run check:tokens && npm test && npm run build
cd backend && dotnet test # if the backend changed
```

View File

@@ -0,0 +1,55 @@
---
name: new-ssp
description: Bootstrap a new self-service portal from this repo as a template — what to keep, strip, rename, and re-seed. Use when starting a new SSP for a different domain/register.
---
# New SSP from this template
The template's value is the **enforced architecture** (layer fences, token gate,
a11y gate, API-drift gate) and the shared building blocks — not the BIG-register
business content. Keep the machinery, replace the domain.
## Keep as-is
- `src/app/shared/` — kernel (`fp.ts`), application (`remote-data`, `store`,
`submit`), ui atoms/molecules, layout templates, upload subtree.
- Tooling: `eslint.config.mjs`, `scripts/check-tokens.sh`, `.github/workflows/ci.yml`,
`nswag.json`, `.storybook/`, `proxy.conf.json`, `.npmrc` (`legacy-peer-deps`
and never `npm audit fix --force`, it downgrades Angular).
- `src/app/auth/` (fake auth shell) and `src/app/shared/infrastructure/scenario.interceptor.ts` (dev-only).
- `docs/architecture/` ADRs 00010003 — the decisions still apply; amend, don't delete.
- `CLAUDE.md`, `docs/ARCHITECTURE.md`, `docs/fp-tea-atomic-design.md` — update names/examples as contexts change.
- `.claude/skills/` — these recipes are the point of the template.
## Strip / replace
- Business contexts `registratie/`, `herregistratie/`, `brief/`, and `showcase/`:
delete or keep one slice temporarily as the worked example while building the
first real context (**new-context** + **new-feature** skills). If deleted, update
the worked-example paths in these skills to the new flagship context.
- `app.routes.ts` routes and `tsconfig.json` aliases for removed contexts, plus
their eslint blocks in `eslint.config.mjs`.
- Backend: keep the skeleton (`Program.cs` minimal-API style, ProblemDetails 422,
`X-Correlation-Id` audit line, `/api/v1` versioning, `Contracts/`/`Domain/`/`Data/`
split, test project) — replace `Data/SeedData.cs`, `Domain/*` rules, and
`Contracts/*` DTOs with the new domain's. Rename the solution/projects from
`BigRegister.*` (also update `package.json` `gen:api` and `ci.yml` paths).
- Regenerate the seam: `npm run gen:api` (commits `backend/swagger.json` +
`src/app/shared/infrastructure/api-client.ts`).
- Branding: `public/cibg-huisstijl/` + the token bridge in `src/styles.scss` — for a
different house style, swap the vendored CSS and re-point the `--rhc-*` bridge
(ADR-0003 pattern: bridge, don't rewrite tokens).
- `docs/backlog/` WPs, PRDs, and memory-specific docs — new portal, new backlog
(keep `docs/backlog/README.md`'s WP process/template if you like the workflow).
## Verify — the GREEN gate must pass at every step
```bash
npm run lint && npm run check:tokens && npm test && npm run build
npm run build-storybook && npm run test-storybook:ci
cd backend && dotnet test && cd ..
npm run gen:api && git diff --exit-code backend/swagger.json src/app/shared/infrastructure/api-client.ts
```
Strip incrementally and keep this green — the fences are only worth having if they
never go red.

View File

@@ -0,0 +1,53 @@
---
name: ui-component
description: Add a shared UI building block (atom, molecule, organism) with its Storybook story. Use only after confirming no existing block in shared/ui fits — new blocks are the exception, composition is the default.
---
# UI component (atom / molecule / organism)
First: check `shared/ui/` and `shared/layout/` — a new page should be composition of
existing blocks. Only add a block when nothing fits.
## Rules
- **Folder = atomic layer**: `shared/ui/` atoms → molecules → organisms;
`shared/layout/` templates. Each level only uses levels below.
- Standalone component, **English name** (shared = language-agnostic), signal
`input()`s only, `inject()` over constructor DI.
- **Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2) classes** (`btn`,
`form-control`, `card`, …) — you own a small typed `input()` API, the design system
owns the visuals. No CIBG class for it? Hand-roll a small surface from the token
bridge (ADR-0003, e.g. the `alert` atom).
- **Tokens only** — `var(--rhc-*)` / `var(--app-*)`, never hardcoded colors
(`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
`$localize` defaults; the domain caller supplies the text (see `shared/ui/async`).
- Components with content-projected slots export a spread constant so callers import
one thing: `export const ASYNC = [AsyncComponent, AsyncLoadedDirective, …] as const;`
## Story (required — this is the component's test)
Co-located `<name>.stories.ts`, title prefixed with the layer:
```ts
const meta: Meta<ButtonComponent> = { title: 'Atoms/Button', component: ButtonComponent };
export const Primary: StoryObj<ButtonComponent> = { args: { variant: 'primary' } };
```
One named export per meaningful state. CI runs axe on every story
(`test-storybook:ci`) — a11y failures break the build. Machines/wizards mount
specific states via the `Seed` message. No heavy component specs; Storybook is the
UI test surface.
## Worked examples
- Atom: `src/app/shared/ui/button/` — typed variant API over `btn` classes.
- Molecule: `src/app/shared/ui/async/` — slot directives, localizable input defaults, spread constant.
- Template: `src/app/shared/layout/wizard-shell/` — the canonical wizard outline.
## Verify
```bash
npm run lint && npm run check:tokens && npm run build
npm run storybook # eyeball the story; CI will run axe
```

View File

@@ -0,0 +1,55 @@
---
name: value-object
description: Add a validated input type (postcode, email, hours, id number, …) as a branded value object with a parser — "parse, don't validate". Use whenever a form field or API value has format rules.
---
# Value object (parse, don't validate)
Raw input becomes a branded type only via a parser returning `Result`. Once you hold
the type, never re-check it. Never model validity as a boolean flag next to a string.
## Skeleton
`<context>/domain/value-objects/<name>.ts` (pure TS, no Angular):
```ts
import { Brand, Result, ok, err } from '@shared/kernel/fp';
export type Postcode = Brand<string, 'Postcode'>;
export function parsePostcode(raw: string): Result<string, Postcode> {
const t = raw.trim().toUpperCase();
if (!/^[1-9]\d{3}\s?[A-Z]{2}$/.test(t)) {
return err($localize`:@@validation.postcode:Voer een geldige postcode in, bijv. 1234 AB.`);
}
// The parser also normalises — callers always hold the canonical form.
return ok(t.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') as Postcode);
}
```
Rules:
- The `as Brand` cast appears **only** inside the parser — the type is mintable nowhere else.
- Error message is user-facing → `$localize` with a stable `@@validation.<name>` id.
- Trim/normalise before testing; return the cleaned value.
- This is **format** feedback only. The server re-validates as authority (ADR-0001) — never encode business rules (existence, eligibility) here.
## Spec (required)
Co-located `<name>.spec.ts`: happy path, normalisation, each rejection case. Call the
parser directly — no TestBed.
## Wiring into a form
The machine's `validate(draft)` calls the parsers and collects errors into
`StepErrors`; the `Valid` type holds the branded values (see **form-machine** skill).
## Worked examples
`src/app/registratie/domain/value-objects/``postcode.ts`, `email.ts`, `uren.ts`,
`big-nummer.ts`, each with a co-located spec.
## Verify
```bash
npm test && npm run lint
```

View File

@@ -34,7 +34,8 @@ advisories are pinned via `package.json` `overrides`; the shipped bundle audits
### 1. DDD: contexts then layers, dependencies point inward
`src/app/<context>/<layer>/`. Contexts: `shared`, `auth`, `registratie`,
`herregistratie`, `showcase` (teaching page, not a feature).
`herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching
page, not a feature; **sanctioned** to read every context — nothing imports it).
| Layer | Job | Angular allowed? |
| ----------------- | ----------------------------------------- | -------------------------------- |
@@ -45,9 +46,11 @@ advisories are pinned via `package.json` `overrides`; the shipped bundle audits
| `ui/` | how it looks (components, pages) | yes |
**Dependencies only point inward**: `ui → application → domain`; everyone may use
`shared`; never the reverse. Cross-context only `herregistratie → registratie → shared`,
`auth → shared`. Imports use aliases as direction statements: `@shared/* @auth/*
@registratie/* @herregistratie/*`. `domain/` imports nothing from Angular.
`shared`; never the reverse. `ui`/`layout` never import `infrastructure` directly
(reach data through an application store/command) — lint-enforced. Cross-context only
`herregistratieregistratie → shared`, `auth → shared`, `brief → shared`. Imports use
aliases as direction statements: `@shared/* @auth/* @registratie/* @herregistratie/*
@brief/*`. `domain/` imports nothing from Angular.
### 2. Atomic design: folder = layer
@@ -158,6 +161,10 @@ Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter
union + pure reduce) → UI last (compose `shared/ui` atoms, wrap async in `<app-async>`,
dispatch messages). Worked example: the intake wizard (`herregistratie/`).
The recipes are also invocable skills in `.claude/skills/`: `new-feature`,
`new-context`, `value-object`, `form-machine`, `bff-endpoint`, `mutation-command`,
`ui-component`, `new-ssp` (bootstrap a new portal from this template).
## Out of scope (POC, don't build unprompted)
Real auth/DigiD, NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark),

View File

@@ -16,7 +16,7 @@ re-registration — "herregistratie").
---
## 1. The big picture: three "contexts", four "layers"
## 1. The big picture: six "contexts", five "layers"
The code is split first by **business area** (a "bounded context" in DDD terms),
then inside each area by **layer**.
@@ -27,9 +27,14 @@ src/app/
auth/ logging in / the current session
registratie/ the user's BIG registration + personal data
herregistratie/ the re-registration application flow
showcase/ a teaching page; not a real feature
brief/ letter-composition teaching slice
showcase/ a teaching page; not a real feature (may read every context)
```
`showcase/` is a **sanctioned exception** to the direction rules: its whole point is
showing multiple contexts side by side, so it may import any context. Nothing imports
`showcase`. (Enforced in `eslint.config.mjs`; same precedent as the `debug-state` panel.)
### The atomic-design hierarchy, visualised
The UI is built bottom-up: tiny **atoms** combine into **molecules**, which combine
@@ -53,7 +58,7 @@ organism** (`intake-wizard`) — everything else (`form-field`, `text-input`, `b
`alert`, `spinner`, the page shell) was reused unchanged. That is the payoff of the
hierarchy.
Inside a context you'll see the same four folders. They answer four different
Inside a context you'll see the same five folders. They answer five different
questions:
| Layer | Answers… | May import Angular? | Example here |
@@ -61,14 +66,18 @@ questions:
| `domain/` | What are the business rules and data? | **No** (pure TS) | `registration.ts`, `registration.policy.ts` |
| `application/` | How do we coordinate a task / state? | Yes (signals) | `big-profile.store.ts` |
| `infrastructure/` | Where does data come from? | Yes (HTTP) | `big-register.adapter.ts`, `brp.adapter.ts` |
| `contracts/` | What's the FE⇄BE wire shape? | **No** (pure DTOs) | `dashboard-view.dto.ts` |
| `ui/` | How does it look? | Yes (components) | `dashboard.page.ts` |
**The one rule that keeps it sane: dependencies only point _inward_.** UI may use
application, application may use domain, everyone may use `shared`. Never the
other way around. The `domain/` layer imports nothing from Angular, so the
business rules are plain functions you can read and test in isolation.
other way around. In particular **`ui/` and `layout/` never import `infrastructure/`
directly** — they reach data through an application store or command (lint-enforced).
The `domain/` layer imports nothing from Angular, so the business rules are plain
functions you can read and test in isolation.
Allowed direction: `herregistratie → registratie → shared`, `auth → shared`.
Allowed direction: `herregistratie → registratie → shared`, `auth → shared`,
`brief → shared` (`showcase` may read every context; see above).
### Why the `shared/` kernel is split too
@@ -79,7 +88,7 @@ Allowed direction: `herregistratie → registratie → shared`, `auth → shared
- `shared/infrastructure/` — the demo HTTP interceptor.
Imports use path aliases so they read as direction statements:
`@shared/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`.
`@shared/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`, `@brief/*`.
---

View File

@@ -43,15 +43,15 @@ for its existing violations, so every WP ends green.
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | todo |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | todo |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo |
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo |
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | todo |
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo |
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | todo |
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | todo |
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | todo |
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | todo |
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | todo |
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | todo |

View File

@@ -1,6 +1,6 @@
# WP-04 — Boundaries II: `ui ↛ infrastructure` + showcase sanction
Status: todo
Status: done (035e785)
Phase: 0 — enforcement & gates
## Why
@@ -54,11 +54,13 @@ Phase 0 — a pure move of wiring, no behavior change.
## Acceptance criteria
- [ ] Rule active; lint green; no disables beyond the documented showcase + debug-state
exemptions.
- [ ] No `**/ui/**` file imports from `**/infrastructure/**`.
- [ ] Both wizards behave unchanged (specs pass; manual smoke).
- [ ] CLAUDE.md and ARCHITECTURE.md list 6 contexts / 5 layers.
- [x] Rule active; lint green; no disables beyond the documented showcase + debug-state
exemptions. (Probed: a ui→infra import errors.)
- [x] No `**/ui/**` file imports from `**/infrastructure/**` (production; stories/specs
exempted — test scaffolding wires the real client).
- [x] Both wizards behave unchanged (178 specs pass; storybook a11y suite mounts both
wizards green — behaviour is a pure wiring move behind root facades).
- [x] CLAUDE.md and ARCHITECTURE.md list 6 contexts / 5 layers.
## Verification

View File

@@ -1,8 +1,15 @@
# WP-11 — CIBG markup fidelity: application-link + absent-class triage
Status: todo
Status: done (98fd7e4)
Phase: 2 — CIBG fidelity
> Done as part of the "CIBG UI fidelity pass" (user-requested, out of order).
> application-link now uses the real `.dashboard-block.applications li a` chain via a
> `li[app-application-link]` attribute selector (native `<li>` child, axe-clean); the
> invented `.application`/`.application-title` classes are gone (grep gate clean). The
> dashboard "Mijn aanvragen" renders as the CIBG Aanvragen component. Remaining
> absent-class triage for non-aanvragen components stays with WP-13's gap register.
## Why
`application-link.component.ts` invents `.application` / `.application-title` — absent

View File

@@ -1,8 +1,14 @@
# WP-12 — CIBG Datablock for application data
Status: todo
Status: done (82fc3c4)
Phase: 2 — CIBG fidelity
> Done as part of the "CIBG UI fidelity pass" (user-requested, out of order). New
> `app-data-block` molecule wraps `.data-block`/`.block-wrapper`; `data-row` moved to a
> `div[app-data-row]` attribute selector so the `<dl>`'s child is a native `<div>`
> (axe-clean — fixed a live definition-list defect). review-section folded on;
> registration-summary + dashboard BRP block dropped `app-card` for the datablock.
## Why
CIBG documents **Datablock** (designsystem.cibg.nl/componenten/datablock/) as THE way to

View File

@@ -3,6 +3,17 @@
Status: todo
Phase: 2 — CIBG fidelity
> **Correction (CIBG UI fidelity pass, b5c5d30):** this WP assumed the `upload/` suite
> had no vendored CIBG classes and would be marked as a CIBG-gap ("Bestand-upload").
> The vendored build actually ships a full upload vocabulary (`.file-picker-drop-area`,
> `ul.file-list`, `.file-container`, `.file-name`/`.file-meta`, `.btn-upload`,
> `.upload-validation`), so the suite was **reworked to wrap those classes** instead —
> it is no longer a gap to mark. WP-13's remaining register still covers skeleton/
> spinner, rich-text-editor, wizard-shell, confirmation, card (`.app-card`),
> status-badge, placeholder-chip, etc. (Note: `application-link`'s non-navigating
> `.static-row` mirrors the aanvragen card surface from tokens — a small marked-gap
> candidate.)
## Why
User decision: hand-rolled token-bridge components are allowed **only if explicitly

File diff suppressed because one or more lines are too long

View File

@@ -143,4 +143,44 @@ export default [
plugins: { '@typescript-eslint': tseslint.plugin },
rules: { '@typescript-eslint/no-restricted-imports': 'off' },
},
// ui/ and layout/ are the presentation layer: dependencies point inward
// (ui → application → domain, CLAUDE.md §1), so they must NOT import
// infrastructure/ directly — they reach data through an application store or
// command. (Stories/specs are test scaffolding and may wire the real client.)
// Uses the @typescript-eslint variant so it composes with the base
// no-restricted-imports context-direction rules above (last-wins is per rule name).
{
files: ['src/app/**/ui/**/*.ts', 'src/app/**/layout/**/*.ts'],
ignores: ['**/*.stories.ts', '**/*.spec.ts'],
plugins: { '@typescript-eslint': tseslint.plugin },
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
{
group: [
'@shared/infrastructure/*',
'@auth/infrastructure/*',
'@registratie/infrastructure/*',
'@herregistratie/infrastructure/*',
'@brief/infrastructure/*',
],
allowTypeImports: true,
message: 'ui/ and layout/ must not import infrastructure/ directly (CLAUDE.md §1: ui → application → domain). Reach data through an application store or command. (Type-only DTO imports are fine: use `import type`.)',
},
],
},
],
},
},
// Sanctioned exception: showcase/ is the teaching page whose whole point is showing
// multiple contexts side by side (ARCHITECTURE.md §6). It may read every context;
// nothing imports showcase. Same precedent as the shared/ui/debug-state exemption.
{
files: ['src/app/showcase/**/*.ts'],
rules: { 'no-restricted-imports': 'off' },
},
];

View File

@@ -1,5 +1,6 @@
import { ApplicationConfig, LOCALE_ID, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withViewTransitions } from '@angular/router';
import type { ActivatedRouteSnapshot } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { registerLocaleData } from '@angular/common';
import localeNl from '@angular/common/locales/nl';
@@ -16,7 +17,26 @@ registerLocaleData(localeNl);
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withViewTransitions()),
provideRouter(
routes,
// 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] : [])),

View File

@@ -11,6 +11,7 @@ export const routes: Routes = [
{ path: 'login', loadComponent: () => import('@auth/ui/login.page').then(m => m.LoginPage) },
{ path: 'dashboard', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/dashboard.page').then(m => m.DashboardPage) },
{ path: 'registratie', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registration-detail.page').then(m => m.RegistrationDetailPage) },
{ path: 'aanvraag/:id', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/aanvraag-detail.page').then(m => m.AanvraagDetailPage) },
{ path: 'registreren', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registratie.page').then(m => m.RegistratiePage) },
{ path: 'herregistratie', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage) },
{ path: 'intake', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/intake.page').then(m => m.IntakePage) },

View File

@@ -0,0 +1,19 @@
import { Injectable, computed, inject } from '@angular/core';
import { SCHOLING_THRESHOLD_DEFAULT } from '../domain/intake.machine';
import { IntakePolicyAdapter } from '../infrastructure/intake-policy.adapter';
/**
* Application-layer facade for the server-owned intake policy (the scholing
* threshold config value). It owns the httpResource (created here, in the required
* injection context) and exposes the threshold as a derived signal, falling back to
* the domain default until the backend answers. The UI reaches the network through
* application/, never infrastructure/ directly (CLAUDE.md §1). The backend stays the
* authority and re-validates on submit.
*/
@Injectable({ providedIn: 'root' })
export class IntakePolicyStore {
private policy = inject(IntakePolicyAdapter);
private policyRes = this.policy.policyResource();
readonly scholingThreshold = computed(() => this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT);
}

View File

@@ -25,7 +25,7 @@ import {
SCHOLING_THRESHOLD_DEFAULT,
} from '@herregistratie/domain/intake.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-policy.adapter';
import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
@@ -88,21 +88,21 @@ import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-polic
<app-review-section i18n-heading="@@intake.sectie.buitenland" heading="Buitenland"
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria" editAriaLabel="Wijzigen buitenland"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })">
<app-data-row i18n-key="@@intake.review.buitenNl" key="Buiten NL gewerkt" [value]="answers().buitenlandGewerkt ?? '—'" />
<div app-data-row i18n-key="@@intake.review.buitenNl" key="Buiten NL gewerkt" [value]="answers().buitenlandGewerkt ?? '—'"></div>
@if (answers().buitenlandGewerkt === 'ja') {
<app-data-row i18n-key="@@intake.review.land" key="Land" [value]="answers().land ?? ''" />
<app-data-row i18n-key="@@intake.review.buitenlandseUren" key="Buitenlandse uren" [value]="answers().buitenlandseUren ?? ''" />
<div app-data-row i18n-key="@@intake.review.land" key="Land" [value]="answers().land ?? ''"></div>
<div app-data-row i18n-key="@@intake.review.buitenlandseUren" key="Buitenlandse uren" [value]="answers().buitenlandseUren ?? ''"></div>
}
</app-review-section>
<app-review-section class="app-section" i18n-heading="@@intake.sectie.werk" heading="Werk in Nederland"
i18n-editAriaLabel="@@intake.werkWijzigenAria" editAriaLabel="Wijzigen werk in Nederland"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })">
<app-data-row i18n-key="@@intake.review.urenNl" key="Uren NL" [value]="answers().uren ?? ''" />
<div app-data-row i18n-key="@@intake.review.urenNl" key="Uren NL" [value]="answers().uren ?? ''"></div>
@if (scholingZichtbaar()) {
<app-data-row i18n-key="@@intake.review.scholing" key="Aanvullende scholing" [value]="answers().scholingGevolgd ?? ''" />
<div app-data-row i18n-key="@@intake.review.scholing" key="Aanvullende scholing" [value]="answers().scholingGevolgd ?? ''"></div>
}
@if (answers().scholingGevolgd === 'ja') {
<app-data-row i18n-key="@@intake.review.punten" key="Nascholingspunten" [value]="answers().punten ?? ''" />
<div app-data-row i18n-key="@@intake.review.punten" key="Nascholingspunten" [value]="answers().punten ?? ''"></div>
}
</app-review-section>
}
@@ -120,13 +120,11 @@ import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-polic
})
export class IntakeWizardComponent {
private profile = inject(BigProfileStore);
private policy = inject(IntakePolicyAdapter);
// Server-owned policy (scholing threshold): fetched from the backend via the
// application facade, not hardcoded. The backend stays the authority on submit.
private policyStore = inject(IntakePolicyStore);
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
// Server-owned policy: the scholing threshold is fetched from the backend, not
// hardcoded. The backend stays the authority and re-validates on submit.
private policyRes = this.policy.policyResource();
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<IntakeState>(initial);
@@ -201,7 +199,7 @@ export class IntakeWizardComponent {
// only the policy value; untrack the dispatch (it reads the state signal
// internally, which would otherwise make this effect loop on its own write).
effect(() => {
const scholingThreshold = this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT;
const scholingThreshold = this.policyStore.scholingThreshold();
untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));
});
}

View File

@@ -0,0 +1,58 @@
import { Injectable, computed, inject } from '@angular/core';
import { RemoteData, fromResource } from '@shared/application/remote-data';
import { DuoLookupDto } from '../contracts/duo-diplomas.dto';
import { BrpAdapter, parseBrpAddress } from '../infrastructure/brp.adapter';
import { DuoAdapter, parseDuoLookup } from '../infrastructure/duo.adapter';
type Err = Error | undefined;
/**
* Application-layer facade for the registratie wizard's two lookups (BRP address,
* DUO diplomas). It owns the httpResources (created here, in the required injection
* context), runs the trust-boundary parse, and exposes RemoteData / derived signals
* — so the UI reaches the network through application/, never infrastructure/
* directly (CLAUDE.md §1: ui → application → domain). Same facade shape as
* BigProfileStore.
*/
@Injectable({ providedIn: 'root' })
export class RegistratieLookupStore {
private brp = inject(BrpAdapter);
private duo = inject(DuoAdapter);
private adresRes = this.brp.adresResource();
private diplomasRes = this.duo.diplomasResource();
/** BRP lookup outcome. A failure or "geen adres" never blocks the wizard — both
fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a
malformed response; 'fout' is an unreachable BRP. */
readonly adresStatus = computed<'laden' | 'gevonden' | 'geen' | 'fout'>(() => {
const st = this.adresRes.status();
if (st === 'loading' || st === 'reloading') return 'laden';
if (st === 'error') return 'fout';
const json = this.adresRes.value();
const parsed = json !== undefined ? parseBrpAddress(json) : null;
return parsed && parsed.ok && parsed.value.gevonden ? 'gevonden' : 'geen';
});
/** The address to prefill the draft with, once BRP resolves with a found address;
null otherwise (loading, error, no address, malformed). */
readonly prefillAdres = computed<{ straat: string; postcode: string; woonplaats: string } | null>(() => {
const json = this.adresRes.value();
if (json === undefined) return null;
const parsed = parseBrpAddress(json);
return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null;
});
/** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */
readonly duoLookup = computed<RemoteData<Err, DuoLookupDto>>(() => {
const rd = fromResource(this.diplomasRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseDuoLookup(rd.value);
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
});
/** Reload the BRP lookup (e.g. when the wizard restarts) so the address re-prefills. */
reloadAdres() {
this.adresRes.reload();
}
}

View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { submittedRow, detailRows, purposeLabel, statusLabel, TYPE_LABELS } from './aanvraag-view';
import { Aanvraag } from './aanvraag';
const base = { id: '1', type: 'herregistratie' as const, documentIds: [], createdAt: '', updatedAt: '', submittedAt: '2024-05-12' };
describe('submittedRow', () => {
it('heading is the type, subtitle is the purpose', () => {
const row = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: false } } as Aanvraag);
expect(row.heading).toBe(TYPE_LABELS.herregistratie);
expect(row.subtitle).toBe(purposeLabel('herregistratie'));
});
it('status line carries the status label, reference and submit date', () => {
const row = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: false } } as Aanvraag);
expect(row.status).toContain(statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }));
expect(row.status).toContain('R1');
expect(row.status).toContain('12 mei 2024');
});
it('manual review adds a note; rejection adds its reason', () => {
const manual = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: true } } as Aanvraag);
expect(manual.status).toContain('handmatig');
const rejected = submittedRow({ ...base, status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' } } as Aanvraag);
expect(rejected.status).toContain('Onvoldoende uren');
});
});
describe('detailRows', () => {
it('lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected', () => {
const rows = detailRows({ ...base, status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' } } as Aanvraag);
const values = rows.map((r) => r.value);
expect(values).toContain(TYPE_LABELS.herregistratie);
expect(values).toContain('R2');
expect(values).toContain('Onvoldoende uren');
expect(rows.length).toBe(6);
});
it('reference falls back to em dash for a Concept', () => {
const rows = detailRows({ ...base, submittedAt: undefined, status: { tag: 'Concept', stepIndex: 0, stepCount: 3 } } as Aanvraag);
const ref = rows.find((r) => r.value === '—');
expect(ref).toBeTruthy();
expect(rows.length).toBe(5);
});
});

View File

@@ -0,0 +1,75 @@
import { Aanvraag, AanvraagStatus, AanvraagType } from './aanvraag';
/** View-model mapping for an aanvraag: type → labels, status → label, and the fields
for a CIBG "aanvragen" row / the case-detail page. Pure, no Angular — the UI
renders these, it does not derive them. */
export const TYPE_LABELS: Record<AanvraagType, string> = {
registratie: $localize`:@@aanvraagBlock.type.registratie:Inschrijving`,
herregistratie: $localize`:@@aanvraagBlock.type.herregistratie:Herregistratie`,
intake: $localize`:@@aanvraagBlock.type.intake:Herregistratie-intake`,
};
/** What the aanvraag is for (shown under the title). */
export function purposeLabel(type: AanvraagType): string {
switch (type) {
case 'registratie': return $localize`:@@aanvraag.purpose.registratie:Inschrijving in het BIG-register`;
case 'herregistratie': return $localize`:@@aanvraag.purpose.herregistratie:Verlenging van uw BIG-registratie`;
case 'intake': return $localize`:@@aanvraag.purpose.intake:Intake-vragenlijst voor uw herregistratie`;
}
}
/** The status as a plain label (what state the aanvraag is in). */
export function statusLabel(status: AanvraagStatus): string {
switch (status.tag) {
case 'Concept': return $localize`:@@aanvraag.status.concept:Concept (nog niet ingediend)`;
case 'InBehandeling': return $localize`:@@aanvraag.status.inBehandeling:In behandeling`;
case 'Goedgekeurd': return $localize`:@@aanvraag.status.goedgekeurd:Goedgekeurd`;
case 'Afgewezen': return $localize`:@@aanvraag.status.afgewezen:Afgewezen`;
}
}
/** The reference number, or '' for a Concept (which has none yet). */
export function referentie(status: AanvraagStatus): string {
return status.tag === 'Concept' ? '' : status.referentie;
}
export interface AanvraagRow {
heading: string;
/** What the aanvraag is for (the `.subtitle` line). */
subtitle: string;
/** The status: label + reference + submit date (+ any note) — the `.status` line. */
status: string;
}
function formatNL(iso?: string): string {
return iso ? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
}
/** Fields for a submitted aanvraag's row in the dashboard "aanvragen" list (Concept
has no row — it renders as a resumable melding, see aanvraag-block). */
export function submittedRow(a: Aanvraag): AanvraagRow {
const s = a.status;
const parts = [statusLabel(s)];
const ref = referentie(s);
if (ref) parts.push($localize`:@@aanvraag.row.ref:Referentie ${ref}:ref:`);
if (a.submittedAt) parts.push($localize`:@@aanvraag.row.ingediend:ingediend op ${formatNL(a.submittedAt)}:datum:`);
if (s.tag === 'InBehandeling' && s.manual) parts.push($localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`);
if (s.tag === 'Afgewezen') parts.push(s.reden);
return { heading: TYPE_LABELS[a.type], subtitle: purposeLabel(a.type), status: parts.join(' · ') };
}
/** Key/value rows for the case-detail page (CIBG Datablock). */
export function detailRows(a: Aanvraag): { key: string; value: string }[] {
const rows = [
{ key: $localize`:@@aanvraag.detail.soort:Soort aanvraag`, value: TYPE_LABELS[a.type] },
{ key: $localize`:@@aanvraag.detail.waarvoor:Waarvoor`, value: purposeLabel(a.type) },
{ key: $localize`:@@aanvraag.detail.status:Status`, value: statusLabel(a.status) },
{ key: $localize`:@@aanvraag.detail.referentie:Referentie`, value: referentie(a.status) || '—' },
{ key: $localize`:@@aanvraag.detail.ingediend:Ingediend op`, value: a.submittedAt ? formatNL(a.submittedAt) : '—' },
];
if (a.status.tag === 'Afgewezen') {
rows.push({ key: $localize`:@@aanvraag.detail.reden:Reden van afwijzing`, value: a.status.reden });
}
return rows;
}

View File

@@ -1,28 +1,19 @@
import { Component, computed, input, output } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ChoiceLinkComponent } from '@shared/ui/choice-link/choice-link.component';
import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag';
import { Aanvraag } from '@registratie/domain/aanvraag';
import { TYPE_LABELS } from '@registratie/domain/aanvraag-view';
import { blockActions } from '@registratie/domain/block-actions';
const TYPE_LABELS: Record<AanvraagType, string> = {
registratie: $localize`:@@aanvraagBlock.type.registratie:Inschrijving`,
herregistratie: $localize`:@@aanvraagBlock.type.herregistratie:Herregistratie`,
intake: $localize`:@@aanvraagBlock.type.intake:Herregistratie-intake`,
};
/** Organism: one application on the dashboard. A resumable Concept ("lopende
aanvraag") renders as a CIBG "melding" (warning) with its actions — verwijderen
as a link, aanvraag openen as a button; a submitted/resolved status renders as a
plain keuzelijst card. Which actions show is driven by the pure
`blockActions(status)`; the block only renders. NOTE: the caller places the two
shapes differently — a Concept melding is a block element, the rest are `<li>`s
that must sit inside a keuzelijst `<ul>` (see dashboard.page.ts). */
/** Organism: a resumable Concept ("lopende aanvraag") on the dashboard, rendered as
a CIBG "melding" (warning) with its actions — verwijderen as a link, aanvraag
openen as a button. Which actions show is driven by the pure `blockActions(status)`;
the block only renders. Submitted/resolved aanvragen are NOT rendered here — they
render as CIBG "aanvragen" rows (see application-link + aanvraag-view). */
@Component({
selector: 'app-aanvraag-block',
imports: [ButtonComponent, AlertComponent, ChoiceLinkComponent],
imports: [ButtonComponent, AlertComponent],
styles: [`
:host{display:contents} /* see choice-link.component.ts (keeps <li> a direct <ul> child) */
.actions{display:flex;align-items:center;gap:var(--rhc-space-max-md);margin-block-start:var(--rhc-space-max-sm)}
`],
template: `
@@ -39,8 +30,6 @@ const TYPE_LABELS: Record<AanvraagType, string> = {
}
</div>
</app-alert>
} @else {
<app-choice-link [heading]="typeLabel()" [instructions]="instructions()" />
}
`,
})
@@ -67,30 +56,6 @@ export class AanvraagBlockComponent {
if (s.tag !== 'Concept') return '';
return $localize`:@@aanvraagBlock.conceptMelding:Deze aanvraag is nog niet volledig afgerond — u bent gebleven bij stap ${s.stepIndex + 1}:stap: van ${s.stepCount}:totaal:. Rond de aanvraag af vóór ${formatNL(this.deadline())}:datum:.`;
});
// Status-tag → the choice's description line (the UI's mapping, not a domain rule).
private statusText = computed(() => {
const s = this.aanvraag().status;
switch (s.tag) {
case 'Concept': return '';
case 'InBehandeling': return $localize`:@@aanvraagBlock.inBehandeling:Referentie ${s.referentie}:ref: · ingediend op ${formatNL(this.aanvraag().submittedAt)}:datum:`;
case 'Goedgekeurd': return $localize`:@@aanvraagBlock.goedgekeurd:Referentie ${s.referentie}:ref:`;
case 'Afgewezen': return $localize`:@@aanvraagBlock.afgewezen:Referentie ${s.referentie}:ref:`;
}
});
// Secondary note appended to the status line, when there's one to show.
private subtitle = computed(() => {
const s = this.aanvraag().status;
if (s.tag === 'InBehandeling' && s.manual) return $localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`;
if (s.tag === 'Afgewezen') return s.reden;
return '';
});
// The keuzelijst has one description paragraph — combine status + secondary note.
protected instructions = computed(() => {
const status = this.statusText();
const sub = this.subtitle();
return sub ? `${status} ${sub}` : status;
});
}
function formatNL(iso?: string): string {

View File

@@ -0,0 +1,50 @@
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { ApplicationsStore } from '@registratie/application/applications.store';
import { Aanvraag } from '@registratie/domain/aanvraag';
import { detailRows } from '@registratie/domain/aanvraag-view';
/** Page: a single aanvraag ("case"). Stub — it renders the aanvraag's known fields
(soort, waarvoor, status, referentie, ingediend) in a CIBG Datablock; full case
handling is future work. The dashboard "Mijn aanvragen" rows link here. */
@Component({
selector: 'app-aanvraag-detail-page',
imports: [PageShellComponent, SkeletonComponent, AlertComponent, DataBlockComponent, DataRowComponent, ...ASYNC],
template: `
<app-page-shell i18n-heading="@@aanvraagDetail.heading" heading="Aanvraag" backLink="/dashboard">
<app-async [data]="store.applications()">
<ng-template appAsyncLoaded let-list>
@let a = find($any(list));
@if (a) {
<app-data-block i18n-ariaLabel="@@aanvraagDetail.ariaLabel" ariaLabel="Aanvraaggegevens">
@for (row of rows(a); track row.key) {
<div app-data-row [key]="row.key" [value]="row.value"></div>
}
</app-data-block>
<app-alert class="app-section" type="info" i18n="@@aanvraagDetail.stub">
De volledige afhandeling van deze aanvraag is nog niet beschikbaar in deze POC.
</app-alert>
} @else {
<app-alert type="warning" i18n="@@aanvraagDetail.nietGevonden">Deze aanvraag is niet gevonden.</app-alert>
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="5" />
</ng-template>
</app-async>
</app-page-shell>
`,
})
export class AanvraagDetailPage {
protected store = inject(ApplicationsStore);
private id = inject(ActivatedRoute).snapshot.paramMap.get('id') ?? '';
protected find = (list: Aanvraag[]): Aanvraag | undefined => list.find((a) => a.id === this.id);
protected rows = detailRows;
}

View File

@@ -5,11 +5,10 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { CardComponent } from '@shared/ui/card/card.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { TaskListComponent } from '@shared/ui/task-list/task-list.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
import { ChoiceListComponent } from '@shared/ui/choice-list/choice-list.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';
@@ -18,6 +17,7 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
import { ApplicationsStore } from '@registratie/application/applications.store';
import { Registration } from '@registratie/domain/registration';
import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag';
import { submittedRow } from '@registratie/domain/aanvraag-view';
import { tasksFromProfile } from '@registratie/domain/tasks';
/** Page:"Mijn overzicht" — the portal home, following the NL Design System
@@ -26,8 +26,8 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
selector: 'app-dashboard-page',
imports: [
PageShellComponent, HeadingComponent, AlertComponent, SkeletonComponent,
DataRowComponent, CardComponent, TaskListComponent, ApplicationListComponent, ApplicationLinkComponent,
ChoiceListComponent, ...ASYNC,
DataRowComponent, DataBlockComponent, TaskListComponent, ApplicationListComponent, ApplicationLinkComponent,
...ASYNC,
RegistrationSummaryComponent, RegistrationTableComponent, AanvraagBlockComponent,
],
template: `
@@ -39,11 +39,13 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
<app-aanvraag-block animate.enter="app-item-enter" animate.leave="app-item-leave" [aanvraag]="a" (resume)="resume(a)" (cancel)="cancelAanvraag(a)" />
}
@if (ingediend().length) {
<app-choice-list i18n-heading="@@dashboard.mijnAanvragen" heading="Mijn aanvragen" class="app-section">
<app-heading [level]="2" class="app-section" i18n="@@dashboard.mijnAanvragen">Mijn aanvragen</app-heading>
<app-application-list>
@for (a of ingediend(); track a.id) {
<app-aanvraag-block animate.enter="app-item-enter" animate.leave="app-item-leave" [aanvraag]="a" />
@let row = submittedRow(a);
<li app-application-link animate.enter="app-item-enter" animate.leave="app-item-leave" [heading]="row.heading" [subtitle]="row.subtitle" [status]="row.status" [to]="'/aanvraag/' + a.id"></li>
}
</app-choice-list>
</app-application-list>
}
</section>
}
@@ -70,13 +72,11 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
<div class="app-section">
<app-registration-summary [reg]="$any(p).registration" />
</div>
<app-card class="app-section" i18n-heading="@@dashboard.persoonsgegevens" heading="Persoonsgegevens (BRP)">
<dl class="row mb-0">
<app-data-row i18n-key="@@dashboard.straat" key="Straat" [value]="$any(p).person.adres.straat" />
<app-data-row i18n-key="@@dashboard.postcode" key="Postcode" [value]="$any(p).person.adres.postcode" />
<app-data-row i18n-key="@@dashboard.woonplaats" key="Woonplaats" [value]="$any(p).person.adres.woonplaats" />
</dl>
</app-card>
<app-data-block class="app-section" i18n-heading="@@dashboard.persoonsgegevens" heading="Persoonsgegevens (BRP)">
<div app-data-row i18n-key="@@dashboard.straat" key="Straat" [value]="$any(p).person.adres.straat"></div>
<div app-data-row i18n-key="@@dashboard.postcode" key="Postcode" [value]="$any(p).person.adres.postcode"></div>
<div app-data-row i18n-key="@@dashboard.woonplaats" key="Woonplaats" [value]="$any(p).person.adres.woonplaats"></div>
</app-data-block>
</section>
</ng-template>
<ng-template appAsyncLoading>
@@ -105,7 +105,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
<app-heading [level]="2" i18n="@@dashboard.watWiltUDoen">Wat wilt u doen?</app-heading>
<app-application-list class="app-section">
@for (a of acties; track a.to) {
<app-application-link [heading]="a.titel" [subtitle]="a.tekst" [cta]="a.actie" [to]="a.to" />
<li app-application-link [heading]="a.titel" [subtitle]="a.tekst" [cta]="a.actie" [to]="a.to"></li>
}
</app-application-list>
</section>
@@ -118,6 +118,9 @@ export class DashboardPage {
private apps = inject(ApplicationsStore);
private router = inject(Router);
/** Pure view mapping for a submitted aanvraag → CIBG aanvragen-row fields. */
protected submittedRow = submittedRow;
constructor() {
// Re-fetch on each visit so server-computed auto-approval transitions show up
// (Concept → In behandeling → Goedgekeurd after the processing window).

View File

@@ -14,9 +14,8 @@ import { ASYNC } from '@shared/ui/async/async.component';
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { RemoteData, fromResource } from '@shared/application/remote-data';
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';
import { RemoteData } from '@shared/application/remote-data';
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
import {
RegistratieState,
@@ -123,8 +122,8 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
[ngModel]="draft().beroep ?? ''" (ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })" />
</app-form-field>
} @else if (draft().beroep) {
<dl class="row mb-0 app-section">
<app-data-row i18n-key="@@regWizard.beroepAfgeleid" key="Beroep (afgeleid uit diploma)" [value]="draft().beroep ?? ''" />
<dl class="mb-0 app-section">
<div app-data-row i18n-key="@@regWizard.beroepAfgeleid" key="Beroep (afgeleid uit diploma)" [value]="draft().beroep ?? ''"></div>
</dl>
}
@@ -163,20 +162,20 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
<app-review-section i18n-heading="@@regWizard.sectie.adres" heading="Adres en correspondentie"
i18n-editAriaLabel="@@regWizard.adresWijzigenAria" editAriaLabel="Wijzigen adresgegevens"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })">
<app-data-row i18n-key="@@regWizard.summary.adres" key="Adres" [value]="adresSamenvatting()" />
<app-data-row i18n-key="@@regWizard.summary.herkomstAdres" key="Herkomst adres" [value]="adresHerkomstLabel()" />
<app-data-row i18n-key="@@regWizard.summary.correspondentie" key="Correspondentie" [value]="correspondentieLabel()" />
<div app-data-row i18n-key="@@regWizard.summary.adres" key="Adres" [value]="adresSamenvatting()"></div>
<div app-data-row i18n-key="@@regWizard.summary.herkomstAdres" key="Herkomst adres" [value]="adresHerkomstLabel()"></div>
<div app-data-row i18n-key="@@regWizard.summary.correspondentie" key="Correspondentie" [value]="correspondentieLabel()"></div>
@if (draft().correspondentie === 'email') {
<app-data-row i18n-key="@@regWizard.summary.email" key="E-mailadres" [value]="draft().email ?? ''" />
<div app-data-row i18n-key="@@regWizard.summary.email" key="E-mailadres" [value]="draft().email ?? ''"></div>
}
</app-review-section>
<app-review-section class="app-section" i18n-heading="@@regWizard.sectie.beroep" heading="Beroep en diploma"
i18n-editAriaLabel="@@regWizard.diplomaWijzigenAria" editAriaLabel="Wijzigen beroep en diploma"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })">
<app-data-row i18n-key="@@regWizard.summary.beroep" key="Beroep" [value]="draft().beroep ?? ''" />
<app-data-row i18n-key="@@regWizard.summary.herkomstDiploma" key="Herkomst diploma" [value]="diplomaHerkomstLabel()" />
<div app-data-row i18n-key="@@regWizard.summary.beroep" key="Beroep" [value]="draft().beroep ?? ''"></div>
<div app-data-row i18n-key="@@regWizard.summary.herkomstDiploma" key="Herkomst diploma" [value]="diplomaHerkomstLabel()"></div>
@for (item of samenvattingVragen(); track item.vraag) {
<app-data-row [key]="item.vraag" [value]="item.antwoord" />
<div app-data-row [key]="item.vraag" [value]="item.antwoord"></div>
}
</app-review-section>
}
@@ -194,8 +193,7 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
`,
})
export class RegistratieWizardComponent {
private brp = inject(BrpAdapter);
private duo = inject(DuoAdapter);
private lookup = inject(RegistratieLookupStore);
private uploadAdapter = inject(UploadAdapter);
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
@@ -204,9 +202,6 @@ export class RegistratieWizardComponent {
protected previewUrlFor = (documentId: string): string | undefined =>
documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
protected adresRes = this.brp.adresResource();
private diplomasRes = this.duo.diplomasResource();
/** Optional seed so Storybook / tests can mount any state directly. */
seed = input<RegistratieState>(initial);
@@ -286,25 +281,10 @@ export class RegistratieWizardComponent {
protected correspondentieLabel = computed(() => ({ email: $localize`:@@regWizard.corr.email:Per e-mail`, post: $localize`:@@regWizard.corr.post:Per post` }[this.draft().correspondentie ?? 'post']));
protected diplomaHerkomstLabel = computed(() => ({ duo: $localize`:@@regWizard.herkomst.diplomaDuo:Geverifieerd via DUO`, handmatig: $localize`:@@regWizard.herkomst.diplomaHandmatig:Handmatig ingevoerd (wordt beoordeeld)` }[this.draft().diplomaHerkomst ?? 'handmatig']));
/** BRP lookup outcome. A failure or"geen adres" never blocks the wizard — both
fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a
malformed response; 'fout' is an unreachable BRP. */
protected adresStatus = computed<'laden' | 'gevonden' | 'geen' | 'fout'>(() => {
const st = this.adresRes.status();
if (st === 'loading' || st === 'reloading') return 'laden';
if (st === 'error') return 'fout';
const json = this.adresRes.value();
const parsed = json !== undefined ? parseBrpAddress(json) : null;
return parsed && parsed.ok && parsed.value.gevonden ? 'gevonden' : 'geen';
});
/** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */
protected lookupRd = computed<RemoteData<Error | undefined, DuoLookupDto>>(() => {
const rd = fromResource(this.diplomasRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseDuoLookup(rd.value);
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
});
/** BRP lookup outcome (laden/gevonden/geen/fout) and the parsed DUO lookup, both
served by the application facade — the wizard renders, it does not fetch/parse. */
protected adresStatus = this.lookup.adresStatus;
protected lookupRd: () => RemoteData<Error | undefined, DuoLookupDto> = this.lookup.duoLookup;
/** Parsed lookup as a plain value (or null) — used outside the beroep step (the
controle summary) where the <app-async> template variable isn't in scope. */
@@ -362,21 +342,17 @@ export class RegistratieWizardComponent {
// (`?aanvraag=<id>`), or start fresh. Persistence is the draftSync controller's job.
const seeded = this.seed();
queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume()));
// Prefill the address from the BRP lookup as it arrives. Track only the resource
// value; untrack the dispatch (it reads the state signal, which would otherwise
// make this effect loop on its own write). Don't clobber edits/restored data.
// Prefill the address from the BRP lookup as it arrives. Track only the facade's
// parsed prefill signal; untrack the dispatch (it reads the state signal, which
// would otherwise make this effect loop on its own write). Don't clobber
// edits/restored data. A null prefill (loading/error/geen adres) leaves manual entry.
effect(() => {
const json = this.adresRes.value();
if (!json) return;
const parsed = parseBrpAddress(json);
const a = this.lookup.prefillAdres();
if (!a) return;
untracked(() => {
const s = this.state();
if (s.tag !== 'Invullen' || s.draft.straat) return;
// ponytail: slice 2 handles gevonden === false -> manual entry stays handmatig.
if (parsed.ok && parsed.value.gevonden && parsed.value.adres) {
const a = parsed.value.adres;
this.dispatch({ tag: 'PrefillAdres', straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats });
}
this.dispatch({ tag: 'PrefillAdres', straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats });
});
});
// A11y: focus management (step heading on step change, error summary on a
@@ -400,7 +376,7 @@ export class RegistratieWizardComponent {
restart() {
this.draftSync.reset(); // discard the current Concept; a fresh one starts on next progress
this.dispatch({ tag: 'Seed', state: initial });
this.adresRes.reload();
this.lookup.reloadAdres();
}
/** The effect: when we enter Indienen, submit through the aanvraag lifecycle

View File

@@ -4,38 +4,37 @@ import { Registration } from '@registratie/domain/registration';
import { statusColor, statusLabel } from '@registratie/domain/registration.policy';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
import { CardComponent } from '@shared/ui/card/card.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
/** Organism: registration summary card. Composes data-row molecules + status-badge atom. */
/** Organism: registration summary in a CIBG Datablock. Composes data-row rows +
status-badge atom (no card wrapper — the datablock carries its own surface). */
@Component({
selector: 'app-registration-summary',
imports: [DatePipe, DataRowComponent, StatusBadgeComponent, CardComponent],
imports: [DatePipe, DataRowComponent, StatusBadgeComponent, DataBlockComponent],
template: `
<app-card>
<dl class="row mb-0">
<app-data-row i18n-key="@@summary.bigNummer" key="BIG-nummer" [value]="reg().bigNummer" />
<app-data-row i18n-key="@@summary.naam" key="Naam" [value]="reg().naam" />
<app-data-row i18n-key="@@summary.beroep" key="Beroep" [value]="reg().beroep" />
<app-data-row i18n-key="@@summary.status" key="Status">
<app-status-badge [label]="label()" [color]="color()" />
</app-data-row>
<app-data-row i18n-key="@@summary.registratiedatum" key="Registratiedatum" [value]="reg().registratiedatum | date:'longDate'" />
<!-- Each status variant renders only the row its own data supports. -->
@switch (reg().status.tag) {
@case ('Geregistreerd') {
<app-data-row i18n-key="@@summary.uiterste" key="Uiterste herregistratie" [value]="$any(reg().status).herregistratieDatum | date:'longDate'" />
}
@case ('Geschorst') {
<app-data-row i18n-key="@@summary.geschorstTot" key="Geschorst tot" [value]="$any(reg().status).geschorstTot | date:'longDate'" />
<app-data-row i18n-key="@@summary.reden" key="Reden" [value]="$any(reg().status).reden" />
}
@case ('Doorgehaald') {
<app-data-row i18n-key="@@summary.doorgehaaldOp" key="Doorgehaald op" [value]="$any(reg().status).doorgehaaldOp | date:'longDate'" />
<app-data-row i18n-key="@@summary.reden" key="Reden" [value]="$any(reg().status).reden" />
}
<app-data-block i18n-ariaLabel="@@summary.ariaLabel" ariaLabel="Registratiegegevens">
<div app-data-row i18n-key="@@summary.bigNummer" key="BIG-nummer" [value]="reg().bigNummer"></div>
<div app-data-row i18n-key="@@summary.naam" key="Naam" [value]="reg().naam"></div>
<div app-data-row i18n-key="@@summary.beroep" key="Beroep" [value]="reg().beroep"></div>
<div app-data-row i18n-key="@@summary.status" key="Status">
<app-status-badge [label]="label()" [color]="color()" />
</div>
<div app-data-row i18n-key="@@summary.registratiedatum" key="Registratiedatum" [value]="reg().registratiedatum | date:'longDate'"></div>
<!-- Each status variant renders only the row its own data supports. -->
@switch (reg().status.tag) {
@case ('Geregistreerd') {
<div app-data-row i18n-key="@@summary.uiterste" key="Uiterste herregistratie" [value]="$any(reg().status).herregistratieDatum | date:'longDate'"></div>
}
</dl>
</app-card>
@case ('Geschorst') {
<div app-data-row i18n-key="@@summary.geschorstTot" key="Geschorst tot" [value]="$any(reg().status).geschorstTot | date:'longDate'"></div>
<div app-data-row i18n-key="@@summary.reden" key="Reden" [value]="$any(reg().status).reden"></div>
}
@case ('Doorgehaald') {
<div app-data-row i18n-key="@@summary.doorgehaaldOp" key="Doorgehaald op" [value]="$any(reg().status).doorgehaaldOp | date:'longDate'"></div>
<div app-data-row i18n-key="@@summary.reden" key="Reden" [value]="$any(reg().status).reden"></div>
}
}
</app-data-block>
`,
})
export class RegistrationSummaryComponent {

View File

@@ -13,16 +13,11 @@ const base = {
const meta: Meta<RegistrationSummaryComponent> = {
title: 'Organisms/Registration Summary',
component: RegistrationSummaryComponent,
parameters: {
// Structural: app-data-row's host sits between the <dl> and its <dt>/<dd> —
// fixed by the WP-12 Datablock rework. See docs/backlog/WP-12-datablock.md.
a11y: { disable: true },
},
};
export default meta;
type Story = StoryObj<RegistrationSummaryComponent>;
// Each story feeds a different union variant; the card renders only the rows
// Each story feeds a different union variant; the datablock renders only the rows
// that variant's data supports (note Doorgehaald has no herregistratie date).
export const Geregistreerd: Story = {
args: { reg: { ...base, status: { tag: 'Geregistreerd', herregistratieDatum: '2027-09-01' } } },

View File

@@ -2,33 +2,40 @@ import { Component, input, output } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { RouterLink } from '@angular/router';
/** Molecule: one row in a CIBG Huisstijl "aanvragen" list — `<li><a class="application">`
with a title, optional subtitle/status/cta (see application-list.component.ts).
Renders a plain (non-interactive) row when there's nothing to navigate to — the
chevron/hover styling only applies to the `<a>`. A `[applicationActions]` slot
projects a sibling action (e.g. "Annuleren") after the anchor — a button can't
nest inside the anchor itself. */
/** Molecule: one row in a CIBG Huisstijl "aanvragen" list
(designsystem.cibg.nl/componenten/aanvragen) — a white card-link styled by the
vendored `.dashboard-block.applications li a` chain (bg, chevron, link-blue `h3`),
with an optional `.subtitle`/`.status`/`.cta`. Used on an `<li>` so the `<ul>`'s
direct child is a native `<li>` (keeps the list axe-clean — a bare custom element
between `<ul>` and its `<li>` trips axe's list rule regardless of `display:contents`).
A non-navigating row renders a `<div>` (the vendored chain only styles `<a>`, so
that surface is mirrored from tokens). A `[applicationActions]` slot projects a
sibling action after the anchor — a button can't nest inside the anchor itself. */
@Component({
selector: 'app-application-link',
selector: 'li[app-application-link]',
imports: [RouterLink, NgTemplateOutlet],
// display:contents so the <li> becomes a real DOM/accessibility-tree child of the
// parent <ul> — a wrapper host between them can break list semantics for AT.
styles: [`:host{display:contents}`],
styles: [`
/* The vendored .applications li a surface only styles <a>; mirror it from tokens
for a non-navigating (informational) row so the card looks consistent. */
.static-row{display:flex;background:var(--rhc-color-wit);border-block-end:.065rem solid var(--rhc-color-border-subtle);padding:.75rem 2rem .75rem 1rem}
.content{flex:1 1 auto;min-inline-size:0}
.cta{margin-inline-start:auto;align-self:center}
`],
template: `
<li>
@if (to()) {
<a class="application" [routerLink]="to()"><ng-container [ngTemplateOutlet]="body" /></a>
} @else if (clickable()) {
<a href="#" class="application" (click)="onActivate($event)"><ng-container [ngTemplateOutlet]="body" /></a>
} @else {
<div><ng-container [ngTemplateOutlet]="body" /></div>
}
<ng-content select="[applicationActions]" />
</li>
@if (to()) {
<a [routerLink]="to()"><ng-container [ngTemplateOutlet]="body" /></a>
} @else if (clickable()) {
<a href="#" (click)="onActivate($event)"><ng-container [ngTemplateOutlet]="body" /></a>
} @else {
<div class="static-row"><ng-container [ngTemplateOutlet]="body" /></div>
}
<ng-content select="[applicationActions]" />
<ng-template #body>
<h3 class="application-title">{{ heading() }}</h3>
@if (subtitle()) { <div class="subtitle">{{ subtitle() }}</div> }
@if (status()) { <div class="status">{{ status() }}</div> }
<div class="content">
<h3 class="h3">{{ heading() }}</h3>
@if (subtitle()) { <div class="subtitle">{{ subtitle() }}</div> }
@if (status()) { <div class="status">{{ status() }}</div> }
</div>
@if (cta()) { <div class="cta">{{ cta() }}</div> }
</ng-template>
`,

View File

@@ -9,15 +9,9 @@ const meta: Meta<ApplicationLinkComponent> = {
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({
props: args,
// Rows are <li>s — a real list gives them their normal layout in the story.
template: `<ul class="list-unstyled"><app-application-link [heading]="heading" [subtitle]="subtitle" [status]="status" [cta]="cta" [to]="to" [clickable]="clickable" /></ul>`,
// Rows are <li>s in the "aanvragen" list — a real <ul> gives them their layout.
template: `<div class="dashboard-block applications"><ul class="list-unstyled"><li app-application-link [heading]="heading" [subtitle]="subtitle" [status]="status" [cta]="cta" [to]="to" [clickable]="clickable"></li></ul></div>`,
}),
parameters: {
// Structural: app-application-link's host sits between the <ul> and its <li> —
// axe's list/listitem rule requires them adjacent regardless of `display:contents`.
// WP-11 (CIBG markup fidelity) reworks this markup; see docs/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
};
export default meta;
type Story = StoryObj<ApplicationLinkComponent>;

View File

@@ -14,16 +14,11 @@ const meta: Meta<ApplicationListComponent> = {
render: () => ({
template: `
<app-application-list>
<app-application-link heading="Inschrijving" status="Stap 2 van 3" cta="Verder gaan" clickable="true" />
<app-application-link heading="Herregistratie" status="Referentie 2024-00123 · ingediend op 12 mei 2024" />
<app-application-link heading="Inschrijven" subtitle="Schrijf u in in het BIG-register." to="/registreren" />
<li app-application-link heading="Herregistratie" subtitle="Verlenging van uw BIG-registratie" status="In behandeling · Referentie 2024-00123 · ingediend op 12 mei 2024" to="/aanvraag/1"></li>
<li app-application-link heading="Inschrijving" subtitle="Inschrijving in het BIG-register" status="Goedgekeurd · Referentie 2024-00088" to="/aanvraag/2"></li>
<li app-application-link heading="Inschrijven" subtitle="Schrijf u in in het BIG-register." cta="Start inschrijving" to="/registreren"></li>
</app-application-list>`,
}),
parameters: {
// Structural: app-application-link's host sits between the <ul> and its <li> —
// fixed by the WP-11 markup rework. See docs/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
};
export default meta;
type Story = StoryObj<ApplicationListComponent>;

View File

@@ -0,0 +1,33 @@
import { Component, input } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
/** Molecule: CIBG Huisstijl **Datablock** (designsystem.cibg.nl/componenten/datablock)
— THE way to show user/application data. A grey `.data-block` surface holds a white
`.block-wrapper` panel with a `<dl>` of projected `<app-data-row>`s. Use `stacked`
(`.data-block--stacked`) when labels/values are long and should stack. Replaces the
`app-card + dl` idiom for data views (the datablock carries its own surface, so it is
NOT wrapped in an `app-card` — see WP-12). When there is no visible `heading`, pass an
`ariaLabel` so the definition list is announced. */
@Component({
selector: 'app-data-block',
imports: [HeadingComponent],
template: `
@if (heading()) { <app-heading [level]="level()">{{ heading() }}</app-heading> }
<div class="data-block" [class.data-block--stacked]="stacked()">
<div class="block-wrapper">
<dl class="mb-0" [attr.aria-label]="ariaLabel() || null">
<ng-content />
</dl>
</div>
</div>
`,
})
export class DataBlockComponent {
heading = input('');
/** Heading level when `heading` is set (default h3, matching `app-card`). */
level = input<1 | 2 | 3 | 4 | 5>(3);
/** Stacks label above value (`.data-block--stacked`) for long content. */
stacked = input(false);
/** Accessible name for the `<dl>` when there is no visible heading. */
ariaLabel = input('');
}

View File

@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import { DataBlockComponent } from './data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
const meta: Meta<DataBlockComponent> = {
title: 'Molecules/Data Block',
component: DataBlockComponent,
decorators: [moduleMetadata({ imports: [DataRowComponent] })],
render: (args) => ({
props: args,
template: `
<app-data-block [heading]="heading" [stacked]="stacked" [ariaLabel]="ariaLabel">
<div app-data-row key="BIG-nummer" value="19012345601"></div>
<div app-data-row key="Naam" value="J. de Vries"></div>
<div app-data-row key="Beroep" value="Verpleegkundige"></div>
<div app-data-row key="Registratiedatum" value="1 maart 2018"></div>
</app-data-block>`,
}),
};
export default meta;
type Story = StoryObj<DataBlockComponent>;
export const Default: Story = { args: { heading: 'Persoonsgegevens (BRP)' } };
export const ZonderKop: Story = { args: { ariaLabel: 'Registratiegegevens' } };
export const Stacked: Story = { args: { heading: 'Toelichting', stacked: true } };

View File

@@ -1,12 +1,21 @@
import { Component, input } from '@angular/core';
/** Molecule: one key/value row in a CIBG Huisstijl "controlestap" data summary
(`dt.col-md-4` / `dd.col-md-8`). `display:contents` so the dt/dd become direct
flex children of the parent `<dl class="row">` — the Bootstrap grid classes
need that to lay out correctly. Wrap several in a `<dl class="row mb-0">`. */
/** Molecule: one key/value row inside a CIBG Huisstijl **Datablock** (see
`data-block.component.ts`) — the row primitive of the datablock/`controlestap`
data summary. Used on a `<div>` so the `<dl>`'s direct child is a native element
(the HTML5.1 `dl > div > dt + dd` grouping), which keeps the definition list
axe-clean — a bare custom element between `<dl>` and its `<dt>/<dd>` trips axe's
definition-list rule regardless of `display:contents`. The host is the Bootstrap
`.row`; `dt.col-md-4`/`dd.col-md-8` give the label/value widths. Wrap several in a
`<dl class="mb-0">` (a `<app-data-block>`). Project custom content (e.g. a badge)
into the `<dd>` via `<ng-content>`. */
@Component({
selector: 'app-data-row',
styles: [`:host{display:contents}`],
selector: 'div[app-data-row]',
host: { class: 'row' },
// The CIBG datablock draws a separator between entries. It ships that border on
// dt/dd with a `:last-of-type` reset, but our one-row-per-<div> grouping (for axe)
// makes every dt/dd a last-of-type — so we carry the separator on the row instead.
styles: [`:host:not(:last-of-type){border-block-end:var(--rhc-border-width-sm) solid var(--rhc-color-cool-grey-200)}`],
template: `
<dt class="col-md-4">{{ key() }}</dt>
<dd class="col-md-8"><ng-content>{{ value() }}</ng-content></dd>

View File

@@ -6,16 +6,11 @@ const meta: Meta<DataRowComponent> = {
component: DataRowComponent,
render: (args) => ({
props: args,
// Rows are dt.col-md-4/dd.col-md-8 flex children of a Bootstrap .row dl (CIBG controlestap).
template: `<dl class="row mb-0"><app-data-row [key]="key" [value]="value" /></dl>`,
// A row is a `.row` <div> grouping dt.col-md-4/dd.col-md-8 inside the datablock <dl>
// (HTML5.1 dl > div > dt+dd — a native div child keeps the definition list axe-clean).
template: `<dl class="mb-0"><div app-data-row [key]="key" [value]="value"></div></dl>`,
}),
args: { key: 'BIG-nummer', value: '19012345601' },
parameters: {
// Structural: app-data-row's host sits between the <dl> and its <dt>/<dd> —
// axe's definition-list rule needs them adjacent regardless of `display:contents`.
// WP-12 (CIBG Datablock) reworks this markup; see docs/backlog/WP-12-datablock.md.
a11y: { disable: true },
},
};
export default meta;
type Story = StoryObj<DataRowComponent>;

View File

@@ -1,11 +1,13 @@
import { Component, input, output } from '@angular/core';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
/** Molecule: one section of a CIBG Huisstijl "controlestap" (wizard review step) —
a heading with a "Wijzigen" link, and the section's `<app-data-row>`s in a
`.data-block > .block-wrapper > dl.row`. Domain-free; the caller supplies the
a heading with a "Wijzigen" link, and the section's `<app-data-row>`s in a CIBG
Datablock (composes `<app-data-block>`). Domain-free; the caller supplies the
heading and decides what "Wijzigen" does (typically jump back to a step). */
@Component({
selector: 'app-review-section',
imports: [DataBlockComponent],
template: `
<div class="d-flex">
<h2>{{ heading() }}</h2>
@@ -15,13 +17,7 @@ import { Component, input, output } from '@angular/core';
</div>
}
</div>
<div class="data-block">
<div class="block-wrapper">
<dl class="row mb-0">
<ng-content />
</dl>
</div>
</div>
<app-data-block><ng-content /></app-data-block>
`,
})
export class ReviewSectionComponent {

View File

@@ -11,16 +11,11 @@ const meta: Meta<ReviewSectionComponent> = {
props: args,
template: `
<app-review-section [heading]="heading" [editLabel]="editLabel" [editAriaLabel]="editAriaLabel" [showEdit]="showEdit">
<app-data-row key="Straat en huisnummer" value="Dorpsstraat 1" />
<app-data-row key="Postcode" value="1234 AB" />
<app-data-row key="Woonplaats" value="Utrecht" />
<div app-data-row key="Straat en huisnummer" value="Dorpsstraat 1"></div>
<div app-data-row key="Postcode" value="1234 AB"></div>
<div app-data-row key="Woonplaats" value="Utrecht"></div>
</app-review-section>`,
}),
parameters: {
// Structural: app-data-row's host sits between the <dl> and its <dt>/<dd> —
// fixed by the WP-12 Datablock rework. See docs/backlog/WP-12-datablock.md.
a11y: { disable: true },
},
};
export default meta;
type Story = StoryObj<ReviewSectionComponent>;

View File

@@ -4,9 +4,10 @@ import { DeliveryChannelToggleComponent } from '../delivery-channel-toggle/deliv
import { FileInputComponent } from '../file-input/file-input.component';
import { SingleUploadComponent } from '../single-upload/single-upload.component';
/** Organism: one document category — its label/description, an optional delivery
channel toggle, and (when digital) a file picker plus the list of uploads. Pure
UI: emits selection/removal/retry/delete and channel changes; no HTTP or rules. */
/** Organism: one document category (CIBG Bestand-upload) — its label/description, an
optional delivery channel toggle, and (when digital) a validation message, the
file picker/drop-zone, and the `ul.file-list` of uploads. Pure UI: emits
selection/removal/retry/delete and channel changes; no HTTP or rules. */
@Component({
selector: 'app-document-category',
imports: [DeliveryChannelToggleComponent, FileInputComponent, SingleUploadComponent],
@@ -15,14 +16,7 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
.label { font-weight: var(--rhc-text-font-weight-semi-bold); margin-block-end: var(--rhc-space-max-sm); }
.req { font-weight: var(--rhc-text-font-weight-regular); color: var(--rhc-color-foreground-subtle); }
.desc { color: var(--rhc-color-foreground-subtle); font-size: var(--rhc-text-font-size-sm); margin-block-end: var(--rhc-space-max-md); }
.uploads { display: flex; flex-direction: column; gap: var(--rhc-space-max-md); margin-block-start: var(--rhc-space-max-md); }
.rejection {
color: var(--rhc-color-foreground-default);
font-weight: var(--rhc-text-font-weight-semi-bold);
margin-block-start: var(--rhc-space-max-sm);
border-inline-start: var(--rhc-border-width-md) solid var(--rhc-color-rood-500);
padding-inline-start: var(--rhc-space-max-md);
}
.file-list { list-style: none; padding: 0; margin-block-start: var(--rhc-space-max-md); }
`],
template: `
<div class="label">
@@ -40,27 +34,33 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
}
@if (channel() === 'digital') {
<!-- CIBG: validation sits ABOVE the upload block. -->
@if (rejection()) {
<div class="upload-validation">
<div class="feedback feedback-warning" role="alert">{{ rejection() }}</div>
</div>
}
<app-file-input
[inputId]="category().categoryId + '-file'"
[label]="fileInputLabel()"
[accept]="category().acceptedTypes"
[maxSizeMb]="category().maxSizeMb"
[multiple]="category().multiple"
(filesSelected)="fileSelected.emit($event)" />
<div class="uploads">
@for (u of uploads(); track u.localId) {
<app-single-upload
animate.enter="app-item-enter"
animate.leave="app-item-leave"
[upload]="u"
[previewUrlFor]="previewUrlFor()"
(remove)="onRemove(u)"
(retry)="retryUpload.emit(u.localId)" />
}
</div>
@if (rejection()) {
<div class="rejection" role="alert">{{ rejection() }}</div>
@if (uploads().length) {
<ul class="file-list">
@for (u of uploads(); track u.localId) {
<li app-single-upload
animate.enter="app-item-enter"
animate.leave="app-item-leave"
[upload]="u"
[previewUrlFor]="previewUrlFor()"
(remove)="onRemove(u)"
(retry)="retryUpload.emit(u.localId)"></li>
}
</ul>
}
}
`,

View File

@@ -1,72 +1,55 @@
import { Component, input, output } from '@angular/core';
import { Component, computed, input } from '@angular/core';
import type { UploadStatus } from '@shared/upload/upload.machine';
import { UploadStatusIconComponent } from '../upload-status-icon/upload-status-icon.component';
/** Atom: a single uploaded-file chip — filename + status glyph + actions. Pure UI:
emits `remove`/`retry`; the container decides what they mean. */
const STATUS_LABELS: Record<UploadStatus['type'], string> = {
idle: '',
queued: $localize`:@@upload.status.queued:In wachtrij`,
uploading: $localize`:@@upload.status.uploading:Bezig met uploaden`,
complete: $localize`:@@upload.status.complete:Geüpload`,
failed: $localize`:@@upload.status.failed:Mislukt`,
deleting: $localize`:@@upload.status.deleting:Bezig met verwijderen`,
deleted: '',
};
/** Atom: the `.file` block of a CIBG Bestand-upload file row — a status glyph, the
filename (a download link once complete) and a `.file-meta` line (size + status).
`display:contents` so `.file` becomes a flex child of the `.file-container` row.
Pure UI. */
@Component({
selector: 'app-document-chip',
imports: [UploadStatusIconComponent],
styles: [`
:host {
display: inline-flex;
align-items: center;
gap: var(--rhc-space-max-md);
padding-block: var(--rhc-space-max-sm);
padding-inline: var(--rhc-space-max-md);
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-subtle);
border-radius: var(--rhc-border-radius-md);
background: var(--rhc-color-cool-grey-100);
}
.name { flex: 1; }
.action {
background: none;
border: none;
cursor: pointer;
padding: 0;
color: var(--rhc-color-foreground-link);
font: inherit;
text-decoration: underline;
}
.action:hover { color: var(--rhc-color-foreground-link-hover); }
a.action { display: inline-block; }
:host { display: contents; }
.file { display: flex; flex-direction: column; gap: var(--rhc-space-max-xs); min-inline-size: 0; }
.file-name { word-break: break-all; }
`],
template: `
<app-upload-status-icon [status]="status().type" />
<span class="name">{{ fileName() }}</span>
@if (previewUrl()) {
<a class="action" [href]="previewUrl()" target="_blank" rel="noopener" [attr.aria-label]="previewLabel">
<span i18n="@@upload.chip.preview">Voorbeeld / Download</span>
</a>
}
@if (status().type === 'failed') {
<button type="button" class="action" [attr.aria-label]="retryLabel" (click)="retry.emit()">
<span i18n="@@upload.chip.retry">Opnieuw</span>
</button>
}
@if (status().type !== 'deleting') {
<button type="button" class="action" [attr.aria-label]="removeLabel" (click)="remove.emit()">
<span i18n="@@upload.chip.remove">Verwijderen</span>
</button>
}
<div class="file">
<span class="d-inline-flex align-items-center gap-2">
<app-upload-status-icon [status]="status().type" />
@if (previewUrl()) {
<a class="file-name" [href]="previewUrl()" target="_blank" rel="noopener">{{ fileName() }}</a>
} @else {
<span class="file-name">{{ fileName() }}</span>
}
</span>
<span class="file-meta">{{ meta() }}</span>
</div>
`,
})
export class DocumentChipComponent {
fileName = input.required<string>();
status = input.required<UploadStatus>();
/** When set, show a preview/download link (opens the stored bytes). */
fileSizeMb = input(0);
/** When set, the filename is a preview/download link (opens the stored bytes). */
previewUrl = input<string>();
remove = output<void>();
retry = output<void>();
protected get removeLabel(): string {
return $localize`:@@upload.chip.removeAria:${this.fileName()}:fileName: verwijderen`;
}
protected get previewLabel(): string {
return $localize`:@@upload.chip.previewAria:${this.fileName()}:fileName: bekijken of downloaden`;
}
protected get retryLabel(): string {
return $localize`:@@upload.chip.retryAria:${this.fileName()}:fileName: opnieuw uploaden`;
}
/** `.file-meta`: file size + status word (+ the failure reason when failed). */
protected meta = computed(() => {
const s = this.status();
const size = this.fileSizeMb() > 0 ? `${this.fileSizeMb().toFixed(1)} MB` : '';
const label = s.type === 'failed' && s.reason ? s.reason : STATUS_LABELS[s.type];
return [size, label].filter(Boolean).join(' · ');
});
}

View File

@@ -7,20 +7,21 @@ const meta: Meta<DocumentChipComponent> = {
component: DocumentChipComponent,
render: (args) => ({
props: args,
template: `<app-document-chip [fileName]="fileName" [status]="status" [previewUrl]="previewUrl" />`,
// The .file/.file-name/.file-meta styling only applies inside ul.file-list > .file-container.
template: `<ul class="file-list"><li class="file-container"><app-document-chip [fileName]="fileName" [status]="status" [fileSizeMb]="fileSizeMb" [previewUrl]="previewUrl" /></li></ul>`,
}),
};
export default meta;
type Story = StoryObj<DocumentChipComponent>;
export const Complete: Story = {
args: { fileName: 'diploma.pdf', status: { type: 'complete', documentId: 'doc-1' } as UploadStatus },
args: { fileName: 'diploma.pdf', fileSizeMb: 1.2, status: { type: 'complete', documentId: 'doc-1' } as UploadStatus },
};
export const WithPreview: Story = {
args: { fileName: 'diploma.pdf', status: { type: 'complete', documentId: 'doc-1' } as UploadStatus, previewUrl: '/api/v1/uploads/doc-1/content' },
args: { fileName: 'diploma.pdf', fileSizeMb: 1.2, status: { type: 'complete', documentId: 'doc-1' } as UploadStatus, previewUrl: '/api/v1/uploads/doc-1/content' },
};
export const Failed: Story = {
args: { fileName: 'diploma.pdf', status: { type: 'failed', reason: 'Netwerkfout' } as UploadStatus },
args: { fileName: 'diploma.pdf', fileSizeMb: 1.2, status: { type: 'failed', reason: 'Netwerkfout' } as UploadStatus },
};

View File

@@ -1,62 +1,113 @@
import { Component, ElementRef, input, output, viewChild } from '@angular/core';
import { Component, ElementRef, computed, input, output, signal, viewChild } from '@angular/core';
/** Atom: a styled file picker. Wraps a native `<input type="file">` and emits the
selected files; resets its value after each change so re-picking the same file
re-fires. Pure UI — no validation, no upload. */
let nextId = 0;
/** Friendly labels for the MIME types the backend sends in `acceptedTypes`. */
const TYPE_LABELS: Record<string, string> = {
'application/pdf': 'PDF',
'image/jpeg': 'JPG',
'image/jpg': 'JPG',
'image/png': 'PNG',
'application/msword': 'Word',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word',
};
/** Atom: the CIBG Huisstijl **Bestand-upload** control
(designsystem.cibg.nl/componenten/bestand-upload) — a `.file-picker-drop-area`
with an always-visible instruction (allowed types + max size), a real
`.btn-upload` button, and a visually-hidden native `<input type="file">`. Supports
click-to-pick and drag-and-drop; resets its value after each change so re-picking
the same file re-fires. The instruction is linked to the input via
`aria-describedby` (pattern requirement). Pure UI — no validation, no upload. */
@Component({
selector: 'app-file-input',
styles: [`
:host { display: inline-block; }
.field { position: relative; display: inline-flex; }
input[type='file'] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
input[type='file']:disabled { cursor: not-allowed; }
.label {
pointer-events: none;
display: inline-flex;
align-items: center;
gap: var(--rhc-space-max-sm);
}
:host { display: block; }
.file-picker-drop-area { display: flex; flex-direction: column; align-items: flex-start; gap: var(--rhc-space-max-sm); }
/* Default (not subtle) foreground: subtle grey on the grey drop-area fails WCAG AA contrast. */
.upload-instructions { margin: 0; color: var(--rhc-color-foreground-default); font-size: var(--rhc-text-font-size-sm); }
/* Solid CIBG-blue button; the .btn-upload ::before folder glyph is the icon, so
drop its redundant centred background-image folder. */
.btn-upload { cursor: pointer; background-image: none; }
.btn-upload.disabled { cursor: not-allowed; }
/* The input is visually hidden but still focusable; surface its focus on the button. */
input:focus-visible + .btn-upload { outline: var(--rhc-border-width-md) solid var(--rhc-color-foreground-link); outline-offset: 2px; }
`],
template: `
<span class="field">
<div class="file-picker-drop-area" [class.drag-over]="dragging()"
(dragover)="onDragOver($event)" (dragleave)="onDragLeave($event)" (drop)="onDrop($event)">
@if (instructions()) {
<p class="upload-instructions" [id]="instructionsId">{{ instructions() }}</p>
}
<input
#fileInput
class="visually-hidden"
type="file"
[id]="inputId()"
[attr.aria-label]="label()"
[attr.aria-describedby]="instructions() ? instructionsId : null"
[accept]="accept().join(',')"
[multiple]="multiple()"
[disabled]="disabled()"
(change)="onChange($event)" />
<span class="btn btn-outline-primary label" [class.disabled]="disabled()">
<span aria-hidden="true">↑</span>
<span>{{ label() }}</span>
</span>
</span>
<label class="btn btn-primary btn-upload" [class.disabled]="disabled()" [attr.for]="inputId()">
{{ buttonText() }}
</label>
</div>
`,
})
export class FileInputComponent {
accept = input<string[]>([]);
multiple = input(false);
disabled = input(false);
maxSizeMb = input(0);
inputId = input.required<string>();
/** Accessible name + button text; the domain caller supplies a per-category label. */
/** Accessible name for the input; the domain caller supplies a per-category label. */
label = input($localize`:@@upload.fileInput.label:Bestand kiezen`);
filesSelected = output<File[]>();
protected readonly instructionsId = `upload-instructions-${nextId++}`;
protected readonly dragging = signal(false);
private fileInput = viewChild.required<ElementRef<HTMLInputElement>>('fileInput');
protected buttonText = computed(() =>
this.multiple()
? $localize`:@@upload.fileInput.addMany:Bestanden toevoegen`
: $localize`:@@upload.fileInput.addOne:Bestand toevoegen`,
);
/** Always-visible instruction: allowed file types + max size (CIBG pattern). */
protected instructions = computed(() => {
const types = this.accept().map((m) => TYPE_LABELS[m] ?? m.split('/').pop()?.toUpperCase() ?? m).join(', ');
const size = this.maxSizeMb();
if (types && size > 0) return $localize`:@@upload.fileInput.instrBoth:Toegestaan: ${types}:types: · max ${size}:size: MB`;
if (types) return $localize`:@@upload.fileInput.instrTypes:Toegestaan: ${types}:types:`;
if (size > 0) return $localize`:@@upload.fileInput.instrSize:Max ${size}:size: MB`;
return '';
});
onChange(event: Event) {
const input = event.target as HTMLInputElement;
this.filesSelected.emit(Array.from(input.files ?? []));
this.fileInput().nativeElement.value = '';
}
onDragOver(event: DragEvent) {
if (this.disabled()) return;
event.preventDefault(); // allow drop
this.dragging.set(true);
}
onDragLeave(event: DragEvent) {
event.preventDefault();
this.dragging.set(false);
}
onDrop(event: DragEvent) {
event.preventDefault();
this.dragging.set(false);
if (this.disabled()) return;
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.length) this.filesSelected.emit(files);
}
}

View File

@@ -6,16 +6,20 @@ const meta: Meta<FileInputComponent> = {
component: FileInputComponent,
render: (args) => ({
props: args,
template: `<app-file-input [inputId]="inputId" [accept]="accept" [multiple]="multiple" [disabled]="disabled" />`,
template: `<app-file-input [inputId]="inputId" [accept]="accept" [maxSizeMb]="maxSizeMb" [multiple]="multiple" [disabled]="disabled" />`,
}),
};
export default meta;
type Story = StoryObj<FileInputComponent>;
export const Default: Story = {
args: { inputId: 'diploma', accept: ['application/pdf'], multiple: false, disabled: false },
args: { inputId: 'diploma', accept: ['application/pdf', 'image/jpeg'], maxSizeMb: 10, multiple: false, disabled: false },
};
export const Multiple: Story = {
args: { inputId: 'cv', accept: ['application/pdf'], maxSizeMb: 5, multiple: true, disabled: false },
};
export const Disabled: Story = {
args: { inputId: 'diploma-disabled', accept: ['application/pdf'], multiple: false, disabled: true },
args: { inputId: 'diploma-disabled', accept: ['application/pdf'], maxSizeMb: 10, multiple: false, disabled: true },
};

View File

@@ -3,23 +3,36 @@ import type { Upload } from '@shared/upload/upload.machine';
import { DocumentChipComponent } from '../document-chip/document-chip.component';
import { UploadProgressBarComponent } from '../upload-progress-bar/upload-progress-bar.component';
/** Molecule: one upload row — its chip plus a progress bar while uploading. Pure UI:
forwards `remove`/`retry` from the chip. */
/** Molecule: one row in a CIBG Bestand-upload file list — a `.file-container` `<li>`
with the `.file` block (via document-chip), the `.actions` (retry/remove), and a
progress bar while uploading. Used on an `<li>` so `ul.file-list`'s direct child is
a native `<li>`. Pure UI: emits `remove`/`retry`; the container decides what they mean. */
@Component({
selector: 'app-single-upload',
selector: 'li[app-single-upload]',
host: { class: 'file-container' },
imports: [DocumentChipComponent, UploadProgressBarComponent],
styles: [`
:host { display: flex; flex-direction: column; gap: var(--rhc-space-max-sm); }
:host { flex-wrap: wrap; align-items: center; }
.upload-progress { flex: 1 0 100%; margin-block-start: var(--rhc-space-max-sm); }
.actions .btn { background: none; border: none; cursor: pointer; padding: 0; }
.actions .btn-retry { color: var(--rhc-color-foreground-link); text-decoration: underline; font: inherit; }
`],
template: `
<app-document-chip
[fileName]="upload().fileName"
[status]="upload().status"
[previewUrl]="previewUrl()"
(remove)="remove.emit()"
(retry)="retry.emit()" />
[fileSizeMb]="upload().fileSizeMb"
[previewUrl]="previewUrl()" />
<div class="actions">
@if (upload().status.type === 'failed') {
<button type="button" class="btn btn-retry" [attr.aria-label]="retryLabel" (click)="retry.emit()" i18n="@@upload.chip.retry">Opnieuw</button>
}
@if (upload().status.type !== 'deleting') {
<button type="button" class="btn icon-remove" [attr.aria-label]="removeLabel" (click)="remove.emit()"></button>
}
</div>
@if (progressPct() !== null) {
<app-upload-progress-bar [progressPct]="progressPct()!" />
<app-upload-progress-bar class="upload-progress" [progressPct]="progressPct()!" />
}
`,
})
@@ -41,4 +54,11 @@ export class SingleUploadComponent {
remove = output<void>();
retry = output<void>();
protected get removeLabel(): string {
return $localize`:@@upload.chip.removeAria:${this.upload().fileName}:fileName: verwijderen`;
}
protected get retryLabel(): string {
return $localize`:@@upload.chip.retryAria:${this.upload().fileName}:fileName: opnieuw uploaden`;
}
}

View File

@@ -7,7 +7,7 @@ const meta: Meta<SingleUploadComponent> = {
component: SingleUploadComponent,
render: (args) => ({
props: args,
template: `<app-single-upload [upload]="upload" />`,
template: `<ul class="file-list"><li app-single-upload [upload]="upload"></li></ul>`,
}),
};
export default meta;