Compare commits
10 Commits
6224501e0a
...
cf44bda0ce
| Author | SHA1 | Date | |
|---|---|---|---|
| cf44bda0ce | |||
| 0f360c5939 | |||
| 05314afd98 | |||
| ccc0184342 | |||
| b5c5d30a65 | |||
| 98fd7e4bcd | |||
| 82fc3c493d | |||
| 947d5fa90a | |||
| 035e785c95 | |||
| 3a5c8f157a |
57
.claude/skills/bff-endpoint/SKILL.md
Normal file
57
.claude/skills/bff-endpoint/SKILL.md
Normal 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
|
||||
```
|
||||
70
.claude/skills/form-machine/SKILL.md
Normal file
70
.claude/skills/form-machine/SKILL.md
Normal 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
|
||||
```
|
||||
58
.claude/skills/mutation-command/SKILL.md
Normal file
58
.claude/skills/mutation-command/SKILL.md
Normal 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
|
||||
```
|
||||
51
.claude/skills/new-context/SKILL.md
Normal file
51
.claude/skills/new-context/SKILL.md
Normal 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.
|
||||
```
|
||||
45
.claude/skills/new-feature/SKILL.md
Normal file
45
.claude/skills/new-feature/SKILL.md
Normal 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
|
||||
```
|
||||
55
.claude/skills/new-ssp/SKILL.md
Normal file
55
.claude/skills/new-ssp/SKILL.md
Normal 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 0001–0003 — 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.
|
||||
53
.claude/skills/ui-component/SKILL.md
Normal file
53
.claude/skills/ui-component/SKILL.md
Normal 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
|
||||
```
|
||||
55
.claude/skills/value-object/SKILL.md
Normal file
55
.claude/skills/value-object/SKILL.md
Normal 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
|
||||
```
|
||||
15
CLAUDE.md
15
CLAUDE.md
@@ -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
|
||||
`herregistratie → registratie → 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),
|
||||
|
||||
@@ -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/*`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
4818
documentation.json
4818
documentation.json
File diff suppressed because one or more lines are too long
@@ -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' },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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] : [])),
|
||||
|
||||
@@ -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) },
|
||||
|
||||
19
src/app/herregistratie/application/intake-policy.store.ts
Normal file
19
src/app/herregistratie/application/intake-policy.store.ts
Normal 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);
|
||||
}
|
||||
@@ -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 }));
|
||||
});
|
||||
}
|
||||
|
||||
58
src/app/registratie/application/registratie-lookup.store.ts
Normal file
58
src/app/registratie/application/registratie-lookup.store.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
45
src/app/registratie/domain/aanvraag-view.spec.ts
Normal file
45
src/app/registratie/domain/aanvraag-view.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
75
src/app/registratie/domain/aanvraag-view.ts
Normal file
75
src/app/registratie/domain/aanvraag-view.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
50
src/app/registratie/ui/aanvraag-detail.page.ts
Normal file
50
src/app/registratie/ui/aanvraag-detail.page.ts
Normal 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;
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' } } },
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
33
src/app/shared/ui/data-block/data-block.component.ts
Normal file
33
src/app/shared/ui/data-block/data-block.component.ts
Normal 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('');
|
||||
}
|
||||
26
src/app/shared/ui/data-block/data-block.stories.ts
Normal file
26
src/app/shared/ui/data-block/data-block.stories.ts
Normal 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 } };
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -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(' · ');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user