Behandelportal: monorepo merge + WP-64..67 backoffice arc (OpenZaak write closes it out) #1

Merged
eho merged 8 commits from monorepo-behandelportal into main 2026-08-04 12:56:34 +00:00
447 changed files with 10514 additions and 60960 deletions
+135
View File
@@ -0,0 +1,135 @@
// Dependency-cruiser (WP-38, generalized for the WP-67 monorepo split): the single
// declarative source for the bounded-context + atomic-layer boundaries. Each app
// (apps/ssp, apps/behandelportal) is cruised SEPARATELY against its own tsconfig.json
// (see .dependency-cruiser.<app>.js) — a single merged tsconfig can't resolve both
// apps' `@auth/*` alias at once, since each points at a different physical directory.
// This file is the shared rule *factory*; it never runs standalone.
//
// libs/shared and libs/beheer are cross-app libraries, not per-app feature contexts:
// any app context may import either; neither may import an app's feature context;
// libs/shared may not import libs/beheer (shared stays the base, beheer a peer leaf).
/**
* @param {Record<string, string[] | null>} contextAllowed context name -> the OTHER
* app-local contexts it may additionally import (besides itself + libs/shared|beheer).
* `null` = unrestricted (showcase, the sanctioned teaching page).
* @param {string} appName the apps/<appName> directory this config cruises.
* @param {string} tsConfigFileName this app's own tsconfig.json (resolves its aliases).
*/
module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName) {
const FEATURES = Object.keys(contextAllowed).join('|');
const appRoot = `apps/${appName}/src/app`;
const contextRule = (from) => {
const allowed = contextAllowed[from];
if (allowed === null) return null;
const forbidden = Object.keys(contextAllowed)
.filter((name) => name !== from && !allowed.includes(name))
.join('|');
return {
name: `${appName}-${from}-scope`,
comment: `${from} may depend only on its allowed contexts (+ libs/shared|beheer). See CLAUDE.md §1.`,
severity: 'error',
from: { path: `^${appRoot}/${from}/` },
to: { path: `^${appRoot}/(${forbidden})/` },
};
};
// Atomic-layer rules apply uniformly across this app's tree AND both libraries.
const anyRoot = `(${appRoot}|libs/shared/src|libs/beheer/src)`;
return {
forbidden: [
// --- Bounded-context direction (the "dependencies point inward" spine) ---
{
name: 'shared-no-features',
comment: 'libs/shared is the base — it must not import any app feature context.',
severity: 'error',
from: { path: '^libs/shared/src/' },
to: { path: `^${appRoot}/(${FEATURES})/` },
},
{
name: 'beheer-no-features',
comment: 'libs/beheer is a cross-app library — it must not import any app feature context.',
severity: 'error',
from: { path: '^libs/beheer/src/' },
to: { path: `^${appRoot}/(${FEATURES})/` },
},
{
name: 'shared-no-beheer',
comment: 'libs/shared stays the base — it must not depend on the beheer library.',
severity: 'error',
from: { path: '^libs/shared/src/' },
to: { path: '^libs/beheer/src/' },
},
{
name: `${appName}-no-other-app`,
comment: "An app may not import another app's source directly.",
severity: 'error',
from: { path: `^${appRoot}/` },
to: { path: '^apps/(?!' + appName + '/)' },
},
...Object.keys(contextAllowed).map(contextRule).filter(Boolean),
// --- Atomic-layer rules (dependencies point inward: ui → application → domain) ---
{
name: 'domain-is-pure',
comment: 'domain/ is framework-free business logic — no Angular.',
severity: 'error',
from: { path: `^${anyRoot}/.*/domain/` },
to: { path: 'node_modules/@angular/' },
},
{
name: 'contracts-import-nothing',
comment: 'contracts/ are pure wire DTO shapes — they import nothing (ADR-0001).',
severity: 'error',
from: { path: `^${anyRoot}/.*/contracts/` },
to: { pathNot: '/contracts/', path: `^(${anyRoot}/|node_modules/@angular/)` },
},
{
name: 'ui-not-infrastructure',
comment:
'ui/ + layout/ reach data through an application store/command, never infrastructure directly (type-only DTO imports allowed).',
severity: 'error',
from: {
path: `^${anyRoot}/.*(/ui/|/layout/)`,
pathNot: '\\.stories\\.ts$|\\.spec\\.ts$',
},
to: { path: '/infrastructure/', dependencyTypesNot: ['type-only'] },
},
{
name: 'apiclient-infrastructure-only',
comment:
'The generated ApiClient is a value only inside infrastructure/ (+ shared/upload); elsewhere type-only.',
severity: 'error',
from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' },
to: {
path: '^libs/shared/src/infrastructure/api-client\\.ts$',
dependencyTypesNot: ['type-only'],
},
},
// --- Hygiene (cheap wins a graph makes obvious) ---
{
name: 'no-circular',
comment: 'No cyclic dependencies.',
severity: 'error',
from: {},
to: { circular: true },
},
],
options: {
doNotFollow: { path: 'node_modules' },
tsConfig: { fileName: tsConfigFileName },
tsPreCompilationDeps: true, // needed so `type-only` imports are distinguished
enhancedResolveOptions: {
exportsFields: ['exports'],
conditionNames: ['import', 'require', 'node', 'default'],
},
reporterOptions: {
archi: { collapsePattern: '^(apps/[^/]+/src/app|libs/[^/]+/src)/[^/]+' },
},
},
};
};
+9
View File
@@ -0,0 +1,9 @@
// behandelportal's own context boundaries.
module.exports = require('./.dependency-cruiser.base.js')(
{
auth: [],
behandeling: [],
},
'behandelportal',
'apps/behandelportal/tsconfig.json',
);
-118
View File
@@ -1,118 +0,0 @@
// Dependency-cruiser (WP-38): the single declarative source for the app's bounded-context
// + atomic-layer boundaries — and the graph you can SEE (`npm run dep:graph`). Replaces the
// hand-duplicated `no-restricted-imports` blocks that had to be copied per context (and that
// left `herregistratie` without one). ESLint keeps only the rules dep-cruiser can't express
// (no-explicit-any, template a11y).
//
// Contexts: shared (base) · auth · registratie · herregistratie · brief · beheer · showcase.
// Allowed cross-context edges: everyone → shared; herregistratie → registratie; showcase → *
// (the sanctioned teaching page). Nobody imports showcase.
// Single source of truth for bounded-context boundaries: each entry maps a context name to the
// OTHER contexts it may additionally import (besides itself + shared). `showcase` maps to `null`
// — sanctioned to import every context (the teaching page); nothing else may import it. Add a
// context here — nowhere else — when scaffolding one (see `gen:context`, WP-44); FEATURES and
// every contextRule below are derived from this object.
const CONTEXT_ALLOWED = {
auth: [],
registratie: [],
herregistratie: ['registratie'], // the one sanctioned cross-feature edge
brief: [],
beheer: [],
showcase: null,
};
const FEATURES = Object.keys(CONTEXT_ALLOWED).join('|');
/** A context may import shared + itself + its allowed list; forbidden = every other context. */
const contextRule = (from) => {
const allowed = CONTEXT_ALLOWED[from];
if (allowed === null) return null; // unrestricted (showcase) — no rule to generate
const forbidden = Object.keys(CONTEXT_ALLOWED)
.filter((name) => name !== from && !allowed.includes(name))
.join('|');
return {
name: `${from}-scope`,
comment: `${from} may depend only on its allowed contexts (+ shared). See CLAUDE.md §1.`,
severity: 'error',
from: { path: `^src/app/${from}/` },
to: { path: `^src/app/(${forbidden})/` },
};
};
module.exports = {
forbidden: [
// --- Bounded-context direction (the "dependencies point inward" spine) ---
{
name: 'shared-no-features',
comment: 'shared/ is the base — it must not import any feature context.',
severity: 'error',
from: { path: '^src/app/shared/', pathNot: '^src/app/shared/ui/debug-state/' },
to: { path: `^src/app/(${FEATURES})/` },
},
...Object.keys(CONTEXT_ALLOWED).map(contextRule).filter(Boolean),
// showcase/ is exempt (reads every context by design); nothing imports it — covered by the
// rules above each forbidding `→ showcase`.
// --- Atomic-layer rules (dependencies point inward: ui → application → domain) ---
{
name: 'domain-is-pure',
comment: 'domain/ is framework-free business logic — no Angular.',
severity: 'error',
from: { path: '/domain/' },
to: { path: 'node_modules/@angular/' },
},
{
name: 'contracts-import-nothing',
comment: 'contracts/ are pure wire DTO shapes — they import nothing (ADR-0001).',
severity: 'error',
from: { path: '/contracts/' },
to: { pathNot: '/contracts/', path: '^(src/app/|node_modules/@angular/)' },
},
{
name: 'ui-not-infrastructure',
comment:
'ui/ + layout/ reach data through an application store/command, never infrastructure directly (type-only DTO imports allowed).',
severity: 'error',
from: {
path: '(/ui/|/layout/)',
pathNot: '\\.stories\\.ts$|\\.spec\\.ts$|^src/app/shared/ui/debug-state/',
},
to: { path: '/infrastructure/', dependencyTypesNot: ['type-only'] },
},
{
name: 'apiclient-infrastructure-only',
comment:
'The generated ApiClient is a value only inside infrastructure/ (+ shared/upload); elsewhere type-only.',
severity: 'error',
from: { pathNot: '/infrastructure/|^src/app/shared/upload/' },
to: {
path: '^src/app/shared/infrastructure/api-client\\.ts$',
dependencyTypesNot: ['type-only'],
},
},
// --- Hygiene (cheap wins a graph makes obvious) ---
{
name: 'no-circular',
comment: 'No cyclic dependencies.',
severity: 'error',
from: {},
to: { circular: true },
},
],
options: {
doNotFollow: { path: 'node_modules' },
tsConfig: { fileName: 'tsconfig.json' }, // resolves @shared/@registratie/… path aliases
tsPreCompilationDeps: true, // needed so `type-only` imports are distinguished
enhancedResolveOptions: {
exportsFields: ['exports'],
conditionNames: ['import', 'require', 'node', 'default'],
},
reporterOptions: {
// Context-level architecture graph for `npm run dep:graph` (mermaid — no graphviz needed).
archi: { collapsePattern: '^src/app/[^/]+/[^/]+' },
},
},
};
+13
View File
@@ -0,0 +1,13 @@
// The SSP's own context boundaries. Add a context here — nowhere else — when
// scaffolding one (see `gen:context`, WP-44).
module.exports = require('./.dependency-cruiser.base.js')(
{
auth: [],
registratie: [],
herregistratie: ['registratie'], // the one sanctioned cross-feature edge
brief: [],
showcase: null, // unrestricted — the sanctioned teaching page; nothing imports it
},
'ssp',
'apps/ssp/tsconfig.json',
);
+1 -1
View File
@@ -4,7 +4,7 @@
.git
node_modules
dist
storybook-static
storybook-static*
backend/**/bin
backend/**/obj
backend/**/bigregister.db*
+17 -11
View File
@@ -35,16 +35,17 @@ jobs:
with:
filters: |
frontend:
- 'src/**'
- 'apps/**'
- 'libs/**'
- 'public/**'
- 'e2e/**'
- 'scripts/**'
- 'angular.json'
- 'package*.json'
- 'tsconfig*.json'
- '.storybook/**'
- '.storybook*/**'
- 'eslint.config.mjs'
- '.dependency-cruiser.js'
- '.dependency-cruiser*.js'
- '.github/workflows/**'
backend:
- 'backend/**'
@@ -104,16 +105,17 @@ jobs:
- run: npm run dep:check
if: needs.changes.outputs.frontend == 'true'
# Showcase snippets must match their real source regions (WP-39, no drift).
- run: npm run gen:snippets && git diff --exit-code src/app/showcase/snippets.generated.ts
- run: npm run gen:snippets && git diff --exit-code apps/ssp/src/app/showcase/snippets.generated.ts
if: needs.changes.outputs.frontend == 'true'
# Runs the full suite AND reports coverage (WP-46, report-only — no thresholds, so
# it can't fail on coverage; it still fails on a failing test, like `npm test` did).
# Runs the full suite (both apps + both shared libraries, WP-67) AND reports coverage
# (WP-46, report-only — no thresholds, so it can't fail on coverage; it still fails on
# a failing test, like `npm test` did).
- run: npm run test:coverage
if: needs.changes.outputs.frontend == 'true'
# --localize builds every configured locale (nl + en, angular.json's i18n
# block) in one pass; i18nMissingTranslation:"error" (angular.json) fails
# this step if messages.en.xlf is missing a unit the source (WP-20) gains.
- run: npx ng build --localize
# --localize builds every configured locale (nl + en, angular.json's i18n block) in one
# pass per app; i18nMissingTranslation:"error" (angular.json) fails this step if either
# app's messages.en.xlf is missing a unit its source (WP-20) or libs/shared gains.
- run: npx ng build ssp --localize && npx ng build behandelportal --localize
if: needs.changes.outputs.frontend == 'true'
# The shipped bundle must stay clean; dev-only advisories are excluded.
- run: npm audit --omit=dev
@@ -166,6 +168,10 @@ jobs:
if: needs.changes.outputs.frontend == 'true'
- run: npm run test-storybook:ci
if: needs.changes.outputs.frontend == 'true'
- run: npm run build-storybook:behandelportal
if: needs.changes.outputs.frontend == 'true'
- run: npm run test-storybook:ci:behandelportal
if: needs.changes.outputs.frontend == 'true'
backend:
needs: changes
@@ -300,5 +306,5 @@ jobs:
if: (needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true') && steps.node-modules-cache.outputs.cache-hit != 'true'
- run: npm run gen:api
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true'
- run: git diff --exit-code src/app/shared/infrastructure/api-client.ts backend/swagger.json
- run: git diff --exit-code libs/shared/src/infrastructure/api-client.ts backend/swagger.json
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true'
+2 -1
View File
@@ -44,7 +44,8 @@ __screenshots__/
Thumbs.db
*storybook.log
storybook-static
storybook-static*
documentation.json
# Playwright e2e
/test-results
+3 -3
View File
@@ -1,6 +1,6 @@
# Build output & caches
dist/
storybook-static/
storybook-static*/
coverage/
.angular/
@@ -9,8 +9,8 @@ package-lock.json
# Generated — owned by their generators, not prettier
documentation.json
src/app/shared/infrastructure/api-client.ts
src/app/showcase/snippets.generated.ts
libs/shared/src/infrastructure/api-client.ts
apps/ssp/src/app/showcase/snippets.generated.ts
# Vendored design system (CIBG Huisstijl)
public/cibg-huisstijl/
+29
View File
@@ -0,0 +1,29 @@
import type { StorybookConfig } from '@storybook/angular';
import remarkGfm from 'remark-gfm';
// WP-67: see .storybook-ssp/main.ts's comment for why this is a separate config dir.
const config: StorybookConfig = {
stories: [
'../apps/behandelportal/src/**/*.mdx',
'../libs/shared/**/*.mdx',
'../libs/beheer/**/*.mdx',
'../apps/behandelportal/src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
'../libs/shared/**/*.stories.@(js|jsx|mjs|ts|tsx)',
'../libs/beheer/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
addons: [
'@storybook/addon-a11y',
{
name: '@storybook/addon-docs',
options: { mdxPluginOptions: { mdxCompileOptions: { remarkPlugins: [remarkGfm] } } },
},
'@storybook/addon-onboarding',
],
framework: '@storybook/angular',
staticDirs: ['../public'],
webpackFinal: async (config) => {
config.optimization = { ...config.optimization, nodeEnv: false };
return config;
},
};
export default config;
+53
View File
@@ -0,0 +1,53 @@
import type { Preview } from '@storybook/angular';
import { componentWrapperDecorator } from '@storybook/angular';
import { setCompodocJson } from '@storybook/addon-docs/angular';
import docJson from './documentation.json';
setCompodocJson(docJson);
if (typeof document !== 'undefined') document.body.classList.add('brand--cibg');
const preview: Preview = {
decorators: [componentWrapperDecorator((story) => `<div style="padding:1.5rem">${story}</div>`)],
parameters: {
layout: 'padded',
a11y: {
config: { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] } },
},
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
// Sidebar tells the DDD seam: reusable design system, then domain contexts. The
// "Foundations" MDX pages live in libs/shared/docs and apply to either app's Storybook.
options: {
storySort: {
order: [
'Foundations',
[
'Overview',
'Learning Path',
'Domain-Driven Design',
'Atomic Design',
'FP in the UI',
'State Machines (TEA)',
'RemoteData & Async',
"Parse, don't validate",
'Design Tokens',
'CIBG Gap Register',
'Accessibility',
'Testing strategy',
'BDD',
'Internationalization',
],
'Design System',
['Atoms', 'Molecules', 'Organisms', 'Templates', 'Devtools'],
'Domein',
],
},
},
},
};
export default preview;
@@ -0,0 +1,16 @@
// This tsconfig is used by Compodoc to generate the documentation for the project.
// If Compodoc is not used, this file can be deleted.
{
"extends": "./tsconfig.json",
"exclude": [
"../apps/behandelportal/src/test.ts",
"../apps/behandelportal/src/**/*.spec.ts",
"../apps/behandelportal/src/**/*.stories.ts",
"../libs/shared/**/*.spec.ts",
"../libs/shared/**/*.stories.ts",
"../libs/beheer/**/*.spec.ts",
"../libs/beheer/**/*.stories.ts"
],
"include": ["../apps/behandelportal/src/**/*", "../libs/shared/**/*", "../libs/beheer/**/*"],
"files": ["./typings.d.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "../apps/behandelportal/tsconfig.app.json",
"compilerOptions": {
"types": ["node", "@angular/localize"],
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"moduleResolution": "bundler"
},
"exclude": ["../apps/behandelportal/src/test.ts", "../apps/behandelportal/src/**/*.spec.ts"],
"include": [
"../apps/behandelportal/src/**/*.stories.*",
"../libs/shared/**/*.stories.*",
"../libs/beheer/**/*.stories.*",
"./preview.ts"
],
"files": ["./typings.d.ts"]
}
+12 -1
View File
@@ -1,8 +1,19 @@
import type { StorybookConfig } from '@storybook/angular';
import remarkGfm from 'remark-gfm';
// WP-67: one config dir per app — a single merged tsconfig can't resolve both apps' @auth/*
// alias at once (each points at a different physical directory), so this Storybook instance
// only ever compiles under the ssp project's own browserTarget/tsconfig (see angular.json's
// ssp:storybook target) and only globs ssp's own stories + the shared libraries'.
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
stories: [
'../apps/ssp/src/**/*.mdx',
'../libs/shared/**/*.mdx',
'../libs/beheer/**/*.mdx',
'../apps/ssp/src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
'../libs/shared/**/*.stories.@(js|jsx|mjs|ts|tsx)',
'../libs/beheer/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
addons: [
'@storybook/addon-a11y',
// remark-gfm so GFM pipe tables in *.mdx docs actually render (addon-docs
+5
View File
@@ -0,0 +1,5 @@
<!-- CIBG Huisstijl (customized Bootstrap 5.2), served from the vendored public/ staticDir.
Mirrors src/index.html so stories match the app. System font per styles.scss. -->
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
<!-- Letter-rendering contract (WP-24), mirrors index.html. -->
<link rel="stylesheet" href="letter.css" />
@@ -1,7 +1,7 @@
import type { Preview } from '@storybook/angular';
import { componentWrapperDecorator } from '@storybook/angular';
import { setCompodocJson } from '@storybook/addon-docs/angular';
import docJson from '../documentation.json';
import docJson from './documentation.json';
setCompodocJson(docJson);
// Activate CIBG's official palette in the story iframe. The override is `body.brand--cibg`,
@@ -26,7 +26,7 @@ const preview: Preview = {
},
},
// Sidebar tells the DDD seam: reusable design system, then domain contexts.
// See src/docs/layers.mdx.
// See libs/shared/docs/layers.mdx.
options: {
storySort: {
order: [
+21
View File
@@ -0,0 +1,21 @@
import type { TestRunnerConfig } from '@storybook/test-runner';
import { getStoryContext } from '@storybook/test-runner';
import { injectAxe, checkA11y } from 'axe-playwright';
const config: TestRunnerConfig = {
async preVisit(page) {
await injectAxe(page);
},
async postVisit(page, context) {
const storyContext = await getStoryContext(page, context);
if (storyContext.parameters?.a11y?.disable) return;
await checkA11y(page, '#storybook-root', {
axeOptions: storyContext.parameters?.a11y?.config,
detailedReport: true,
detailedReportOptions: { html: true },
});
},
};
export default config;
@@ -3,8 +3,16 @@
{
"extends": "./tsconfig.json",
// Exclude all files that are not needed for documentation generation.
"exclude": ["../src/test.ts", "../src/**/*.spec.ts", "../src/**/*.stories.ts"],
"exclude": [
"../apps/ssp/src/test.ts",
"../apps/ssp/src/**/*.spec.ts",
"../apps/ssp/src/**/*.stories.ts",
"../libs/shared/**/*.spec.ts",
"../libs/shared/**/*.stories.ts",
"../libs/beheer/**/*.spec.ts",
"../libs/beheer/**/*.stories.ts"
],
// Please make sure to include all files from which Compodoc should generate documentation.
"include": ["../src/**/*"],
"include": ["../apps/ssp/src/**/*", "../libs/shared/**/*", "../libs/beheer/**/*"],
"files": ["./typings.d.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "../apps/ssp/tsconfig.app.json",
"compilerOptions": {
"types": ["node", "@angular/localize"],
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"moduleResolution": "bundler"
},
"exclude": ["../apps/ssp/src/test.ts", "../apps/ssp/src/**/*.spec.ts"],
"include": [
"../apps/ssp/src/**/*.stories.*",
"../libs/shared/**/*.stories.*",
"../libs/beheer/**/*.stories.*",
"./preview.ts"
],
"files": ["./typings.d.ts"]
}
+4
View File
@@ -0,0 +1,4 @@
declare module '*.md' {
const content: string;
export default content;
}
-12
View File
@@ -1,12 +0,0 @@
{
"extends": "../tsconfig.app.json",
"compilerOptions": {
"types": ["node", "@angular/localize"],
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"moduleResolution": "bundler"
},
"exclude": ["../src/test.ts", "../src/**/*.spec.ts"],
"include": ["../src/**/*.stories.*", "./preview.ts"],
"files": ["./typings.d.ts"]
}
+84 -42
View File
@@ -14,20 +14,34 @@ typed client. The FE renders the backend's decisions. Reference data mimicking
BRP/DUO (`Data/SeedData.cs`) is in-memory; applications, documents and the brief
persist to a SQLite file via EF Core (WP-22) — `docs/project/backlog/WP-22-durable-persistence.md`.
**Monorepo (WP-67):** two Angular projects share one backend + one shared library —
`apps/ssp` (Zorgverlener self-service, this doc's main subject) and `apps/behandelportal`
(Behandelaar backoffice, ADR-0002). Both import `libs/shared` (design system + kernel +
generated API client) and `libs/beheer` (the admin/stamdata context, used identically by
both). `backend/` is unowned by either — a genuinely shared dependency.
## Commands
```bash
npm start # ng serve (proxies /api → backend) → http://localhost:4200
npm test # vitest
npm run lint # eslint — enforces `any`-free code + import/layer boundaries
npm run build # ng build (must stay green)
npm run storybook # component library by atomic layer
npm run gen:api # regenerate the typed client from the backend OpenAPI doc
npm run ci # run the CI gate locally BEFORE pushing (mirrors ci.yml); `npm run ci --full` adds storybook-a11y
docker compose up # run FE + backend together (Swagger at :5000/swagger)
cd backend && dotnet test # backend rule + endpoint tests
npm start # ng serve ssp (proxies /api → backend) → http://localhost:4200
npm run start:behandelportal # ng serve behandelportal → http://localhost:4201
npm test # vitest — both apps + both shared libraries (ssp, behandelportal, shared, beheer)
npm run lint # eslint — enforces `any`-free code + import/layer boundaries
npm run build # ng build ssp && ng build behandelportal (must stay green)
npm run storybook # ssp's component library by atomic layer
npm run storybook:behandelportal # behandelportal's own instance (see "Monorepo" note below)
npm run gen:api # regenerate the ONE typed client (libs/shared) from the backend OpenAPI doc
npm run ci # run the CI gate locally BEFORE pushing (mirrors ci.yml); `npm run ci --full` adds storybook-a11y
docker compose up # run both FE apps + backend together (Swagger at :5000/swagger)
cd backend && dotnet test # backend rule + endpoint tests
```
**Two Storybook instances, not one:** `apps/ssp` and `apps/behandelportal` each have their own
`auth` context at the same `@auth/*` alias pointing at different physical directories — a single
merged tsconfig can't resolve both at once, so `.storybook-ssp/` and `.storybook-behandelportal/`
are separate config dirs (`npm run storybook[:behandelportal]` / `build-storybook[:behandelportal]`),
each globbing its own app's stories + both shared libraries'.
**Run `npm run ci` before every push** (`scripts/ci-local.sh`) — it runs the same jobs
Gitea CI does (lint, format:check, check:tokens, test, `ng build --localize`, audit, backend
format+test, api-client drift), so a red build is caught locally. Two ways to make it
@@ -65,9 +79,17 @@ session protocol is the worked example of this in practice.
### 1. DDD: contexts then layers, dependencies point inward
`src/app/<context>/<layer>/`. Contexts: `shared`, `auth`, `registratie`,
`herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching
page, not a feature; **sanctioned** to read every context — nothing imports it).
`apps/<app>/src/app/<context>/<layer>/` for an app-local context; `libs/<lib>/src/<layer>/`
for a cross-app library (WP-67). Two apps today: `apps/ssp` (Zorgverlener self-service —
contexts `auth`, `registratie`, `herregistratie`, `brief` (letter-composition teaching
slice), `showcase` (teaching page, not a feature; **sanctioned** to read every context in
its own app — nothing imports it)) and `apps/behandelportal` (Behandelaar backoffice,
ADR-0002 — contexts `auth`, `behandeling`). Two cross-app libraries: `libs/shared` (the
design system + kernel + generated API client — no business logic) and `libs/beheer` (the
admin/stamdata context, identical for both apps today — WP-67 folded a silently-diverging
duplicate copy back into one). `auth` is deliberately **not** shared even though today it's
near-identical in both apps — ADR-0002 models Zorgverlener/Medewerker as different
`Principal` variants with different login flows; the two copies are expected to diverge.
| Layer | Job | Angular allowed? |
| ----------------- | ----------------------------------------- | -------------------------------- |
@@ -77,17 +99,28 @@ page, not a feature; **sanctioned** to read every context — nothing imports it
| `contracts/` | wire DTOs (the FE⇄BE seam) | no |
| `ui/` | how it looks (components, pages) | yes |
**Dependencies only point inward**: `ui → application → domain`; everyone may use
`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.
**Dependencies only point inward**: `ui → application → domain`; every context in either
app may use `libs/shared` and `libs/beheer`; never the reverse (`libs/shared` may not
depend on `libs/beheer` either — it stays the base). `ui`/`layout` never import
`infrastructure` directly (reach data through an application store/command) —
lint-enforced (per app, since each app is cruised against its own tsconfig — WP-67's
`.dependency-cruiser.base.js` + one thin `.dependency-cruiser.<app>.js` per app). An app
may not import the other app's source directly. Cross-context only
`herregistratie → registratie → libs/shared|beheer`, `auth → libs/shared|beheer`,
`brief → libs/shared|beheer` (ssp); `behandeling → libs/shared|beheer`, `auth →
libs/shared|beheer` (behandelportal). Imports use aliases as direction statements:
`@shared/* @beheer/* @auth/* @registratie/* @herregistratie/* @brief/*` (ssp) —
`@shared/* @beheer/* @auth/* @behandeling/*` (behandelportal); each app's own
`tsconfig.json` declares its full map (the root `tsconfig.json` intentionally has no
`paths` — see its comment). `domain/` imports nothing from Angular.
### 2. Atomic design: folder = layer
`shared/ui` atoms → molecules → organisms; `shared/layout` templates (`shell`,
`page-shell`); context `ui/` pages. Each level only uses levels below. A new page
`libs/shared/ui` atoms → molecules → organisms; `libs/shared/layout` templates (`shell`,
`page-shell`); each app's own context `ui/` pages. Each level only uses levels below,
and a shared component takes nav/copy as `input()`s or an injection token (e.g.
`HEADER_NAV_ITEMS`/`HEADER_ADMIN_LINKS`, `DEBUG_PANEL` in `shell.component.ts`) rather
than hardcoding one app's content — the two apps' primary nav genuinely differs. A new page
should be **composition of existing blocks** — adding building blocks is the
exception, not the default. Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2)
CSS classes (`btn`, `form-control`, `card`, …); we own only a small typed `input()` API,
@@ -97,11 +130,11 @@ small hand-rolled surface built from the token bridge; see ADR-0003.)
### 3. State: make illegal states unrepresentable
Default reflex — **if you're about to add a second/third boolean to track state,
model a discriminated union instead.** Three tools, all in `shared/application`:
model a discriminated union instead.** Three tools, all in `libs/shared/src/application`:
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`.
Combine sources with `map`/`map2`/`andThen` (Failure > Loading > Success).
Render it via the `<app-async>` molecule (`shared/ui/async`) — one of four
Render it via the `<app-async>` molecule (`libs/shared/src/ui/async`) — one of four
templates, mutually exclusive by construction. Default loading spinner/skeleton
is delay-gated (~250ms) so fast connections don't flash.
- **Elm-style store** (`store.ts``createStore(initial, reduce)`) — all state in
@@ -116,8 +149,9 @@ model a discriminated union instead.** Three tools, all in `shared/application`:
see `upload.machine.ts`) — prefixing there avoids alias noise at the
composition site.
- **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a
branded type only via a parser returning `Result` (`registratie/domain/value-objects/`:
`Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it.
branded type only via a parser returning `Result` (ssp's
`registratie/domain/value-objects/`: `Postcode`, `Uren`, `BigNummer`). Once you hold
the type, never re-check it.
**Derive, don't store** what you can compute — e.g. the wizard's visible steps are
`visibleSteps(answers)`, not a stored field (`intake.machine.ts`).
@@ -158,12 +192,15 @@ fails CI, never prod) — never runtime-editable. Org-templates are the delibera
Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters).
Test the pure function directly — no Angular TestBed for domain. UI is exercised via
Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests.
**Story titles mirror the sidebar's Design System/Domein split** (see
`src/docs/layers.mdx`): a `shared/ui`/`shared/layout` component is titled
`Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`; a component in a
context's `ui/` is titled `Domein/<Context>/<Name>` — full stop, regardless of which
atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests
each app has its **own Storybook instance** (`.storybook-ssp/`, `.storybook-behandelportal/`,
WP-67 — a single merged tsconfig can't resolve both apps' `@auth/*` at once), each globbing
its own app's stories plus both shared libraries'. **Story titles mirror the sidebar's
Design System/Domein split** (see `libs/shared/docs/layers.mdx`): a `libs/shared/ui|layout`
or `libs/beheer/ui` component is titled `Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`;
a component in an app context's `ui/` is titled `Domein/<Context>/<Name>` — full stop,
regardless of which atomic layer it is (a context organism doesn't get its own
`Organisms/` bucket).
## Conventions
@@ -181,7 +218,7 @@ atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
(`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a
translation file, not a code change (the seam). Shared/English components must **not**
hardcode Dutch — expose copy as `input()`s with localizable defaults; the domain caller
supplies the text (see `shared/ui/async`). Format-validation messages in
supplies the text (see `libs/shared/src/ui/async`). Format-validation messages in
`domain/value-objects/` stay co-located but are still `$localize`-wrapped.
- **Forms = one idiom.** Any form with validation or submission uses a `*.machine.ts`
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
@@ -190,12 +227,13 @@ atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
- **Dates: `DatePipe` in templates, `formatDatumNl` in pure TS.** A template formats a
date with Angular's `DatePipe` (`| date: 'longDate'`); pure TS that can't reach a pipe
(a domain function, a `$localize` string) uses the one hand-written
`formatDatumNl` (`shared/kernel/datum.ts`). Never a third hand-rolled
`formatDatumNl` (`libs/shared/src/kernel/datum.ts`). Never a third hand-rolled
`toLocaleDateString` call.
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate:
[authGuard]` on protected routes (`app.routes.ts`).
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent (`libs/shared`),
`canActivate: [authGuard]` on protected routes (each app's own `app.routes.ts`).
- Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under
`public/cibg-huisstijl/` and loaded via a `<link>` in `index.html`; `src/styles.scss` holds a
`public/cibg-huisstijl/` and loaded via a `<link>` in each app's `index.html`;
`libs/shared/styles.scss` (one copy, both apps' `angular.json` point at it — WP-67) holds a
**token bridge** mapping the app's `--rhc-*` token vocabulary onto CIBG/`--bs-*` values (so
components keep referencing tokens). System-font stack (licensed RO/Rijks fonts not shipped). See ADR-0003.
- Scenario toggle (**dev-only**, not wired in prod builds): `?scenario=slow|loading|empty|error`
@@ -209,18 +247,22 @@ atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
`/beheer/stamdata`, `/beheer/zaken`, `/beheer/audit`, `/beheer/functies`.
- Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`,
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
- **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`) fails the build
on `any` and on illegal imports — `domain/` importing Angular, or a context importing
"upward" (the `herregistratie → registratie → shared`, `auth → shared` direction).
CI (`.github/workflows/ci.yml`) runs lint + `check:tokens` + test + build, backend
`dotnet test`, and an API-client drift check.
- **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`, scoped to
`{apps,libs}/**`) fails the build on `any`; `npm run dep:check`
(`.dependency-cruiser.base.js` + one `.dependency-cruiser.<app>.js` per app, WP-67) fails
on illegal imports — `domain/` importing Angular, a context importing "upward" (the
`herregistratie → registratie → shared`, `auth → shared` direction), an app importing the
other app's source, or `libs/shared` depending on `libs/beheer`. CI
(`.github/workflows/ci.yml`) runs lint + `dep:check` + `check:tokens` + test (both apps +
both libraries) + build (both apps), backend `dotnet test`, and an API-client drift check
(one generated client, `libs/shared/src/infrastructure/api-client.ts`).
## Adding a feature (recipe)
Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter:
`httpResource` or command returning `Result`) → application (store if shared state;
union + pure reduce) → UI last (compose `shared/ui` atoms, wrap async in `<app-async>`,
dispatch messages). Worked example: the intake wizard (`herregistratie/`).
union + pure reduce) → UI last (compose `libs/shared/ui` atoms, wrap async in
`<app-async>`, dispatch messages). Worked example: the SSP's intake wizard (`herregistratie/`).
The recipes are also invocable skills in `.claude/skills/`: `new-feature`,
`new-context`, `value-object`, `form-machine`, `bff-endpoint`, `mutation-command`,
+228 -20
View File
@@ -7,21 +7,21 @@
},
"newProjectRoot": "projects",
"projects": {
"atomic-design-poc": {
"ssp": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"root": "apps/ssp",
"sourceRoot": "apps/ssp/src",
"prefix": "app",
"i18n": {
"sourceLocale": { "code": "nl", "subPath": "" },
"locales": {
"en": {
"translation": "src/locale/messages.en.xlf"
"translation": "apps/ssp/src/locale/messages.en.xlf"
}
}
},
@@ -29,8 +29,8 @@
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"browser": "apps/ssp/src/main.ts",
"tsConfig": "apps/ssp/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
@@ -38,7 +38,7 @@
"input": "public"
}
],
"styles": ["src/styles.scss"],
"styles": ["libs/shared/styles.scss"],
"polyfills": ["@angular/localize/init"],
"i18nMissingTranslation": "error"
},
@@ -59,8 +59,8 @@
"outputHashing": "all",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
"replace": "libs/shared/src/environments/environment.ts",
"with": "libs/shared/src/environments/environment.prod.ts"
}
]
},
@@ -77,21 +77,27 @@
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "apps/ssp/proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "atomic-design-poc:build:production"
"buildTarget": "ssp:build:production"
},
"development": {
"buildTarget": "atomic-design-poc:build:development"
"buildTarget": "ssp:build:development"
},
"en": {
"buildTarget": "atomic-design-poc:build:development,en"
"buildTarget": "ssp:build:development,en"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "apps/ssp/tsconfig.spec.json"
},
"configurations": {
"coverage": {
"coverage": true,
@@ -100,9 +106,8 @@
"**/*.spec.ts",
"**/*.stories.ts",
"**/contracts/**",
"src/app/shared/infrastructure/api-client.ts",
"src/main.ts",
"src/test-setup.ts",
"libs/shared/src/infrastructure/api-client.ts",
"apps/ssp/src/main.ts",
"**/*.d.ts"
]
}
@@ -111,8 +116,8 @@
"storybook": {
"builder": "@storybook/angular:start-storybook",
"options": {
"configDir": ".storybook",
"browserTarget": "atomic-design-poc:build",
"configDir": ".storybook-ssp",
"browserTarget": "ssp:build",
"compodoc": false,
"port": 6006
}
@@ -120,14 +125,217 @@
"build-storybook": {
"builder": "@storybook/angular:build-storybook",
"options": {
"configDir": ".storybook",
"browserTarget": "atomic-design-poc:build",
"configDir": ".storybook-ssp",
"browserTarget": "ssp:build",
"compodoc": true,
"compodocArgs": ["-e", "json", "-d", "."],
"compodocArgs": ["-e", "json", "-d", ".storybook-ssp"],
"outputDir": "storybook-static"
}
}
}
},
"behandelportal": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "apps/behandelportal",
"sourceRoot": "apps/behandelportal/src",
"prefix": "app",
"i18n": {
"sourceLocale": { "code": "nl", "subPath": "" },
"locales": {
"en": {
"translation": "apps/behandelportal/src/locale/messages.en.xlf"
}
}
},
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "apps/behandelportal/src/main.ts",
"tsConfig": "apps/behandelportal/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": ["libs/shared/styles.scss"],
"polyfills": ["@angular/localize/init"],
"i18nMissingTranslation": "error"
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1.5MB",
"maximumError": "2MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all",
"fileReplacements": [
{
"replace": "libs/shared/src/environments/environment.ts",
"with": "libs/shared/src/environments/environment.prod.ts"
}
]
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
},
"en": {
"localize": ["en"]
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"port": 4201,
"proxyConfig": "apps/behandelportal/proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "behandelportal:build:production"
},
"development": {
"buildTarget": "behandelportal:build:development"
},
"en": {
"buildTarget": "behandelportal:build:development,en"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "apps/behandelportal/tsconfig.spec.json"
},
"configurations": {
"coverage": {
"coverage": true,
"coverageReporters": ["text-summary", "html", "lcov"],
"coverageExclude": [
"**/*.spec.ts",
"**/*.stories.ts",
"**/contracts/**",
"libs/shared/src/infrastructure/api-client.ts",
"apps/behandelportal/src/main.ts",
"**/*.d.ts"
]
}
}
},
"storybook": {
"builder": "@storybook/angular:start-storybook",
"options": {
"configDir": ".storybook-behandelportal",
"browserTarget": "behandelportal:build",
"compodoc": false,
"port": 6007
}
},
"build-storybook": {
"builder": "@storybook/angular:build-storybook",
"options": {
"configDir": ".storybook-behandelportal",
"browserTarget": "behandelportal:build",
"compodoc": true,
"compodocArgs": ["-e", "json", "-d", ".storybook-behandelportal"],
"outputDir": "storybook-static-behandelportal"
}
}
}
},
"shared": {
"projectType": "library",
"root": "libs/shared",
"sourceRoot": "libs/shared/src",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "libs/shared/src/test-entry.ts",
"tsConfig": "libs/shared/tsconfig.app.json"
},
"configurations": {
"development": {}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "libs/shared/tsconfig.spec.json"
},
"configurations": {
"coverage": {
"coverage": true,
"coverageReporters": ["text-summary", "html", "lcov"],
"coverageExclude": [
"**/*.spec.ts",
"**/*.stories.ts",
"**/contracts/**",
"src/infrastructure/api-client.ts",
"src/test-entry.ts",
"**/*.d.ts"
]
}
}
}
}
},
"beheer": {
"projectType": "library",
"root": "libs/beheer",
"sourceRoot": "libs/beheer/src",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "libs/beheer/src/test-entry.ts",
"tsConfig": "libs/beheer/tsconfig.app.json"
},
"configurations": {
"development": {}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "libs/beheer/tsconfig.spec.json"
},
"configurations": {
"coverage": {
"coverage": true,
"coverageReporters": ["text-summary", "html", "lcov"],
"coverageExclude": [
"**/*.spec.ts",
"**/*.stories.ts",
"**/contracts/**",
"src/test-entry.ts",
"**/*.d.ts"
]
}
}
}
}
}
}
}
+72
View File
@@ -0,0 +1,72 @@
import {
ApplicationConfig,
LOCALE_ID,
isDevMode,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter, withInMemoryScrolling, withViewTransitions } from '@angular/router';
import type { ActivatedRouteSnapshot } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { registerLocaleData } from '@angular/common';
import localeNl from '@angular/common/locales/nl';
import localeEn from '@angular/common/locales/en';
import { routes } from './app.routes';
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
import { medewerkerInterceptor } from '@auth/infrastructure/medewerker.interceptor';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { SESSION_PORT } from '@shared/application/session.port';
import { SessionStore } from '@auth/application/session.store';
import { provideRouteFocus } from '@shared/layout/route-focus';
import { provideUnloadFlush } from '@shared/application/pending-saves';
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from '@shared/layout/site-header/nav-config';
import { ADMIN_LINKS, NAV_ITEMS } from './shell/nav.config';
// Both locales' data so DatePipe/number pipes work for whichever bundle is active.
registerLocaleData(localeNl);
registerLocaleData(localeEn);
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(
routes,
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
// Cross-fade page-to-page navigations only. A silent same-route nav — e.g.
// draft-sync stamping `?aanvraag=<id>` into the URL mid-wizard — must NOT
// animate: for the transition's duration Firefox's `::view-transition`
// overlay swallows pointer events (Chrome sets pointer-events:none, so it
// doesn't), which loses a click landing on it and makes the wizard's "next"
// button need a second click. Skip the transition when the route is unchanged.
withViewTransitions({
onViewTransitionCreated: ({ transition, from, to }) => {
// `from`/`to` are the ROOT snapshots (the shared shell), so descend to the
// leaf before comparing — otherwise every navigation looks "same route".
const leaf = (r: ActivatedRouteSnapshot) => {
while (r.firstChild) r = r.firstChild;
return r;
};
if (leaf(from).routeConfig === leaf(to).routeConfig) transition.skipTransition();
},
}),
),
// Dev-only: the ?scenario= toggle must never reach a production build, where
// a query param could otherwise force errors on the live app.
provideHttpClient(
withInterceptors(
isDevMode() ? [scenarioInterceptor, roleInterceptor, medewerkerInterceptor] : [],
),
),
provideApiClient(),
{ provide: SESSION_PORT, useExisting: SessionStore },
// Per-bundle locale: the localize build sets `$localize.locale` ('nl'/'en'); the
// non-localized dev/source build leaves it undefined → fall back to 'nl'. (Was hardcoded
// 'nl', which mis-formatted dates/numbers in the en bundle.)
{ provide: LOCALE_ID, useFactory: () => $localize.locale ?? 'nl' },
provideRouteFocus(),
provideUnloadFlush(),
{ provide: HEADER_NAV_ITEMS, useValue: NAV_ITEMS },
{ provide: HEADER_ADMIN_LINKS, useValue: ADMIN_LINKS },
],
};
+54
View File
@@ -0,0 +1,54 @@
import { Routes } from '@angular/router';
import { ShellComponent } from '@shared/layout/shell/shell.component';
import { authGuard, capabilityGuard } from '@auth/auth.guard';
export const routes: Routes = [
{
path: '',
component: ShellComponent, // persistent header/footer; only children swap
children: [
{ path: '', pathMatch: 'full', redirectTo: 'login' },
{
path: 'login',
loadComponent: () => import('@auth/ui/login.page').then((m) => m.LoginPage),
},
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () =>
import('@behandeling/ui/werkvoorraad.page').then((m) => m.WerkvoorraadPage),
},
{
path: 'aanvraag/:id',
// Same capability the werkvoorraad list itself is gated by (WP-64/65) — the
// detail page is reachable only from a row already filtered to that capability.
canActivate: [capabilityGuard('aanvraag:beoordelen')],
loadComponent: () =>
import('@behandeling/ui/beoordeling.page').then((m) => m.BeoordelingPage),
},
{
path: 'beheer/stamdata',
// Admin-only stamdata maintenance editor (ADR-0004): capabilityGuard denies-by-default
// unless GET /me resolved `stamdata:edit` (Admin role). Backend re-enforces via the
// StamdataAdmin gate — the guard just avoids loading a page that would 403.
canActivate: [capabilityGuard('stamdata:edit')],
loadComponent: () => import('@beheer/ui/stamdata.page').then((m) => m.StamdataPage),
},
{
path: 'beheer/audit',
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
canActivate: [capabilityGuard('cases:manage')],
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
},
{
path: 'beheer/functies',
// Admin-only feature-flag toggles (WP-47), gated by `flags:manage`.
canActivate: [capabilityGuard('flags:manage')],
loadComponent: () =>
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
},
{ path: '**', redirectTo: 'login' },
],
},
];
@@ -0,0 +1,19 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { MEDEWERKER_ID, currentRollen } from './medewerker';
/**
* Dev-only: stamps every API request as the fixed stand-in medewerker (`X-Medewerker`/
* `X-Rollen`), so `StubIdentityProvider` resolves a `MedewerkerCaller` instead of falling
* through to its zorgverlener default. Unlike `roleInterceptor`'s allow-listed endpoints,
* this is the app's whole identity — every request needs it, since this app has no
* citizen-scoped screens to keep separate (see `CallerIdentity.Zorgverlener()`'s guard: a
* medewerker hitting a citizen-scoped SSP endpoint would 500, but no such endpoint exists
* here). Real employee-SSO login is out of scope for this POC (ADR-0002 §3 — the two
* apps' login flows are expected to diverge; this stand-in is that flow's placeholder).
*/
export const medewerkerInterceptor: HttpInterceptorFn = (req, next) =>
req.url.includes('/api/v1/')
? next(
req.clone({ setHeaders: { 'X-Medewerker': MEDEWERKER_ID, 'X-Rollen': currentRollen() } }),
)
: next(req);
@@ -0,0 +1,23 @@
/**
* Dev-only medewerker rollen stand-in (the reading MECHANISM — mirrors
* `@shared/infrastructure/role.ts`'s `?role=` idiom, but app-local: `auth` is
* deliberately not shared between ssp and behandelportal, ADR-0002 §3). Until a real
* employee-SSO login exists, every request from this app identifies as one fixed
* medewerker; `?rollen=` lets a dev exercise the deny path (`?rollen=geen`) the same
* way `?role=` exercises ssp's role-gated pages.
*
* **Sticky within the tab (sessionStorage)**, same reasoning as `currentRole()`: a
* plain in-app navigation drops the query param, which would silently revert to the
* default and mask a deliberately-chosen `?rollen=geen`.
*/
const STORAGE_KEY = 'dev-rollen';
export const MEDEWERKER_ID = 'medewerker-1';
export function currentRollen(): string {
const fromUrl = new URLSearchParams(window.location.search).get('rollen');
if (fromUrl !== null) {
sessionStorage.setItem(STORAGE_KEY, fromUrl);
return fromUrl;
}
return sessionStorage.getItem(STORAGE_KEY) ?? 'behandelaar';
}
@@ -0,0 +1,42 @@
import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { BeoordelingView } from '@behandeling/domain/beoordeling';
import {
BeoordelingAdapter,
parseBeoordelingView,
} from '@behandeling/infrastructure/beoordeling.adapter';
type Err = Error | undefined;
/** One aanvraag's beoordeling detail (WP-65) — a root singleton like `WerkvoorraadStore`.
Keyed by id: navigating to a different case resets to Loading. */
@Injectable({ providedIn: 'root' })
export class BeoordelingStore {
private adapter = inject(BeoordelingAdapter);
private id: string | undefined;
private state = signal<RemoteData<Err, BeoordelingView>>({ tag: 'Loading' });
readonly view = this.state.asReadonly();
async load(id: string) {
if (this.id !== id) this.state.set({ tag: 'Loading' });
this.id = id;
try {
const parsed = parseBeoordelingView(await this.adapter.get(id));
// A navigation to a different case may have started while this one was in flight.
if (this.id !== id) return;
this.state.set(
parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) },
);
} catch (e) {
if (this.id !== id) return;
this.state.set({ tag: 'Failure', error: e as Error });
}
}
reload() {
if (this.id) void this.load(this.id);
}
}
@@ -0,0 +1,16 @@
import { inject } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { Valid } from '@behandeling/domain/besluit.machine';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { BesluitAdapter } from '@behandeling/infrastructure/besluit.adapter';
/**
* Command factory: binds the besluit adapter in an injection context and returns the
* submit function the decision form calls. Same field-initializer shape as
* `createStore` the UI holds an application command, never the network client.
*/
export function createSubmitBesluit() {
const adapter = inject(BesluitAdapter);
return (id: string, data: Valid): Promise<Result<string, void>> =>
runSubmit(() => adapter.besluit(id, data), SUBMIT_FAILED);
}
@@ -0,0 +1,37 @@
import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
import {
WerkvoorraadAdapter,
parseWerkvoorraad,
} from '@behandeling/infrastructure/werkvoorraad.adapter';
type Err = Error | undefined;
/** The behandelaar's queue (WP-64) a root singleton like `AdminCasesStore`'s ssp
counterpart. Fetch + parse at the trust boundary, publish as RemoteData. */
@Injectable({ providedIn: 'root' })
export class WerkvoorraadStore {
private adapter = inject(WerkvoorraadAdapter);
private state = signal<RemoteData<Err, WerkvoorraadItem[]>>({ tag: 'Loading' });
readonly items = this.state.asReadonly();
async load() {
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
try {
const parsed = parseWerkvoorraad(await this.adapter.list());
this.state.set(
parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) },
);
} catch (e) {
this.state.set({ tag: 'Failure', error: e as Error });
}
}
reload() {
void this.load();
}
}
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { statusLabel, detailRows, TYPE_LABELS } from './beoordeling-view';
import { BeoordelingView } from './beoordeling';
const base: Omit<BeoordelingView, 'status'> = {
id: '1',
type: 'herregistratie',
owner: '*****2333',
submittedAt: '2024-05-12',
documenten: [],
canBesluiten: true,
};
describe('statusLabel', () => {
it('labels every tag distinctly', () => {
const labels = [
statusLabel({ tag: 'Ingediend', referentie: 'R1' }),
statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }),
statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: true }),
statusLabel({ tag: 'MeerInfoGevraagd', referentie: 'R1', reden: 'x' }),
statusLabel({ tag: 'Goedgekeurd', referentie: 'R1' }),
statusLabel({ tag: 'Afgewezen', referentie: 'R1', reden: 'x' }),
];
expect(new Set(labels).size).toBe(labels.length);
});
});
describe('detailRows', () => {
it('lists soort/status/referentie/eigenaar/ingediend', () => {
const rows = detailRows({
...base,
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
});
const values = rows.map((r) => r.value);
expect(values).toContain(TYPE_LABELS.herregistratie);
expect(values).toContain('R1');
expect(values).toContain(base.owner);
expect(rows.length).toBe(5);
});
it('adds a reden row for Afgewezen and MeerInfoGevraagd only', () => {
const afgewezen = detailRows({
...base,
status: { tag: 'Afgewezen', referentie: 'R1', reden: 'Onvoldoende uren' },
});
expect(afgewezen.length).toBe(6);
expect(afgewezen.map((r) => r.value)).toContain('Onvoldoende uren');
const meerInfo = detailRows({
...base,
status: { tag: 'MeerInfoGevraagd', referentie: 'R1', reden: 'Diploma ontbreekt' },
});
expect(meerInfo.length).toBe(6);
const goedgekeurd = detailRows({ ...base, status: { tag: 'Goedgekeurd', referentie: 'R1' } });
expect(goedgekeurd.length).toBe(5);
});
});
@@ -0,0 +1,50 @@
import { formatDatumNl } from '@shared/kernel/datum';
import { AanvraagType } from './werkvoorraad-item';
import { BeoordelingStatus, BeoordelingView } from './beoordeling';
/** View-model mapping shared by the werkvoorraad list (WP-64) and the beoordeling
detail screen (WP-65): type/status labels. Pure, no Angular. Lives here (not in
`werkvoorraad-item-view.ts`) because `BeoordelingStatus` is the wider of the two
status unions `werkvoorraad-item-view.ts` re-exports these for its own use. */
export const TYPE_LABELS: Record<AanvraagType, string> = {
registratie: $localize`:@@werkvoorraad.type.registratie:Inschrijving`,
herregistratie: $localize`:@@werkvoorraad.type.herregistratie:Herregistratie`,
intake: $localize`:@@werkvoorraad.type.intake:Herregistratie-intake`,
};
export function statusLabel(status: BeoordelingStatus): string {
switch (status.tag) {
case 'Ingediend':
return $localize`:@@werkvoorraad.status.ingediend:Ingediend`;
case 'InBehandeling':
return status.manual
? $localize`:@@werkvoorraad.status.inBehandelingHandmatig:In behandeling (handmatig)`
: $localize`:@@werkvoorraad.status.inBehandeling:In behandeling`;
case 'MeerInfoGevraagd':
return $localize`:@@beoordeling.status.meerInfoGevraagd:Meer informatie gevraagd`;
case 'Goedgekeurd':
return $localize`:@@beoordeling.status.goedgekeurd:Goedgekeurd`;
case 'Afgewezen':
return $localize`:@@beoordeling.status.afgewezen:Afgewezen`;
}
}
/** Key/value rows for the beoordeling detail page (CIBG Datablock). */
export function detailRows(view: BeoordelingView): { key: string; value: string }[] {
const s = view.status;
const rows = [
{ key: $localize`:@@beoordeling.detail.soort:Soort aanvraag`, value: TYPE_LABELS[view.type] },
{ key: $localize`:@@beoordeling.detail.status:Status`, value: statusLabel(s) },
{ key: $localize`:@@beoordeling.detail.referentie:Referentie`, value: s.referentie },
{ key: $localize`:@@beoordeling.detail.eigenaar:Eigenaar (BSN)`, value: view.owner },
{
key: $localize`:@@beoordeling.detail.ingediend:Ingediend op`,
value: view.submittedAt ? formatDatumNl(view.submittedAt) : '—',
},
];
if (s.tag === 'Afgewezen' || s.tag === 'MeerInfoGevraagd') {
rows.push({ key: $localize`:@@beoordeling.detail.reden:Reden`, value: s.reden });
}
return rows;
}
@@ -0,0 +1,33 @@
import { AanvraagType } from './werkvoorraad-item';
/**
* A case's full status lifecycle as the beoordeling detail screen sees it (WP-65)
* wider than `WerkvoorraadStatus` (WP-64), which only ever sees the two "still open"
* tags. This is the same five-tag union ssp's `AanvraagStatus` models (minus `Concept`
* the detail endpoint 404s a Concept, it isn't a case a behandelaar can treat yet).
*/
export type BeoordelingStatus =
| { tag: 'Ingediend'; referentie: string }
| { tag: 'InBehandeling'; referentie: string; manual: boolean }
| { tag: 'MeerInfoGevraagd'; referentie: string; reden: string }
| { tag: 'Goedgekeurd'; referentie: string }
| { tag: 'Afgewezen'; referentie: string; reden: string };
export interface BeoordelingDocument {
documentId: string;
categoryId: string;
fileName: string;
}
export interface BeoordelingView {
id: string;
type: AanvraagType;
status: BeoordelingStatus;
/** The BSN of the citizen the aanvraag belongs to — masked by the server. */
owner: string;
submittedAt?: string;
documenten: BeoordelingDocument[];
/** Decision flag (ADR-0001): the server computes whether a decision may be recorded;
the FE renders it, it never recomputes the lifecycle. */
canBesluiten: boolean;
}
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { BesluitState, reduce, initial } from './besluit.machine';
const editingWith = (besluit: string, toelichting = ''): BesluitState => ({
tag: 'Editing',
draft: { besluit, toelichting },
errors: {},
});
describe('besluit reduce', () => {
it('SetField updates the draft while editing', () => {
const s = reduce(initial, { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' });
expect(s.tag).toBe('Editing');
expect((s as Extract<BesluitState, { tag: 'Editing' }>).draft.besluit).toBe('Goedkeuren');
});
it('Submit with no besluit chosen stays Editing and reports a field error', () => {
const s = reduce(editingWith(''), { tag: 'Submit' });
expect(s.tag).toBe('Editing');
expect((s as Extract<BesluitState, { tag: 'Editing' }>).errors.besluit).toBeTruthy();
});
it('Submit Afwijzen without a toelichting stays Editing and reports a field error', () => {
const s = reduce(editingWith('Afwijzen'), { tag: 'Submit' });
expect(s.tag).toBe('Editing');
expect((s as Extract<BesluitState, { tag: 'Editing' }>).errors.toelichting).toBeTruthy();
});
it('Submit Goedkeuren with no toelichting moves to Submitting (optional there)', () => {
const s = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
expect((s as Extract<BesluitState, { tag: 'Submitting' }>).data).toEqual({
besluit: 'Goedkeuren',
toelichting: undefined,
});
});
it('Submit Afwijzen with a toelichting moves to Submitting with the trimmed value', () => {
const s = reduce(editingWith('Afwijzen', ' niet erkend '), { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
expect((s as Extract<BesluitState, { tag: 'Submitting' }>).data).toEqual({
besluit: 'Afwijzen',
toelichting: 'niet erkend',
});
});
it('SubmitConfirmed maps Submitting to Submitted', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
expect(reduce(submitting, { tag: 'SubmitConfirmed' }).tag).toBe('Submitted');
});
it('SubmitFailed maps Submitting to Failed with the error', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' });
});
it('Retry re-submits a failure', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting');
});
it('Reset returns to the initial editing state', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
});
});
@@ -0,0 +1,92 @@
import { Result, assertNever } from '@shared/kernel/fp';
/** The three actions the beoordeling screen offers a behandelaar (WP-65b) mirrors the
backend's `Besluit` enum member names 1:1 (the wire convention: a string, not a raw
enum see `RecordBesluitRequest`). */
const BESLUIT_TAGS = ['Goedkeuren', 'Afwijzen', 'MeerInfoOpvragen'] as const;
export type BesluitTag = (typeof BESLUIT_TAGS)[number];
function isBesluitTag(v: string): v is BesluitTag {
return (BESLUIT_TAGS as readonly string[]).includes(v);
}
/** What the user picked (raw, possibly empty while nothing is selected yet). */
export interface Draft {
besluit: string;
toelichting: string;
}
/** After parsing besluit is the narrow tag; toelichting is present only when given
(required for Afwijzen/MeerInfoOpvragen, optional for Goedkeuren enforced by validate). */
export interface Valid {
besluit: BesluitTag;
toelichting?: string;
}
export type Errors = Partial<Record<keyof Draft, string>>;
/** The decision form as one tagged union same idiom as every other form in this
house (form-machine skill), single-step. draft/errors exist only while Editing. */
export type BesluitState =
| { tag: 'Editing'; draft: Draft; errors: Errors }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid }
| { tag: 'Failed'; data: Valid; error: string };
export const initial: BesluitState = {
tag: 'Editing',
draft: { besluit: '', toelichting: '' },
errors: {},
};
function validate(draft: Draft): Result<Errors, Valid> {
if (!isBesluitTag(draft.besluit)) {
return {
ok: false,
error: { besluit: $localize`:@@besluit.error.verplicht:Kies een besluit.` },
};
}
const toelichting = draft.toelichting.trim();
if (draft.besluit !== 'Goedkeuren' && toelichting === '') {
return {
ok: false,
error: {
toelichting: $localize`:@@besluit.error.toelichtingVerplicht:Geef een toelichting.`,
},
};
}
return { ok: true, value: { besluit: draft.besluit, toelichting: toelichting || undefined } };
}
export type BesluitMsg =
| { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Reset' }
| { tag: 'Seed'; state: BesluitState }; // mount a specific state (stories/tests)
export function reduce(s: BesluitState, m: BesluitMsg): BesluitState {
switch (m.tag) {
case 'SetField':
return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;
case 'Submit': {
if (s.tag !== 'Editing') return s;
const r = validate(s.draft);
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
}
case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed':
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'Reset':
return initial;
case 'Seed':
return m.state;
default:
return assertNever(m);
}
}
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { werkvoorraadRow, statusLabel, TYPE_LABELS } from './werkvoorraad-item-view';
import { WerkvoorraadItem } from './werkvoorraad-item';
const base: Omit<WerkvoorraadItem, 'status'> = {
id: '1',
type: 'herregistratie',
owner: '111222333',
submittedAt: '2024-05-12',
};
describe('werkvoorraadRow', () => {
it('heading is the type, subtitle carries the owner BSN', () => {
const row = werkvoorraadRow({
...base,
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
});
expect(row.heading).toBe(TYPE_LABELS.herregistratie);
expect(row.subtitle).toContain('111222333');
});
it('status line carries the status label, reference and submit date', () => {
const row = werkvoorraadRow({
...base,
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
});
expect(row.status).toContain(
statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }),
);
expect(row.status).toContain('R1');
expect(row.status).toContain('12 mei 2024');
});
it('manual review is called out distinctly from an automatic InBehandeling', () => {
const manual = statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: true });
const auto = statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false });
expect(manual).not.toBe(auto);
});
it('a missing submit date leaves no dangling separator', () => {
const row = werkvoorraadRow({
...base,
submittedAt: undefined,
status: { tag: 'Ingediend', referentie: 'R9' },
});
expect(row.status).toBe(`${statusLabel({ tag: 'Ingediend', referentie: 'R9' })} · R9`);
});
});
@@ -0,0 +1,31 @@
import { formatDatumNl } from '@shared/kernel/datum';
import { WerkvoorraadItem } from './werkvoorraad-item';
import { TYPE_LABELS, statusLabel } from './beoordeling-view';
/** View-model mapping for a queue row: type/status the fields for a CIBG
"aanvragen" row. Pure, no Angular the UI renders these, it does not derive them.
`TYPE_LABELS`/`statusLabel` live in `./beoordeling-view` (the wider status union) and
are re-exported here so existing imports of this file keep working. */
export { TYPE_LABELS, statusLabel };
export interface WerkvoorraadRow {
heading: string;
subtitle: string;
status: string;
}
/** Fields for one queue row: type as heading, owner (BSN) as subtitle, status +
reference + submit date as the status line. */
export function werkvoorraadRow(item: WerkvoorraadItem): WerkvoorraadRow {
const parts = [statusLabel(item.status), item.status.referentie];
if (item.submittedAt) {
parts.push(
$localize`:@@werkvoorraad.row.ingediend:ingediend op ${formatDatumNl(item.submittedAt)}:datum:`,
);
}
return {
heading: TYPE_LABELS[item.type],
subtitle: $localize`:@@werkvoorraad.row.bsn:BSN ${item.owner}:bsn:`,
status: parts.join(' · '),
};
}
@@ -0,0 +1,23 @@
/**
* A queue entry as the behandelportal sees it (WP-64) the parsed, domain-side view
* of the backend's cross-owner `GET /werkvoorraad`. Pure types, no Angular.
*
* The status union is narrower than the SSP's full `AanvraagStatus` (ssp's
* `registratie/domain/aanvraag.ts`): the backend only ever puts a case in the queue
* while it is still open (`Ingediend`/`InBehandeling`), so a queue item literally
* cannot be `Concept`/`Goedgekeurd`/`Afgewezen` illegal states unrepresentable.
*/
export type AanvraagType = 'registratie' | 'herregistratie' | 'intake';
export type WerkvoorraadStatus =
| { tag: 'Ingediend'; referentie: string }
| { tag: 'InBehandeling'; referentie: string; manual: boolean };
export interface WerkvoorraadItem {
id: string;
type: AanvraagType;
status: WerkvoorraadStatus;
/** The BSN of the citizen the aanvraag belongs to — always populated (cross-owner list). */
owner: string;
submittedAt?: string;
}
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { parseBeoordelingStatus, parseBeoordelingView } from './beoordeling.adapter';
const view = {
aanvraag: {
id: 'a1',
type: 'registratie',
status: { tag: 'InBehandeling', referentie: 'BIG-1', manual: true },
documentIds: ['d1'],
createdAt: '2026-07-01T10:00:00Z',
updatedAt: '2026-07-01T10:05:00Z',
submittedAt: '2026-07-01T10:05:00Z',
owner: '*****2333',
},
documenten: [{ documentId: 'd1', categoryId: 'diploma', fileName: 'diploma.pdf' }],
decisions: { canBesluiten: true },
};
describe('parseBeoordelingStatus', () => {
it('parses each tag with its required fields', () => {
expect(parseBeoordelingStatus({ tag: 'Ingediend', referentie: 'BIG-1' }).ok).toBe(true);
expect(
parseBeoordelingStatus({ tag: 'InBehandeling', referentie: 'BIG-1', manual: false }).ok,
).toBe(true);
expect(
parseBeoordelingStatus({ tag: 'MeerInfoGevraagd', referentie: 'BIG-1', reden: 'x' }).ok,
).toBe(true);
expect(parseBeoordelingStatus({ tag: 'Goedgekeurd', referentie: 'BIG-1' }).ok).toBe(true);
expect(parseBeoordelingStatus({ tag: 'Afgewezen', referentie: 'BIG-1', reden: 'x' }).ok).toBe(
true,
);
});
it('rejects a missing status, unknown tag, and wrong-typed fields', () => {
expect(parseBeoordelingStatus(undefined).ok).toBe(false);
expect(parseBeoordelingStatus({ tag: 'Concept' } as never).ok).toBe(false);
expect(parseBeoordelingStatus({ tag: 'InBehandeling', referentie: 'BIG-1' }).ok).toBe(false);
});
});
describe('parseBeoordelingView', () => {
it('maps a valid DTO to domain', () => {
const r = parseBeoordelingView(view);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.type).toBe('registratie');
expect(r.value.documenten).toEqual([
{ documentId: 'd1', categoryId: 'diploma', fileName: 'diploma.pdf' },
]);
expect(r.value.canBesluiten).toBe(true);
});
it('rejects a missing owner, bad type, missing decisions, and non-objects', () => {
expect(parseBeoordelingView(null).ok).toBe(false);
expect(
parseBeoordelingView({ ...view, aanvraag: { ...view.aanvraag, owner: undefined } }).ok,
).toBe(false);
expect(
parseBeoordelingView({ ...view, aanvraag: { ...view.aanvraag, type: 'onbekend' } }).ok,
).toBe(false);
expect(parseBeoordelingView({ ...view, decisions: {} }).ok).toBe(false);
});
it('defaults an absent documenten list to empty', () => {
const r = parseBeoordelingView({ ...view, documenten: undefined });
expect(r.ok && r.value.documenten).toEqual([]);
});
});
@@ -0,0 +1,99 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import {
ApiClient,
BeoordelingViewDto,
AanvraagStatusDto,
} from '@shared/infrastructure/api-client';
import {
BeoordelingView,
BeoordelingStatus,
BeoordelingDocument,
} from '@behandeling/domain/beoordeling';
import { AanvraagType } from '@behandeling/domain/werkvoorraad-item';
/**
* Infrastructure adapter for the beoordeling detail read (WP-65) the only place its
* HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response is validated +
* mapped to domain by the parse* boundary below.
*/
@Injectable({ providedIn: 'root' })
export class BeoordelingAdapter {
private client = inject(ApiClient);
get(id: string): Promise<BeoordelingViewDto> {
return this.client.beoordeling(id);
}
}
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
export function parseBeoordelingStatus(
s: AanvraagStatusDto | undefined,
): Result<string, BeoordelingStatus> {
if (!s || typeof s.tag !== 'string') return err('beoordeling: missing status');
switch (s.tag) {
case 'Ingediend':
if (typeof s.referentie !== 'string') return err('beoordeling: bad Ingediend status');
return ok({ tag: 'Ingediend', referentie: s.referentie });
case 'InBehandeling':
if (typeof s.referentie !== 'string' || typeof s.manual !== 'boolean')
return err('beoordeling: bad InBehandeling status');
return ok({ tag: 'InBehandeling', referentie: s.referentie, manual: s.manual });
case 'MeerInfoGevraagd':
if (typeof s.referentie !== 'string' || typeof s.reden !== 'string')
return err('beoordeling: bad MeerInfoGevraagd status');
return ok({ tag: 'MeerInfoGevraagd', referentie: s.referentie, reden: s.reden });
case 'Goedgekeurd':
if (typeof s.referentie !== 'string') return err('beoordeling: bad Goedgekeurd status');
return ok({ tag: 'Goedgekeurd', referentie: s.referentie });
case 'Afgewezen':
if (typeof s.referentie !== 'string' || typeof s.reden !== 'string')
return err('beoordeling: bad Afgewezen status');
return ok({ tag: 'Afgewezen', referentie: s.referentie, reden: s.reden });
default:
return err(`beoordeling: unknown status tag ${s.tag}`);
}
}
function parseDocument(json: unknown): Result<string, BeoordelingDocument> {
if (typeof json !== 'object' || json === null) return err('beoordeling: document not an object');
const d = json as { documentId?: unknown; categoryId?: unknown; fileName?: unknown };
if (typeof d.documentId !== 'string') return err('beoordeling: document missing documentId');
if (typeof d.categoryId !== 'string') return err('beoordeling: document missing categoryId');
if (typeof d.fileName !== 'string') return err('beoordeling: document missing fileName');
return ok({ documentId: d.documentId, categoryId: d.categoryId, fileName: d.fileName });
}
export function parseBeoordelingView(json: unknown): Result<string, BeoordelingView> {
if (typeof json !== 'object' || json === null) return err('beoordeling: not an object');
const dto = json as BeoordelingViewDto;
const a = dto.aanvraag;
if (!a || typeof a.id !== 'string') return err('beoordeling: missing aanvraag.id');
if (typeof a.type !== 'string' || !AANVRAAG_TYPES.includes(a.type))
return err(`beoordeling: bad type ${a.type}`);
if (typeof a.owner !== 'string' || !a.owner) return err('beoordeling: missing owner');
const status = parseBeoordelingStatus(a.status);
if (!status.ok) return status;
const documenten: BeoordelingDocument[] = [];
for (const item of dto.documenten ?? []) {
const parsed = parseDocument(item);
if (!parsed.ok) return parsed;
documenten.push(parsed.value);
}
if (typeof dto.decisions?.canBesluiten !== 'boolean')
return err('beoordeling: missing decisions.canBesluiten');
return ok({
id: a.id,
type: a.type as AanvraagType,
status: status.value,
owner: a.owner,
submittedAt: a.submittedAt,
documenten,
canBesluiten: dto.decisions.canBesluiten,
});
}
@@ -0,0 +1,18 @@
import { Injectable, inject } from '@angular/core';
import { ApiClient } from '@shared/infrastructure/api-client';
import { Valid } from '@behandeling/domain/besluit.machine';
/**
* Infrastructure adapter for recording a behandelaar's decision (WP-65b) the single
* place its HTTP lives. No return value: a successful call means the server accepted
* the transition; the caller reloads `BeoordelingStore` to see the new status (the
* server, not this adapter, re-validates and is the authority).
*/
@Injectable({ providedIn: 'root' })
export class BesluitAdapter {
private client = inject(ApiClient);
async besluit(id: string, data: Valid): Promise<void> {
await this.client.besluit(id, { besluit: data.besluit, toelichting: data.toelichting });
}
}
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { parseWerkvoorraadItem, parseWerkvoorraad } from './werkvoorraad.adapter';
const inBehandeling = {
id: 'a1',
type: 'herregistratie',
status: { tag: 'InBehandeling', referentie: 'BIG-1', manual: false },
documentIds: [],
createdAt: '2026-07-01T10:00:00Z',
updatedAt: '2026-07-01T10:05:00Z',
owner: '111222333',
};
describe('parseWerkvoorraadItem', () => {
it('parses Ingediend and InBehandeling', () => {
expect(parseWerkvoorraadItem(inBehandeling).ok).toBe(true);
expect(
parseWerkvoorraadItem({ ...inBehandeling, status: { tag: 'Ingediend', referentie: 'BIG-2' } })
.ok,
).toBe(true);
});
it('rejects a case whose status is not an open queue tag', () => {
expect(
parseWerkvoorraadItem({
...inBehandeling,
status: { tag: 'Goedgekeurd', referentie: 'BIG-1' },
}).ok,
).toBe(false);
expect(
parseWerkvoorraadItem({
...inBehandeling,
status: { tag: 'Concept', stepIndex: 0, stepCount: 1 },
}).ok,
).toBe(false);
});
it('rejects a missing owner, bad type, and non-objects', () => {
expect(parseWerkvoorraadItem({ ...inBehandeling, owner: undefined }).ok).toBe(false);
expect(parseWerkvoorraadItem({ ...inBehandeling, type: 'onbekend' }).ok).toBe(false);
expect(parseWerkvoorraadItem(null).ok).toBe(false);
});
});
describe('parseWerkvoorraad', () => {
it('parses a list and fails fast on a bad element', () => {
expect(parseWerkvoorraad([inBehandeling, inBehandeling]).ok).toBe(true);
expect(parseWerkvoorraad([inBehandeling, { ...inBehandeling, owner: undefined }]).ok).toBe(
false,
);
expect(parseWerkvoorraad({}).ok).toBe(false);
});
});
@@ -0,0 +1,72 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { ApiClient, ApplicationSummaryDto } from '@shared/infrastructure/api-client';
import {
WerkvoorraadItem,
WerkvoorraadStatus,
AanvraagType,
} from '@behandeling/domain/werkvoorraad-item';
/**
* Infrastructure adapter for the behandelportal's queue read (WP-64) the only
* place its HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response
* is validated + mapped to the (narrower) queue domain shape by the parse* boundary
* below; a case whose status isn't `Ingediend`/`InBehandeling` is a parse error, not
* a silently-rendered row the endpoint's own filter is a guarantee this boundary enforces.
*/
@Injectable({ providedIn: 'root' })
export class WerkvoorraadAdapter {
private client = inject(ApiClient);
list(): Promise<ApplicationSummaryDto[]> {
return this.client.werkvoorraad();
}
}
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
function parseWerkvoorraadStatus(
s: ApplicationSummaryDto['status'] | undefined,
): Result<string, WerkvoorraadStatus> {
if (!s || typeof s.tag !== 'string') return err('werkvoorraad: missing status');
switch (s.tag) {
case 'Ingediend':
if (typeof s.referentie !== 'string') return err('werkvoorraad: bad Ingediend status');
return ok({ tag: 'Ingediend', referentie: s.referentie });
case 'InBehandeling':
if (typeof s.referentie !== 'string' || typeof s.manual !== 'boolean')
return err('werkvoorraad: bad InBehandeling status');
return ok({ tag: 'InBehandeling', referentie: s.referentie, manual: s.manual });
default:
return err(`werkvoorraad: a queue item cannot have status ${s.tag}`);
}
}
export function parseWerkvoorraadItem(json: unknown): Result<string, WerkvoorraadItem> {
if (typeof json !== 'object' || json === null) return err('werkvoorraad: not an object');
const dto = json as ApplicationSummaryDto;
if (typeof dto.id !== 'string') return err('werkvoorraad: missing id');
if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type))
return err(`werkvoorraad: bad type ${dto.type}`);
if (typeof dto.owner !== 'string' || !dto.owner) return err('werkvoorraad: missing owner');
const status = parseWerkvoorraadStatus(dto.status);
if (!status.ok) return status;
return ok({
id: dto.id,
type: dto.type as AanvraagType,
status: status.value,
owner: dto.owner,
submittedAt: dto.submittedAt,
});
}
export function parseWerkvoorraad(json: unknown): Result<string, WerkvoorraadItem[]> {
if (!Array.isArray(json)) return err('werkvoorraad: not an array');
const out: WerkvoorraadItem[] = [];
for (const item of json) {
const parsed = parseWerkvoorraadItem(item);
if (!parsed.ok) return parsed;
out.push(parsed.value);
}
return ok(out);
}
@@ -0,0 +1,30 @@
import { Component, input } from '@angular/core';
import { BeoordelingDocument } from '@behandeling/domain/beoordeling';
/** Organism: the documents linked to an aanvraag (WP-65) plain links to the existing
(pre-existing, unauthenticated same as ssp's own document previews) content
endpoint. No new shared atom: a context-local list, not a reusable building block. */
@Component({
selector: 'app-beoordeling-documenten',
template: `
@if (documenten().length === 0) {
<p class="app-text-subtle" i18n="@@beoordeling.documenten.leeg">Geen documenten.</p>
} @else {
<ul class="list-unstyled">
@for (doc of documenten(); track doc.documentId) {
<li>
<a
[href]="'/api/v1/uploads/' + doc.documentId + '/content'"
target="_blank"
rel="noopener"
>{{ doc.fileName }}</a
>
</li>
}
</ul>
}
`,
})
export class BeoordelingDocumentenComponent {
documenten = input.required<BeoordelingDocument[]>();
}
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { BeoordelingDocumentenComponent } from './beoordeling-documenten.component';
const meta: Meta<BeoordelingDocumentenComponent> = {
title: 'Domein/Behandeling/Beoordeling Documenten',
component: BeoordelingDocumentenComponent,
};
export default meta;
type Story = StoryObj<BeoordelingDocumentenComponent>;
export const Default: Story = {
args: {
documenten: [
{ documentId: 'd1', categoryId: 'diploma', fileName: 'diploma.pdf' },
{ documentId: 'd2', categoryId: 'identiteit', fileName: 'paspoort.pdf' },
],
},
};
export const Empty: Story = {
args: { documenten: [] },
};
@@ -0,0 +1,88 @@
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.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 { BeoordelingStore } from '@behandeling/application/beoordeling.store';
import { detailRows } from '@behandeling/domain/beoordeling-view';
import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component';
import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.component';
/**
* Page: one aanvraag's beoordeling detail (WP-65). The werkvoorraad list (WP-64) links
* here. `canBesluiten` (server-computed, ADR-0001) gates the decision form (WP-65b)
* the page never recomputes the lifecycle itself. On a recorded decision the form emits
* `decided`, and the page just reloads (the server is the authority on the new status).
*/
@Component({
selector: 'app-beoordeling-page',
imports: [
PageShellComponent,
AlertComponent,
ButtonComponent,
SkeletonComponent,
DataBlockComponent,
DataRowComponent,
BeoordelingDocumentenComponent,
BesluitFormComponent,
...ASYNC,
],
template: `
<app-page-shell [heading]="heading" backLink="/dashboard">
<app-async [data]="store.view()" (retryClicked)="reload()">
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="5" />
</ng-template>
<ng-template appAsyncError>
<app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
</ng-template>
<ng-template appAsyncLoaded>
@if (view(); as v) {
<app-data-block [heading]="detailHeading" class="app-section">
@for (row of rows(v); track row.key) {
<div app-data-row [key]="row.key" [value]="row.value"></div>
}
</app-data-block>
<app-data-block [heading]="documentenHeading" class="app-section">
<app-beoordeling-documenten [documenten]="v.documenten" />
</app-data-block>
@if (v.canBesluiten) {
<div class="app-section">
<app-besluit-form [id]="v.id" (decided)="reload()" />
</div>
}
}
</ng-template>
</app-async>
</app-page-shell>
`,
})
export class BeoordelingPage {
protected store = inject(BeoordelingStore);
private id = inject(ActivatedRoute).snapshot.paramMap.get('id') ?? '';
protected heading = $localize`:@@beoordeling.heading:Aanvraag`;
protected detailHeading = $localize`:@@beoordeling.detail.heading:Aanvraaggegevens`;
protected documentenHeading = $localize`:@@beoordeling.documenten.heading:Documenten`;
protected failedText = $localize`:@@beoordeling.failed:De aanvraag kon niet worden geladen.`;
protected retryText = $localize`:@@beoordeling.retry:Opnieuw proberen`;
protected rows = detailRows;
protected readonly view = computed(() => {
const rd = this.store.view();
return rd.tag === 'Success' ? rd.value : undefined;
});
constructor() {
void this.store.load(this.id);
}
protected reload() {
this.store.reload();
}
}
@@ -0,0 +1,140 @@
import { Component, computed, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine';
import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
/**
* Organism: the decision form (WP-65b) goedkeuren/afwijzen/meer-info-opvragen. Same
* idiom as every other form in this house (`change-request-form`): all state in one
* signal driven by the pure `reduce` (besluit.machine.ts), submitted via a `submit-*`
* command returning `Result`. The server re-validates the transition and is the
* authority; on success this only emits `decided` the page reloads the detail
* (BeoordelingStore.reload()), it doesn't guess the new state itself.
*/
@Component({
selector: 'app-besluit-form',
imports: [
FormsModule,
ButtonComponent,
HeadingComponent,
AlertComponent,
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
],
template: `
@if (state().tag === 'Submitted') {
<app-alert type="ok" i18n="@@besluit.success">Het besluit is vastgelegd.</app-alert>
} @else {
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
<form (ngSubmit)="onSubmit()" class="form-horizontal app-section">
<app-form-field
i18n-label="@@besluit.besluitLabel"
label="Besluit"
fieldId="besluit-keuze"
required
[error]="errors().besluit"
>
<app-radio-group
name="besluit-keuze"
[options]="BESLUIT_OPTIONS"
[invalid]="!!errors().besluit"
[ngModel]="besluit()"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'besluit', value: $event })"
[ngModelOptions]="{ standalone: true }"
/>
</app-form-field>
<app-form-field
i18n-label="@@besluit.toelichtingLabel"
label="Toelichting"
fieldId="besluit-toelichting"
[error]="errors().toelichting"
>
<app-text-input
inputId="besluit-toelichting"
[invalid]="!!errors().toelichting"
[ngModel]="toelichting()"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'toelichting', value: $event })"
name="toelichting"
[ngModelOptions]="{ standalone: true }"
/>
</app-form-field>
@if (failedError()) {
<app-alert type="error"
><ng-container i18n="@@besluit.failed">Het vastleggen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
}
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
{{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}
</app-button>
</form>
}
`,
})
export class BesluitFormComponent {
private submit = createSubmitBesluit();
private store = createStore<BesluitState, BesluitMsg>(initial, reduce);
id = input.required<string>();
decided = output<void>();
/** Optional seed so Storybook / tests can mount any state directly. */
seed = input<BesluitState>(initial);
readonly state = this.store.model;
protected dispatch = this.store.dispatch;
protected readonly BESLUIT_OPTIONS: RadioOption[] = [
{ value: 'Goedkeuren', label: $localize`:@@besluit.optie.goedkeuren:Goedkeuren` },
{ value: 'Afwijzen', label: $localize`:@@besluit.optie.afwijzen:Afwijzen` },
{
value: 'MeerInfoOpvragen',
label: $localize`:@@besluit.optie.meerInfoOpvragen:Meer informatie opvragen`,
},
];
protected readonly submitLabel = $localize`:@@besluit.submit:Besluit vastleggen`;
protected readonly submitBezigLabel = $localize`:@@besluit.submitBezig:Bezig met vastleggen…`;
private editing = computed(() => whenTag(this.state(), 'Editing'));
protected errors = computed(() => this.editing()?.errors ?? {});
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
protected besluit = computed(() => this.editing()?.draft.besluit ?? '');
protected toelichting = computed(() => this.editing()?.draft.toelichting ?? '');
constructor() {
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
}
onSubmit() {
this.dispatch({ tag: 'Submit' });
this.runIfSubmitting();
}
/** Effect: when we entered Submitting, call the command, then dispatch the outcome. */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
const r = await this.submit(this.id(), s.data);
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.decided.emit();
} else {
this.dispatch({ tag: 'SubmitFailed', error: r.error });
}
}
}
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { BesluitFormComponent } from './besluit-form.component';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { Valid } from '@behandeling/domain/besluit.machine';
const validData: Valid = { besluit: 'Afwijzen', toelichting: 'Diploma niet erkend' };
const meta: Meta<BesluitFormComponent> = {
title: 'Domein/Behandeling/Besluit Form',
component: BesluitFormComponent,
// The form injects ApiClient (over HttpClient) for the submit command.
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
args: { id: 'aanvraag-1' },
};
export default meta;
type Story = StoryObj<BesluitFormComponent>;
// One render per state of the machine.
export const Empty: Story = {
args: { seed: { tag: 'Editing', draft: { besluit: '', toelichting: '' }, errors: {} } },
};
export const WithErrors: Story = {
args: {
seed: {
tag: 'Editing',
draft: { besluit: 'Afwijzen', toelichting: '' },
errors: { toelichting: 'Geef een toelichting.' },
},
},
};
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
export const Failed: Story = {
args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } },
};
@@ -0,0 +1,32 @@
import { Component, input } from '@angular/core';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view';
/** Organism: the behandelaar's queue as CIBG "aanvragen" rows (WP-64) composition
of the two existing shared/ui molecules, no new atom. Each row links to the
beoordeling detail page (WP-65). */
@Component({
selector: 'app-werkvoorraad-list',
imports: [ApplicationListComponent, ApplicationLinkComponent],
template: `
<app-application-list>
@for (item of items(); track item.id) {
@let row = row_(item);
<li
app-application-link
[heading]="row.heading"
[subtitle]="row.subtitle"
[status]="row.status"
[to]="'/aanvraag/' + item.id"
></li>
}
</app-application-list>
`,
})
export class WerkvoorraadListComponent {
items = input.required<WerkvoorraadItem[]>();
protected row_ = werkvoorraadRow;
}
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { WerkvoorraadListComponent } from './werkvoorraad-list.component';
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
const items: WerkvoorraadItem[] = [
{
id: 'a1',
type: 'herregistratie',
owner: '111222333',
submittedAt: '2026-06-28T10:05:00Z',
status: { tag: 'InBehandeling', referentie: 'BIG-2026-456789', manual: false },
},
{
id: 'a2',
type: 'registratie',
owner: '444555666',
submittedAt: '2026-06-27T09:00:00Z',
status: { tag: 'InBehandeling', referentie: 'BIG-2026-456790', manual: true },
},
{
id: 'a3',
type: 'intake',
owner: '777888999',
status: { tag: 'Ingediend', referentie: 'BIG-2026-456791' },
},
];
const meta: Meta<WerkvoorraadListComponent> = {
title: 'Domein/Behandeling/Werkvoorraad List',
component: WerkvoorraadListComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
};
export default meta;
type Story = StoryObj<WerkvoorraadListComponent>;
export const Default: Story = {
args: { items },
};
export const Empty: Story = {
args: { items: [] },
};
@@ -0,0 +1,82 @@
import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store';
import { WerkvoorraadListComponent } from '@behandeling/ui/werkvoorraad-list/werkvoorraad-list.component';
/**
* Page: the behandelaar's werkvoorraad (WP-64) — the behandelportal's landing page.
* Deny-by-default capability gate (`aanvraag:beoordelen`), same idiom as ssp's
* AdminCasesPage: a denial alert for a non-behandelaar, the queue for one. Opening
* a case's detail is out of scope here (WP-65).
*/
@Component({
selector: 'app-werkvoorraad-page',
imports: [
PageShellComponent,
AlertComponent,
ButtonComponent,
SkeletonComponent,
WerkvoorraadListComponent,
...ASYNC,
],
template: `
<app-page-shell [heading]="heading" [intro]="intro">
@if (!access.ready()) {
<!-- wait for /me before deciding — avoids flashing the denial to a behandelaar -->
} @else if (!canBeoordelen()) {
<app-alert type="error">{{ deniedText }}</app-alert>
} @else {
<app-async [data]="store.items()" (retryClicked)="store.reload()">
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="4" />
</ng-template>
<ng-template appAsyncError>
<app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="store.reload()">{{ retryText }}</app-button>
</ng-template>
<ng-template appAsyncLoaded>
@if (items().length === 0) {
<app-alert type="info">{{ emptyText }}</app-alert>
} @else {
<app-werkvoorraad-list [items]="items()" />
}
</ng-template>
</app-async>
}
</app-page-shell>
`,
})
export class WerkvoorraadPage {
protected store = inject(WerkvoorraadStore);
protected access = inject(AccessStore);
protected canBeoordelen = computed(() => this.access.can('aanvraag:beoordelen'));
protected items = computed(() => {
const rd = this.store.items();
return rd.tag === 'Success' ? rd.value : [];
});
protected heading = $localize`:@@werkvoorraad.heading:Werkvoorraad`;
protected intro = $localize`:@@werkvoorraad.intro:Aanvragen die op beoordeling wachten.`;
protected deniedText = $localize`:@@werkvoorraad.denied:U hebt geen rechten om de werkvoorraad te bekijken.`;
protected failedText = $localize`:@@werkvoorraad.failed:De werkvoorraad kon niet worden geladen.`;
protected emptyText = $localize`:@@werkvoorraad.empty:Er staan geen aanvragen open.`;
protected retryText = $localize`:@@werkvoorraad.retry:Opnieuw proberen`;
private loadRequested = false;
constructor() {
// Load once the capability resolves to allowed (a 403 GET would be wasted otherwise) —
// same guard-against-the-loop idiom as AdminCasesPage (WP-26 lesson).
effect(() => {
if (this.canBeoordelen() && !this.loadRequested) {
this.loadRequested = true;
void this.store.load();
}
});
}
}
@@ -0,0 +1,31 @@
import { AdminLink, HeaderNavItem } from '@shared/layout/site-header/nav-config';
/** This app's primary nav provided to the shared site header via HEADER_NAV_ITEMS
(see app.config.ts). */
export const NAV_ITEMS: readonly HeaderNavItem[] = [
{ label: $localize`:@@header.nav.overzicht:Overzicht`, to: '/dashboard' },
];
/** This app's admin pages provided to the shared site header via HEADER_ADMIN_LINKS.
No huisstijl (that's the SSP's brief context) or zaken entry inherited as-is from
WP-61's bootstrap trim, not revisited by this migration. */
export const ADMIN_LINKS: readonly AdminLink[] = [
{
label: $localize`:@@header.nav.stamdata:Stamdata`,
description: $localize`:@@admin.link.stamdata.desc:Business-tabellen onderhouden`,
to: '/beheer/stamdata',
cap: 'stamdata:edit',
},
{
label: $localize`:@@header.nav.audit:Auditlog`,
description: $localize`:@@admin.link.audit.desc:Toegangs- en inzagebeslissingen bekijken`,
to: '/beheer/audit',
cap: 'cases:manage',
},
{
label: $localize`:@@header.nav.functies:Functievlaggen`,
description: $localize`:@@admin.link.functies.desc:Functionaliteit aan- of uitzetten`,
to: '/beheer/functies',
cap: 'flags:manage',
},
];
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Behandelportal</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<!-- CIBG Huisstijl (customized Bootstrap 5.2). Loaded as a <link> so its relative
url(../fonts|icons|images) refs resolve against the vendored folder at runtime.
Licensed Rijksoverheid fonts are not used — styles.scss overrides the stack to system-ui. -->
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
</head>
<!-- brand--cibg activates CIBG's official palette: robijn layout chrome + lintblauw accents
(without it, --ro-layout falls back to the blue default). -->
<body class="brand--cibg">
<app-root></app-root>
</body>
</html>
File diff suppressed because it is too large Load Diff
+980
View File
@@ -0,0 +1,980 @@
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="nl" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="form.verplichteVelden" datatype="html">
<source>* verplichte velden</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">15,18</context>
</context-group>
</trans-unit>
<trans-unit id="login.bsnLabel" datatype="html">
<source>BSN</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">22,23</context>
</context-group>
</trans-unit>
<trans-unit id="login.bsnDescription" datatype="html">
<source>9-cijferig BSN, elfproef-geldig (demo: 123456782)</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">25,28</context>
</context-group>
</trans-unit>
<trans-unit id="login.wachtwoordLabel" datatype="html">
<source>Wachtwoord</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">36,37</context>
</context-group>
</trans-unit>
<trans-unit id="login.submit" datatype="html">
<source>Inloggen met DigiD</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">41,43</context>
</context-group>
</trans-unit>
<trans-unit id="login.heading" datatype="html">
<source>Inloggen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/auth/ui/login.page.ts</context>
<context context-type="linenumber">14,16</context>
</context-group>
</trans-unit>
<trans-unit id="login.intro" datatype="html">
<source>Log in op uw persoonlijke BIG-register omgeving.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/auth/ui/login.page.ts</context>
<context context-type="linenumber">17,19</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.type.registratie" datatype="html">
<source>Inschrijving</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">11</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.type.herregistratie" datatype="html">
<source>Herregistratie</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">12</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.type.intake" datatype="html">
<source>Herregistratie-intake</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.status.ingediend" datatype="html">
<source>Ingediend</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">19</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.status.inBehandelingHandmatig" datatype="html">
<source>In behandeling (handmatig)</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">22</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.status.inBehandeling" datatype="html">
<source>In behandeling</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">23</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.status.meerInfoGevraagd" datatype="html">
<source>Meer informatie gevraagd</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">25</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.status.goedgekeurd" datatype="html">
<source>Goedgekeurd</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.status.afgewezen" datatype="html">
<source>Afgewezen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">29</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.soort" datatype="html">
<source>Soort aanvraag</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">37</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.status" datatype="html">
<source>Status</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">38</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.referentie" datatype="html">
<source>Referentie</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">39</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.eigenaar" datatype="html">
<source>Eigenaar (BSN)</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">40</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.ingediend" datatype="html">
<source>Ingediend op</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">42</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.reden" datatype="html">
<source>Reden</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">47</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.error.verplicht" datatype="html">
<source>Kies een besluit.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts</context>
<context context-type="linenumber">46</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.error.toelichtingVerplicht" datatype="html">
<source>Geef een toelichting.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts</context>
<context context-type="linenumber">54</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.row.ingediend" datatype="html">
<source>ingediend op <x id="datum" equiv-text="formatDatumNl(item.submittedAt)"/></source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">23</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.row.bsn" datatype="html">
<source>BSN <x id="bsn" equiv-text="item.owner"/></source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">28</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.documenten.leeg" datatype="html">
<source>Geen documenten.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.component.ts</context>
<context context-type="linenumber">11,13</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.heading" datatype="html">
<source>Aanvraag</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">69</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.heading" datatype="html">
<source>Aanvraaggegevens</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">70</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.documenten.heading" datatype="html">
<source>Documenten</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">71</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.failed" datatype="html">
<source>De aanvraag kon niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">72</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">73</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.success" datatype="html">
<source>Het besluit is vastgelegd.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">35,37</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.heading" datatype="html">
<source>Besluit vastleggen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">37,39</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.besluitLabel" datatype="html">
<source>Besluit</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">43</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.toelichtingLabel" datatype="html">
<source>Toelichting</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">59,60</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.failed" datatype="html">
<source>Het vastleggen is niet gelukt:</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">75,76</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.optie.goedkeuren" datatype="html">
<source>Goedkeuren</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">101</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.optie.afwijzen" datatype="html">
<source>Afwijzen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">102</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.optie.meerInfoOpvragen" datatype="html">
<source>Meer informatie opvragen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">105</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.submit" datatype="html">
<source>Besluit vastleggen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">109</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.submitBezig" datatype="html">
<source>Bezig met vastleggen…</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">110</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.heading" datatype="html">
<source>Werkvoorraad</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.intro" datatype="html">
<source>Aanvragen die op beoordeling wachten.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">65</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.denied" datatype="html">
<source>U hebt geen rechten om de werkvoorraad te bekijken.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">66</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.failed" datatype="html">
<source>De werkvoorraad kon niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">67</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.empty" datatype="html">
<source>Er staan geen aanvragen open.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">68</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">69</context>
</context-group>
</trans-unit>
<trans-unit id="header.nav.overzicht" datatype="html">
<source>Overzicht</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
<context context-type="linenumber">6</context>
</context-group>
</trans-unit>
<trans-unit id="header.nav.stamdata" datatype="html">
<source>Stamdata</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
<context context-type="linenumber">14</context>
</context-group>
</trans-unit>
<trans-unit id="admin.link.stamdata.desc" datatype="html">
<source>Business-tabellen onderhouden</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
<context context-type="linenumber">15</context>
</context-group>
</trans-unit>
<trans-unit id="header.nav.audit" datatype="html">
<source>Auditlog</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
<context context-type="linenumber">20</context>
</context-group>
</trans-unit>
<trans-unit id="admin.link.audit.desc" datatype="html">
<source>Toegangs- en inzagebeslissingen bekijken</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
<context context-type="linenumber">21</context>
</context-group>
</trans-unit>
<trans-unit id="header.nav.functies" datatype="html">
<source>Functievlaggen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
<context context-type="linenumber">26</context>
</context-group>
</trans-unit>
<trans-unit id="admin.link.functies.desc" datatype="html">
<source>Functionaliteit aan- of uitzetten</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.noTables" datatype="html">
<source>Er is geen stamdata om te beheren.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/application/stamdata.store.ts</context>
<context context-type="linenumber">150</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.validation.key" datatype="html">
<source>Vul de sleutelkolom in.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/domain/stamdata.ts</context>
<context context-type="linenumber">68</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.validation.van" datatype="html">
<source>Vul een &apos;geldig van&apos;-datum in.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/domain/stamdata.ts</context>
<context context-type="linenumber">72</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.validation.range" datatype="html">
<source>&apos;Geldig tot&apos; moet ná &apos;geldig van&apos; liggen.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/domain/stamdata.ts</context>
<context context-type="linenumber">74</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.load.failed" datatype="html">
<source>De stamdata kon niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/infrastructure/stamdata.adapter.ts</context>
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="audit.heading" datatype="html">
<source>Auditlog</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">102</context>
</context-group>
</trans-unit>
<trans-unit id="audit.intro" datatype="html">
<source>Toegangs- en inzagebeslissingen (autorisatie en het tonen van afgeschermde gegevens). Vastgelegd zonder persoonsgegevens.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">103</context>
</context-group>
</trans-unit>
<trans-unit id="audit.denied" datatype="html">
<source>U hebt geen rechten om de auditlog te bekijken.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">104</context>
</context-group>
</trans-unit>
<trans-unit id="audit.failed" datatype="html">
<source>De auditlog kon niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">105</context>
</context-group>
</trans-unit>
<trans-unit id="audit.empty" datatype="html">
<source>Nog geen auditregels.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">106</context>
</context-group>
</trans-unit>
<trans-unit id="audit.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">107</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.tijd" datatype="html">
<source>Tijd</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">108</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.actie" datatype="html">
<source>Actie</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">109</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.resource" datatype="html">
<source>Resource</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">110</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.besluit" datatype="html">
<source>Besluit</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">111</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.rol" datatype="html">
<source>Rol</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">112</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.cid" datatype="html">
<source>Correlatie-id</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">113</context>
</context-group>
</trans-unit>
<trans-unit id="flags.heading" datatype="html">
<source>Functievlaggen</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">82</context>
</context-group>
</trans-unit>
<trans-unit id="flags.intro" datatype="html">
<source>Zet functionaliteit aan of uit tijdens runtime. De catalogus staat vast in code; hier beheert u de status.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">83</context>
</context-group>
</trans-unit>
<trans-unit id="flags.denied" datatype="html">
<source>U hebt geen rechten om functievlaggen te beheren.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">84</context>
</context-group>
</trans-unit>
<trans-unit id="flags.failed" datatype="html">
<source>De functievlaggen konden niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">85</context>
</context-group>
</trans-unit>
<trans-unit id="flags.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">86</context>
</context-group>
</trans-unit>
<trans-unit id="flags.on" datatype="html">
<source>Aan</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">87</context>
</context-group>
</trans-unit>
<trans-unit id="flags.off" datatype="html">
<source>Uit</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">88</context>
</context-group>
</trans-unit>
<trans-unit id="flags.enable" datatype="html">
<source>Aanzetten</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">89</context>
</context-group>
</trans-unit>
<trans-unit id="flags.disable" datatype="html">
<source>Uitzetten</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">90</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.added" datatype="html">
<source>toegevoegd</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">228</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.edited" datatype="html">
<source>gewijzigd</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">229</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.removed" datatype="html">
<source>verwijderd</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">230</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.table" datatype="html">
<source>Tabel</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">236</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.peildatum" datatype="html">
<source>Toon geldig op</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">237</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.showAll" datatype="html">
<source>Toon alles</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">238</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.previewNote" datatype="html">
<source>Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">239</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.actions" datatype="html">
<source>Acties</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">240</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.remove" datatype="html">
<source>Verwijderen</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">241</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.expire" datatype="html">
<source>Sluiten per vandaag</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">242</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.removeConfirm" datatype="html">
<source>Rij verwijderen? Als andere gegevens ernaar verwijzen, faalt de build-controle (CI). Bij een tabel met een geldigheidsperiode kunt u de rij beter sluiten (geldig tot) in plaats van verwijderen.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">243</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.undo" datatype="html">
<source>Ongedaan maken</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">257</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.redo" datatype="html">
<source>Opnieuw uitvoeren</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">258</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.addRow" datatype="html">
<source>Rij toevoegen</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">259</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.download" datatype="html">
<source>Download JSON</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">260</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.applyHint" datatype="html">
<source>Wijzigingen worden als JSON-bestand gedownload en via een pull request toegepast — de build (CI) controleert ze.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">261</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.heading" datatype="html">
<source>Stamdata onderhouden</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata.page.ts</context>
<context context-type="linenumber">72</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.intro" datatype="html">
<source>Beheer de business-tabellen die de registratie stuurt. Wijzigingen worden als JSON gedownload en via een pull request toegepast; de build blijft de bewaker.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata.page.ts</context>
<context context-type="linenumber">73</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.denied" datatype="html">
<source>U hebt geen rechten om stamdata te onderhouden.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata.page.ts</context>
<context context-type="linenumber">74</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.failed" datatype="html">
<source>De stamdata kon niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata.page.ts</context>
<context context-type="linenumber">75</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata.page.ts</context>
<context context-type="linenumber">76</context>
</context-group>
</trans-unit>
<trans-unit id="submit.failed" datatype="html">
<source>Het indienen is niet gelukt. Probeer het later opnieuw.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/application/submit.ts</context>
<context context-type="linenumber">28</context>
</context-group>
</trans-unit>
<trans-unit id="validation.bsn" datatype="html">
<source>Voer een geldig BSN van 9 cijfers in.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/kernel/bsn.ts</context>
<context context-type="linenumber">18</context>
</context-group>
</trans-unit>
<trans-unit id="validation.bsnElfproef" datatype="html">
<source>Dit is geen geldig BSN (klopt niet met de elfproef).</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/kernel/bsn.ts</context>
<context context-type="linenumber">23</context>
</context-group>
</trans-unit>
<trans-unit id="crumb.dashboard" datatype="html">
<source>Mijn overzicht</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
<context context-type="linenumber">12</context>
</context-group>
</trans-unit>
<trans-unit id="crumb.registratie" datatype="html">
<source>Mijn gegevens</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="crumb.registreren" datatype="html">
<source>Inschrijven</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
<context context-type="linenumber">14</context>
</context-group>
</trans-unit>
<trans-unit id="crumb.herregistratie" datatype="html">
<source>Herregistratie</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
<context context-type="linenumber">16</context>
</context-group>
</trans-unit>
<trans-unit id="crumb.intake" datatype="html">
<source>Herregistratie-intake</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
<context context-type="linenumber">19</context>
</context-group>
</trans-unit>
<trans-unit id="crumb.concepts" datatype="html">
<source>Functionele patronen</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
<context context-type="linenumber">20</context>
</context-group>
</trans-unit>
<trans-unit id="breadcrumb.aria" datatype="html">
<source>Kruimelpad</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb.component.ts</context>
<context context-type="linenumber">27,28</context>
</context-group>
</trans-unit>
<trans-unit id="breadcrumb.hier" datatype="html">
<source>U bevindt zich hier:</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb.component.ts</context>
<context context-type="linenumber">28,29</context>
</context-group>
</trans-unit>
<trans-unit id="lang.navLabel" datatype="html">
<source>Taal / Language</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/language-switcher/language-switcher.component.ts</context>
<context context-type="linenumber">95</context>
</context-group>
</trans-unit>
<trans-unit id="lang.heading" datatype="html">
<source>Kies een taal</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/language-switcher/language-switcher.component.ts</context>
<context context-type="linenumber">96</context>
</context-group>
</trans-unit>
<trans-unit id="pageShell.backLabel" datatype="html">
<source>Terug naar overzicht</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/page-shell/page-shell.component.ts</context>
<context context-type="linenumber">49</context>
</context-group>
</trans-unit>
<trans-unit id="shell.skipLink" datatype="html">
<source>Naar de inhoud</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/shell/shell.component.ts</context>
<context context-type="linenumber">62,63</context>
</context-group>
</trans-unit>
<trans-unit id="footer.tagline" datatype="html">
<source>De Rijksoverheid. Voor Nederland.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
<context context-type="linenumber">85,86</context>
</context-group>
</trans-unit>
<trans-unit id="footer.ministry" datatype="html">
<source> CIBG — Ministerie van Volksgezondheid, Welzijn en Sport </source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
<context context-type="linenumber">87,89</context>
</context-group>
</trans-unit>
<trans-unit id="footer.overSiteAria" datatype="html">
<source>Over deze site</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
<context context-type="linenumber">90,91</context>
</context-group>
</trans-unit>
<trans-unit id="footer.overSite" datatype="html">
<source>Over deze site</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
<context context-type="linenumber">91,92</context>
</context-group>
</trans-unit>
<trans-unit id="footer.privacy" datatype="html">
<source>Privacy</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
<context context-type="linenumber">99,101</context>
</context-group>
</trans-unit>
<trans-unit id="footer.cookies" datatype="html">
<source>Cookies</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
<context context-type="linenumber">108,110</context>
</context-group>
</trans-unit>
<trans-unit id="footer.toegankelijkheid" datatype="html">
<source>Toegankelijkheid</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
<context context-type="linenumber">117,120</context>
</context-group>
</trans-unit>
<trans-unit id="footer.demo" datatype="html">
<source>Demo / POC — geen echte gegevens.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
<context context-type="linenumber">122,124</context>
</context-group>
</trans-unit>
<trans-unit id="header.sender" datatype="html">
<source>BIG-register</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">44,45</context>
</context-group>
</trans-unit>
<trans-unit id="header.ministry" datatype="html">
<source>Ministerie van Volksgezondheid, Welzijn en Sport</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">46,48</context>
</context-group>
</trans-unit>
<trans-unit id="header.uitloggen" datatype="html">
<source> Uitloggen </source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">68,69</context>
</context-group>
</trans-unit>
<trans-unit id="header.navAria" datatype="html">
<source>Hoofdnavigatie</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">76,77</context>
</context-group>
</trans-unit>
<trans-unit id="alert.icon.info" datatype="html">
<source>Informatie</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/alert/alert.component.ts</context>
<context context-type="linenumber">7</context>
</context-group>
</trans-unit>
<trans-unit id="alert.icon.ok" datatype="html">
<source>Gelukt</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/alert/alert.component.ts</context>
<context context-type="linenumber">8</context>
</context-group>
</trans-unit>
<trans-unit id="alert.icon.warning" datatype="html">
<source>Waarschuwing</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/alert/alert.component.ts</context>
<context context-type="linenumber">9</context>
</context-group>
</trans-unit>
<trans-unit id="alert.icon.error" datatype="html">
<source>Foutmelding</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/alert/alert.component.ts</context>
<context context-type="linenumber">10</context>
</context-group>
</trans-unit>
<trans-unit id="async.error" datatype="html">
<source>Er ging iets mis bij het laden van de gegevens.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/async/async.component.ts</context>
<context context-type="linenumber">105</context>
</context-group>
</trans-unit>
<trans-unit id="async.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/async/async.component.ts</context>
<context context-type="linenumber">106</context>
</context-group>
</trans-unit>
<trans-unit id="async.empty" datatype="html">
<source>Geen gegevens gevonden.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/async/async.component.ts</context>
<context context-type="linenumber">107</context>
</context-group>
</trans-unit>
<trans-unit id="common.ja" datatype="html">
<source>Ja</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/radio-group/radio-group.component.ts</context>
<context context-type="linenumber">12</context>
</context-group>
</trans-unit>
<trans-unit id="common.nee" datatype="html">
<source>Nee</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/radio-group/radio-group.component.ts</context>
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="spinner.aria" datatype="html">
<source>Bezig met laden</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/spinner/spinner.component.ts</context>
<context context-type="linenumber">36,40</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>
+15
View File
@@ -0,0 +1,15 @@
/* Per-app base: adds this app's own alias map on top of the workspace-wide compiler
options in the root tsconfig.json. See that file's comment on why paths live here,
per-app, instead of at the root. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@shared/*": ["../../libs/shared/src/*"],
"@beheer/*": ["../../libs/beheer/src/*"],
"@auth/*": ["src/app/auth/*"],
"@behandeling/*": ["src/app/behandeling/*"]
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"/api": {
"target": "http://localhost:5000",
"secure": false,
"changeOrigin": true
}
}
@@ -19,6 +19,10 @@ import { SESSION_PORT } from '@shared/application/session.port';
import { SessionStore } from '@auth/application/session.store';
import { provideRouteFocus } from '@shared/layout/route-focus';
import { provideUnloadFlush } from '@shared/application/pending-saves';
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from '@shared/layout/site-header/nav-config';
import { DEBUG_PANEL } from '@shared/layout/shell/shell.component';
import { ADMIN_LINKS, NAV_ITEMS } from './shell/nav.config';
import { DebugStateComponent } from './shell/debug-state/debug-state.component';
// Both locales' data so DatePipe/number pipes work for whichever bundle is active.
registerLocaleData(localeNl);
@@ -59,5 +63,8 @@ export const appConfig: ApplicationConfig = {
{ provide: LOCALE_ID, useFactory: () => $localize.locale ?? 'nl' },
provideRouteFocus(),
provideUnloadFlush(),
{ provide: HEADER_NAV_ITEMS, useValue: NAV_ITEMS },
{ provide: HEADER_ADMIN_LINKS, useValue: ADMIN_LINKS },
{ provide: DEBUG_PANEL, useValue: DebugStateComponent },
],
};
+9
View File
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: '<router-outlet />',
})
export class App {}
@@ -0,0 +1,61 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { Session } from '../domain/session';
import { DigidAdapter } from '../infrastructure/digid.adapter';
const STORAGE_KEY = 'session-v1';
/** Restore a persisted session (best-effort; corrupt entry logged out).
G2: validate the shape before trusting it. G1: the BSN is never persisted
(see the effect below), so a restored session carries an empty one it is
unused after login; only `naam` is shown in the chrome. */
function restore(): Session | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Session>;
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
} catch {
return null;
}
}
/**
* Holds the current session for the whole app. Because it is providedIn:'root'
* there is exactly one instance every component that injects it sees the same
* session signal, so logging in is instantly visible everywhere (the guard, the
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link,
* or the full-page navigation the language switch performs (nl at `/` en at `/en/`,
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
* in an httpOnly cookie/token, not web storage.
*/
@Injectable({ providedIn: 'root' })
export class SessionStore {
private digid = inject(DigidAdapter);
private _session = signal<Session | null>(restore());
readonly session = this._session.asReadonly();
readonly isAuthenticated = computed(() => this._session() !== null);
constructor() {
effect(() => {
const s = this._session();
// G1: persist only `naam` — never write the BSN (national ID) to storage.
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
else localStorage.removeItem(STORAGE_KEY);
});
}
/** Effectful command: authenticate, then store the session on success. */
async login(bsn: string): Promise<Result<string, Session>> {
const r = await this.digid.authenticate(bsn);
if (r.ok) this._session.set(r.value);
return r;
}
logout() {
this._session.set(null);
}
}
+62
View File
@@ -0,0 +1,62 @@
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { describe, it, expect, vi } from 'vitest';
import { AccessStore } from '@shared/application/access.store';
import { SessionStore } from './application/session.store';
import { authGuard, capabilityGuard } from './auth.guard';
type Opts = {
authed: boolean;
can?: (c: string) => boolean;
whenReady?: () => Promise<void>;
};
function setup({ authed, can = () => false, whenReady = () => Promise.resolve() }: Opts) {
const createUrlTree = vi.fn((cmds: string[]) => ({ tree: cmds }));
const readySpy = vi.fn(whenReady);
TestBed.configureTestingModule({
providers: [
{ provide: SessionStore, useValue: { isAuthenticated: () => authed } },
{ provide: AccessStore, useValue: { whenReady: readySpy, can } },
{ provide: Router, useValue: { createUrlTree } },
],
});
return { createUrlTree, readySpy };
}
// The guards ignore their (route, state) args; cast to call with none.
const call = <T>(fn: unknown) => TestBed.runInInjectionContext(() => (fn as () => T)());
describe('authGuard', () => {
it('allows an authenticated user', () => {
setup({ authed: true });
expect(call(authGuard)).toBe(true);
});
it('redirects an anonymous user to /login', () => {
const { createUrlTree } = setup({ authed: false });
expect(call(authGuard)).toEqual({ tree: ['/login'] });
expect(createUrlTree).toHaveBeenCalledWith(['/login']);
});
});
describe('capabilityGuard', () => {
const guard = () => capabilityGuard('stamdata:edit');
it('waits for /me, then allows an entitled admin', async () => {
const { readySpy } = setup({ authed: true, can: (c) => c === 'stamdata:edit' });
await expect(call<Promise<unknown>>(guard())).resolves.toBe(true);
expect(readySpy).toHaveBeenCalledOnce(); // it awaited caps before deciding
});
it('sends an authenticated-but-unentitled user to /dashboard (not a login loop)', async () => {
setup({ authed: true, can: () => false });
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/dashboard'] });
});
it('redirects an anonymous user to /login without waiting for caps', async () => {
const { readySpy } = setup({ authed: false, can: () => true });
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/login'] });
expect(readySpy).not.toHaveBeenCalled();
});
});
+34
View File
@@ -0,0 +1,34 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AccessStore } from '@shared/application/access.store';
import { Capability } from '@shared/domain/capability';
import { SessionStore } from './application/session.store';
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
export const authGuard: CanActivateFn = () => {
const store = inject(SessionStore);
const router = inject(Router);
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
};
/**
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
* redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`).
*
* **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me`
* is still loading it would deny an entitled admin and bounce them. We await
* `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user
* goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're
* logged in, just not allowed here no re-login loop). The backend re-enforces
* regardless (403); this guard is the UX pre-gate.
*/
export function capabilityGuard(capability: Capability): CanActivateFn {
return async () => {
const session = inject(SessionStore);
const access = inject(AccessStore);
const router = inject(Router);
if (!session.isAuthenticated()) return router.createUrlTree(['/login']);
await access.whenReady();
return access.can(capability) ? true : router.createUrlTree(['/dashboard']);
};
}
+9
View File
@@ -0,0 +1,9 @@
/** Who is logged in. Framework-free domain type. */
export interface Session {
readonly bsn: string;
readonly naam: string;
}
export function isAuthenticated(s: Session | null): s is Session {
return s !== null;
}
@@ -0,0 +1,16 @@
import { Injectable } from '@angular/core';
import { Result, ok } from '@shared/kernel/fp';
import { parseBsn } from '@shared/kernel/bsn';
import { Session } from '../domain/session';
/** Infrastructure: talks to the (mock) DigiD identity provider. */
@Injectable({ providedIn: 'root' })
export class DigidAdapter {
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
// for a real OIDC redirect flow when there's an IdP.
async authenticate(bsn: string): Promise<Result<string, Session>> {
const r = parseBsn(bsn);
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
}
}
@@ -0,0 +1,50 @@
import { Component, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
@Component({
selector: 'app-login-form',
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
template: `
<form (ngSubmit)="submitted.emit(bsn)" class="form-horizontal">
<div class="form-header">
<div class="form-action">
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
</div>
</div>
<app-form-field
i18n-label="@@login.bsnLabel"
label="BSN"
fieldId="bsn"
required
i18n-description="@@login.bsnDescription"
description="9-cijferig BSN, elfproef-geldig (demo: 123456782)"
>
<app-text-input
inputId="bsn"
hasDescription
[(ngModel)]="bsn"
name="bsn"
placeholder="123456782"
/>
</app-form-field>
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>
<app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" />
</app-form-field>
<app-button type="submit" variant="primary" i18n="@@login.submit"
>Inloggen met DigiD</app-button
>
</form>
`,
})
export class LoginFormComponent {
bsn = '';
password = '';
submitted = output<string>();
}
@@ -0,0 +1,11 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LoginFormComponent } from './login-form.component';
const meta: Meta<LoginFormComponent> = {
title: 'Domein/Auth/Login Form',
component: LoginFormComponent,
};
export default meta;
type Story = StoryObj<LoginFormComponent>;
export const Default: Story = {};
+36
View File
@@ -0,0 +1,36 @@
import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
import { SessionStore } from '@auth/application/session.store';
@Component({
selector: 'app-login-page',
imports: [PageShellComponent, AlertComponent, LoginFormComponent],
template: `
<app-page-shell
i18n-heading="@@login.heading"
heading="Inloggen"
width="narrow"
i18n-intro="@@login.intro"
intro="Log in op uw persoonlijke BIG-register omgeving."
>
@if (error()) {
<app-alert type="error">{{ error() }}</app-alert>
}
<app-login-form (submitted)="login($event)" />
</app-page-shell>
`,
})
export class LoginPage {
private store = inject(SessionStore);
private router = inject(Router);
error = signal('');
async login(bsn: string) {
const r = await this.store.login(bsn);
if (r.ok) this.router.navigate(['/dashboard']);
else this.error.set(r.error);
}
}

Some files were not shown because too many files have changed in this diff Show More