Merge pull request 'Behandelportal: monorepo merge + WP-64..67 backoffice arc (OpenZaak write closes it out)' (#1) from monorepo-behandelportal into main
CI / changes (push) Successful in 9s
CI / lint (push) Successful in 56s
CI / frontend (push) Successful in 2m40s
CI / backend (push) Successful in 2m6s
CI / e2e (push) Successful in 3m20s
CI / semgrep (push) Successful in 1m11s
CI / api-client-drift (push) Successful in 2m10s
CI / storybook-a11y (push) Successful in 11m59s

This commit was merged in pull request #1.
This commit is contained in:
eho
2026-08-04 12:56:33 +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 .git
node_modules node_modules
dist dist
storybook-static storybook-static*
backend/**/bin backend/**/bin
backend/**/obj backend/**/obj
backend/**/bigregister.db* backend/**/bigregister.db*
+17 -11
View File
@@ -35,16 +35,17 @@ jobs:
with: with:
filters: | filters: |
frontend: frontend:
- 'src/**' - 'apps/**'
- 'libs/**'
- 'public/**' - 'public/**'
- 'e2e/**' - 'e2e/**'
- 'scripts/**' - 'scripts/**'
- 'angular.json' - 'angular.json'
- 'package*.json' - 'package*.json'
- 'tsconfig*.json' - 'tsconfig*.json'
- '.storybook/**' - '.storybook*/**'
- 'eslint.config.mjs' - 'eslint.config.mjs'
- '.dependency-cruiser.js' - '.dependency-cruiser*.js'
- '.github/workflows/**' - '.github/workflows/**'
backend: backend:
- 'backend/**' - 'backend/**'
@@ -104,16 +105,17 @@ jobs:
- run: npm run dep:check - run: npm run dep:check
if: needs.changes.outputs.frontend == 'true' if: needs.changes.outputs.frontend == 'true'
# Showcase snippets must match their real source regions (WP-39, no drift). # Showcase snippets must match their real source regions (WP-39, no drift).
- run: npm run gen:snippets && git diff --exit-code src/app/showcase/snippets.generated.ts - run: npm run gen:snippets && git diff --exit-code apps/ssp/src/app/showcase/snippets.generated.ts
if: needs.changes.outputs.frontend == 'true' if: needs.changes.outputs.frontend == 'true'
# Runs the full suite AND reports coverage (WP-46, report-only — no thresholds, so # Runs the full suite (both apps + both shared libraries, WP-67) AND reports coverage
# it can't fail on coverage; it still fails on a failing test, like `npm test` did). # (WP-46, report-only — no thresholds, so it can't fail on coverage; it still fails on
# a failing test, like `npm test` did).
- run: npm run test:coverage - run: npm run test:coverage
if: needs.changes.outputs.frontend == 'true' if: needs.changes.outputs.frontend == 'true'
# --localize builds every configured locale (nl + en, angular.json's i18n # --localize builds every configured locale (nl + en, angular.json's i18n block) in one
# block) in one pass; i18nMissingTranslation:"error" (angular.json) fails # pass per app; i18nMissingTranslation:"error" (angular.json) fails this step if either
# this step if messages.en.xlf is missing a unit the source (WP-20) gains. # app's messages.en.xlf is missing a unit its source (WP-20) or libs/shared gains.
- run: npx ng build --localize - run: npx ng build ssp --localize && npx ng build behandelportal --localize
if: needs.changes.outputs.frontend == 'true' if: needs.changes.outputs.frontend == 'true'
# The shipped bundle must stay clean; dev-only advisories are excluded. # The shipped bundle must stay clean; dev-only advisories are excluded.
- run: npm audit --omit=dev - run: npm audit --omit=dev
@@ -166,6 +168,10 @@ jobs:
if: needs.changes.outputs.frontend == 'true' if: needs.changes.outputs.frontend == 'true'
- run: npm run test-storybook:ci - run: npm run test-storybook:ci
if: needs.changes.outputs.frontend == 'true' if: needs.changes.outputs.frontend == 'true'
- run: npm run build-storybook:behandelportal
if: needs.changes.outputs.frontend == 'true'
- run: npm run test-storybook:ci:behandelportal
if: needs.changes.outputs.frontend == 'true'
backend: backend:
needs: changes needs: changes
@@ -300,5 +306,5 @@ jobs:
if: (needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true') && steps.node-modules-cache.outputs.cache-hit != 'true' if: (needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true') && steps.node-modules-cache.outputs.cache-hit != 'true'
- run: npm run gen:api - run: npm run gen:api
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true'
- run: git diff --exit-code src/app/shared/infrastructure/api-client.ts backend/swagger.json - run: git diff --exit-code libs/shared/src/infrastructure/api-client.ts backend/swagger.json
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true'
+2 -1
View File
@@ -44,7 +44,8 @@ __screenshots__/
Thumbs.db Thumbs.db
*storybook.log *storybook.log
storybook-static storybook-static*
documentation.json
# Playwright e2e # Playwright e2e
/test-results /test-results
+3 -3
View File
@@ -1,6 +1,6 @@
# Build output & caches # Build output & caches
dist/ dist/
storybook-static/ storybook-static*/
coverage/ coverage/
.angular/ .angular/
@@ -9,8 +9,8 @@ package-lock.json
# Generated — owned by their generators, not prettier # Generated — owned by their generators, not prettier
documentation.json documentation.json
src/app/shared/infrastructure/api-client.ts libs/shared/src/infrastructure/api-client.ts
src/app/showcase/snippets.generated.ts apps/ssp/src/app/showcase/snippets.generated.ts
# Vendored design system (CIBG Huisstijl) # Vendored design system (CIBG Huisstijl)
public/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 type { StorybookConfig } from '@storybook/angular';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
// WP-67: one config dir per app — a single merged tsconfig can't resolve both apps' @auth/*
// alias at once (each points at a different physical directory), so this Storybook instance
// only ever compiles under the ssp project's own browserTarget/tsconfig (see angular.json's
// ssp:storybook target) and only globs ssp's own stories + the shared libraries'.
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], stories: [
'../apps/ssp/src/**/*.mdx',
'../libs/shared/**/*.mdx',
'../libs/beheer/**/*.mdx',
'../apps/ssp/src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
'../libs/shared/**/*.stories.@(js|jsx|mjs|ts|tsx)',
'../libs/beheer/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
addons: [ addons: [
'@storybook/addon-a11y', '@storybook/addon-a11y',
// remark-gfm so GFM pipe tables in *.mdx docs actually render (addon-docs // remark-gfm so GFM pipe tables in *.mdx docs actually render (addon-docs
+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 type { Preview } from '@storybook/angular';
import { componentWrapperDecorator } from '@storybook/angular'; import { componentWrapperDecorator } from '@storybook/angular';
import { setCompodocJson } from '@storybook/addon-docs/angular'; import { setCompodocJson } from '@storybook/addon-docs/angular';
import docJson from '../documentation.json'; import docJson from './documentation.json';
setCompodocJson(docJson); setCompodocJson(docJson);
// Activate CIBG's official palette in the story iframe. The override is `body.brand--cibg`, // Activate CIBG's official palette in the story iframe. The override is `body.brand--cibg`,
@@ -26,7 +26,7 @@ const preview: Preview = {
}, },
}, },
// Sidebar tells the DDD seam: reusable design system, then domain contexts. // Sidebar tells the DDD seam: reusable design system, then domain contexts.
// See src/docs/layers.mdx. // See libs/shared/docs/layers.mdx.
options: { options: {
storySort: { storySort: {
order: [ order: [
+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", "extends": "./tsconfig.json",
// Exclude all files that are not needed for documentation generation. // Exclude all files that are not needed for documentation generation.
"exclude": ["../src/test.ts", "../src/**/*.spec.ts", "../src/**/*.stories.ts"], "exclude": [
"../apps/ssp/src/test.ts",
"../apps/ssp/src/**/*.spec.ts",
"../apps/ssp/src/**/*.stories.ts",
"../libs/shared/**/*.spec.ts",
"../libs/shared/**/*.stories.ts",
"../libs/beheer/**/*.spec.ts",
"../libs/beheer/**/*.stories.ts"
],
// Please make sure to include all files from which Compodoc should generate documentation. // Please make sure to include all files from which Compodoc should generate documentation.
"include": ["../src/**/*"], "include": ["../apps/ssp/src/**/*", "../libs/shared/**/*", "../libs/beheer/**/*"],
"files": ["./typings.d.ts"] "files": ["./typings.d.ts"]
} }
+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 BRP/DUO (`Data/SeedData.cs`) is in-memory; applications, documents and the brief
persist to a SQLite file via EF Core (WP-22) — `docs/project/backlog/WP-22-durable-persistence.md`. persist to a SQLite file via EF Core (WP-22) — `docs/project/backlog/WP-22-durable-persistence.md`.
**Monorepo (WP-67):** two Angular projects share one backend + one shared library —
`apps/ssp` (Zorgverlener self-service, this doc's main subject) and `apps/behandelportal`
(Behandelaar backoffice, ADR-0002). Both import `libs/shared` (design system + kernel +
generated API client) and `libs/beheer` (the admin/stamdata context, used identically by
both). `backend/` is unowned by either — a genuinely shared dependency.
## Commands ## Commands
```bash ```bash
npm start # ng serve (proxies /api → backend) → http://localhost:4200 npm start # ng serve ssp (proxies /api → backend) → http://localhost:4200
npm test # vitest npm run start:behandelportal # ng serve behandelportal → http://localhost:4201
npm run lint # eslint — enforces `any`-free code + import/layer boundaries npm test # vitest — both apps + both shared libraries (ssp, behandelportal, shared, beheer)
npm run build # ng build (must stay green) npm run lint # eslint — enforces `any`-free code + import/layer boundaries
npm run storybook # component library by atomic layer npm run build # ng build ssp && ng build behandelportal (must stay green)
npm run gen:api # regenerate the typed client from the backend OpenAPI doc npm run storybook # ssp's component library by atomic layer
npm run ci # run the CI gate locally BEFORE pushing (mirrors ci.yml); `npm run ci --full` adds storybook-a11y npm run storybook:behandelportal # behandelportal's own instance (see "Monorepo" note below)
docker compose up # run FE + backend together (Swagger at :5000/swagger) npm run gen:api # regenerate the ONE typed client (libs/shared) from the backend OpenAPI doc
cd backend && dotnet test # backend rule + endpoint tests npm run ci # run the CI gate locally BEFORE pushing (mirrors ci.yml); `npm run ci --full` adds storybook-a11y
docker compose up # run both FE apps + backend together (Swagger at :5000/swagger)
cd backend && dotnet test # backend rule + endpoint tests
``` ```
**Two Storybook instances, not one:** `apps/ssp` and `apps/behandelportal` each have their own
`auth` context at the same `@auth/*` alias pointing at different physical directories — a single
merged tsconfig can't resolve both at once, so `.storybook-ssp/` and `.storybook-behandelportal/`
are separate config dirs (`npm run storybook[:behandelportal]` / `build-storybook[:behandelportal]`),
each globbing its own app's stories + both shared libraries'.
**Run `npm run ci` before every push** (`scripts/ci-local.sh`) — it runs the same jobs **Run `npm run ci` before every push** (`scripts/ci-local.sh`) — it runs the same jobs
Gitea CI does (lint, format:check, check:tokens, test, `ng build --localize`, audit, backend Gitea CI does (lint, format:check, check:tokens, test, `ng build --localize`, audit, backend
format+test, api-client drift), so a red build is caught locally. Two ways to make it format+test, api-client drift), so a red build is caught locally. Two ways to make it
@@ -65,9 +79,17 @@ session protocol is the worked example of this in practice.
### 1. DDD: contexts then layers, dependencies point inward ### 1. DDD: contexts then layers, dependencies point inward
`src/app/<context>/<layer>/`. Contexts: `shared`, `auth`, `registratie`, `apps/<app>/src/app/<context>/<layer>/` for an app-local context; `libs/<lib>/src/<layer>/`
`herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching for a cross-app library (WP-67). Two apps today: `apps/ssp` (Zorgverlener self-service —
page, not a feature; **sanctioned** to read every context — nothing imports it). contexts `auth`, `registratie`, `herregistratie`, `brief` (letter-composition teaching
slice), `showcase` (teaching page, not a feature; **sanctioned** to read every context in
its own app — nothing imports it)) and `apps/behandelportal` (Behandelaar backoffice,
ADR-0002 — contexts `auth`, `behandeling`). Two cross-app libraries: `libs/shared` (the
design system + kernel + generated API client — no business logic) and `libs/beheer` (the
admin/stamdata context, identical for both apps today — WP-67 folded a silently-diverging
duplicate copy back into one). `auth` is deliberately **not** shared even though today it's
near-identical in both apps — ADR-0002 models Zorgverlener/Medewerker as different
`Principal` variants with different login flows; the two copies are expected to diverge.
| Layer | Job | Angular allowed? | | Layer | Job | Angular allowed? |
| ----------------- | ----------------------------------------- | -------------------------------- | | ----------------- | ----------------------------------------- | -------------------------------- |
@@ -77,17 +99,28 @@ page, not a feature; **sanctioned** to read every context — nothing imports it
| `contracts/` | wire DTOs (the FE⇄BE seam) | no | | `contracts/` | wire DTOs (the FE⇄BE seam) | no |
| `ui/` | how it looks (components, pages) | yes | | `ui/` | how it looks (components, pages) | yes |
**Dependencies only point inward**: `ui → application → domain`; everyone may use **Dependencies only point inward**: `ui → application → domain`; every context in either
`shared`; never the reverse. `ui`/`layout` never import `infrastructure` directly app may use `libs/shared` and `libs/beheer`; never the reverse (`libs/shared` may not
(reach data through an application store/command) — lint-enforced. Cross-context only depend on `libs/beheer` either — it stays the base). `ui`/`layout` never import
`herregistratie → registratie → shared`, `auth → shared`, `brief → shared`. Imports use `infrastructure` directly (reach data through an application store/command) —
aliases as direction statements: `@shared/* @auth/* @registratie/* @herregistratie/* lint-enforced (per app, since each app is cruised against its own tsconfig — WP-67's
@brief/*`. `domain/` imports nothing from Angular. `.dependency-cruiser.base.js` + one thin `.dependency-cruiser.<app>.js` per app). An app
may not import the other app's source directly. Cross-context only
`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 ### 2. Atomic design: folder = layer
`shared/ui` atoms → molecules → organisms; `shared/layout` templates (`shell`, `libs/shared/ui` atoms → molecules → organisms; `libs/shared/layout` templates (`shell`,
`page-shell`); context `ui/` pages. Each level only uses levels below. A new page `page-shell`); each app's own context `ui/` pages. Each level only uses levels below,
and a shared component takes nav/copy as `input()`s or an injection token (e.g.
`HEADER_NAV_ITEMS`/`HEADER_ADMIN_LINKS`, `DEBUG_PANEL` in `shell.component.ts`) rather
than hardcoding one app's content — the two apps' primary nav genuinely differs. A new page
should be **composition of existing blocks** — adding building blocks is the should be **composition of existing blocks** — adding building blocks is the
exception, not the default. Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2) exception, not the default. Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2)
CSS classes (`btn`, `form-control`, `card`, …); we own only a small typed `input()` API, CSS classes (`btn`, `form-control`, `card`, …); we own only a small typed `input()` API,
@@ -97,11 +130,11 @@ small hand-rolled surface built from the token bridge; see ADR-0003.)
### 3. State: make illegal states unrepresentable ### 3. State: make illegal states unrepresentable
Default reflex — **if you're about to add a second/third boolean to track state, Default reflex — **if you're about to add a second/third boolean to track state,
model a discriminated union instead.** Three tools, all in `shared/application`: model a discriminated union instead.** Three tools, all in `libs/shared/src/application`:
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`. - **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`.
Combine sources with `map`/`map2`/`andThen` (Failure > Loading > Success). Combine sources with `map`/`map2`/`andThen` (Failure > Loading > Success).
Render it via the `<app-async>` molecule (`shared/ui/async`) — one of four Render it via the `<app-async>` molecule (`libs/shared/src/ui/async`) — one of four
templates, mutually exclusive by construction. Default loading spinner/skeleton templates, mutually exclusive by construction. Default loading spinner/skeleton
is delay-gated (~250ms) so fast connections don't flash. is delay-gated (~250ms) so fast connections don't flash.
- **Elm-style store** (`store.ts``createStore(initial, reduce)`) — all state in - **Elm-style store** (`store.ts``createStore(initial, reduce)`) — all state in
@@ -116,8 +149,9 @@ model a discriminated union instead.** Three tools, all in `shared/application`:
see `upload.machine.ts`) — prefixing there avoids alias noise at the see `upload.machine.ts`) — prefixing there avoids alias noise at the
composition site. composition site.
- **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a - **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a
branded type only via a parser returning `Result` (`registratie/domain/value-objects/`: branded type only via a parser returning `Result` (ssp's
`Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it. `registratie/domain/value-objects/`: `Postcode`, `Uren`, `BigNummer`). Once you hold
the type, never re-check it.
**Derive, don't store** what you can compute — e.g. the wizard's visible steps are **Derive, don't store** what you can compute — e.g. the wizard's visible steps are
`visibleSteps(answers)`, not a stored field (`intake.machine.ts`). `visibleSteps(answers)`, not a stored field (`intake.machine.ts`).
@@ -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 Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters). spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters).
Test the pure function directly — no Angular TestBed for domain. UI is exercised via Test the pure function directly — no Angular TestBed for domain. UI is exercised via
Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests. Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests
**Story titles mirror the sidebar's Design System/Domein split** (see each app has its **own Storybook instance** (`.storybook-ssp/`, `.storybook-behandelportal/`,
`src/docs/layers.mdx`): a `shared/ui`/`shared/layout` component is titled WP-67 — a single merged tsconfig can't resolve both apps' `@auth/*` at once), each globbing
`Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`; a component in a its own app's stories plus both shared libraries'. **Story titles mirror the sidebar's
context's `ui/` is titled `Domein/<Context>/<Name>` — full stop, regardless of which Design System/Domein split** (see `libs/shared/docs/layers.mdx`): a `libs/shared/ui|layout`
atomic layer it is (a context organism doesn't get its own `Organisms/` bucket). 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 ## 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 (`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a
translation file, not a code change (the seam). Shared/English components must **not** translation file, not a code change (the seam). Shared/English components must **not**
hardcode Dutch — expose copy as `input()`s with localizable defaults; the domain caller hardcode Dutch — expose copy as `input()`s with localizable defaults; the domain caller
supplies the text (see `shared/ui/async`). Format-validation messages in supplies the text (see `libs/shared/src/ui/async`). Format-validation messages in
`domain/value-objects/` stay co-located but are still `$localize`-wrapped. `domain/value-objects/` stay co-located but are still `$localize`-wrapped.
- **Forms = one idiom.** Any form with validation or submission uses a `*.machine.ts` - **Forms = one idiom.** Any form with validation or submission uses a `*.machine.ts`
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the (Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
@@ -190,12 +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 - **Dates: `DatePipe` in templates, `formatDatumNl` in pure TS.** A template formats a
date with Angular's `DatePipe` (`| date: 'longDate'`); pure TS that can't reach a pipe date with Angular's `DatePipe` (`| date: 'longDate'`); pure TS that can't reach a pipe
(a domain function, a `$localize` string) uses the one hand-written (a domain function, a `$localize` string) uses the one hand-written
`formatDatumNl` (`shared/kernel/datum.ts`). Never a third hand-rolled `formatDatumNl` (`libs/shared/src/kernel/datum.ts`). Never a third hand-rolled
`toLocaleDateString` call. `toLocaleDateString` call.
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate: - Routes: lazy `loadComponent`, persistent `ShellComponent` parent (`libs/shared`),
[authGuard]` on protected routes (`app.routes.ts`). `canActivate: [authGuard]` on protected routes (each app's own `app.routes.ts`).
- Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under - Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under
`public/cibg-huisstijl/` and loaded via a `<link>` in `index.html`; `src/styles.scss` holds a `public/cibg-huisstijl/` and loaded via a `<link>` in each app's `index.html`;
`libs/shared/styles.scss` (one copy, both apps' `angular.json` point at it — WP-67) holds a
**token bridge** mapping the app's `--rhc-*` token vocabulary onto CIBG/`--bs-*` values (so **token bridge** mapping the app's `--rhc-*` token vocabulary onto CIBG/`--bs-*` values (so
components keep referencing tokens). System-font stack (licensed RO/Rijks fonts not shipped). See ADR-0003. components keep referencing tokens). System-font stack (licensed RO/Rijks fonts not shipped). See ADR-0003.
- Scenario toggle (**dev-only**, not wired in prod builds): `?scenario=slow|loading|empty|error` - Scenario toggle (**dev-only**, not wired in prod builds): `?scenario=slow|loading|empty|error`
@@ -209,18 +247,22 @@ atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
`/beheer/stamdata`, `/beheer/zaken`, `/beheer/audit`, `/beheer/functies`. `/beheer/stamdata`, `/beheer/zaken`, `/beheer/audit`, `/beheer/functies`.
- Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`, - Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`,
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`. `noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
- **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`) fails the build - **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`, scoped to
on `any` and on illegal imports — `domain/` importing Angular, or a context importing `{apps,libs}/**`) fails the build on `any`; `npm run dep:check`
"upward" (the `herregistratie → registratie → shared`, `auth → shared` direction). (`.dependency-cruiser.base.js` + one `.dependency-cruiser.<app>.js` per app, WP-67) fails
CI (`.github/workflows/ci.yml`) runs lint + `check:tokens` + test + build, backend on illegal imports — `domain/` importing Angular, a context importing "upward" (the
`dotnet test`, and an API-client drift check. `herregistratie → registratie → shared`, `auth → shared` direction), an app importing the
other app's source, or `libs/shared` depending on `libs/beheer`. CI
(`.github/workflows/ci.yml`) runs lint + `dep:check` + `check:tokens` + test (both apps +
both libraries) + build (both apps), backend `dotnet test`, and an API-client drift check
(one generated client, `libs/shared/src/infrastructure/api-client.ts`).
## Adding a feature (recipe) ## Adding a feature (recipe)
Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter: Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter:
`httpResource` or command returning `Result`) → application (store if shared state; `httpResource` or command returning `Result`) → application (store if shared state;
union + pure reduce) → UI last (compose `shared/ui` atoms, wrap async in `<app-async>`, union + pure reduce) → UI last (compose `libs/shared/ui` atoms, wrap async in
dispatch messages). Worked example: the intake wizard (`herregistratie/`). `<app-async>`, dispatch messages). Worked example: the SSP's intake wizard (`herregistratie/`).
The recipes are also invocable skills in `.claude/skills/`: `new-feature`, The recipes are also invocable skills in `.claude/skills/`: `new-feature`,
`new-context`, `value-object`, `form-machine`, `bff-endpoint`, `mutation-command`, `new-context`, `value-object`, `form-machine`, `bff-endpoint`, `mutation-command`,
+228 -20
View File
@@ -7,21 +7,21 @@
}, },
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
"atomic-design-poc": { "ssp": {
"projectType": "application", "projectType": "application",
"schematics": { "schematics": {
"@schematics/angular:component": { "@schematics/angular:component": {
"style": "scss" "style": "scss"
} }
}, },
"root": "", "root": "apps/ssp",
"sourceRoot": "src", "sourceRoot": "apps/ssp/src",
"prefix": "app", "prefix": "app",
"i18n": { "i18n": {
"sourceLocale": { "code": "nl", "subPath": "" }, "sourceLocale": { "code": "nl", "subPath": "" },
"locales": { "locales": {
"en": { "en": {
"translation": "src/locale/messages.en.xlf" "translation": "apps/ssp/src/locale/messages.en.xlf"
} }
} }
}, },
@@ -29,8 +29,8 @@
"build": { "build": {
"builder": "@angular/build:application", "builder": "@angular/build:application",
"options": { "options": {
"browser": "src/main.ts", "browser": "apps/ssp/src/main.ts",
"tsConfig": "tsconfig.app.json", "tsConfig": "apps/ssp/tsconfig.app.json",
"inlineStyleLanguage": "scss", "inlineStyleLanguage": "scss",
"assets": [ "assets": [
{ {
@@ -38,7 +38,7 @@
"input": "public" "input": "public"
} }
], ],
"styles": ["src/styles.scss"], "styles": ["libs/shared/styles.scss"],
"polyfills": ["@angular/localize/init"], "polyfills": ["@angular/localize/init"],
"i18nMissingTranslation": "error" "i18nMissingTranslation": "error"
}, },
@@ -59,8 +59,8 @@
"outputHashing": "all", "outputHashing": "all",
"fileReplacements": [ "fileReplacements": [
{ {
"replace": "src/environments/environment.ts", "replace": "libs/shared/src/environments/environment.ts",
"with": "src/environments/environment.prod.ts" "with": "libs/shared/src/environments/environment.prod.ts"
} }
] ]
}, },
@@ -77,21 +77,27 @@
}, },
"serve": { "serve": {
"builder": "@angular/build:dev-server", "builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "apps/ssp/proxy.conf.json"
},
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "atomic-design-poc:build:production" "buildTarget": "ssp:build:production"
}, },
"development": { "development": {
"buildTarget": "atomic-design-poc:build:development" "buildTarget": "ssp:build:development"
}, },
"en": { "en": {
"buildTarget": "atomic-design-poc:build:development,en" "buildTarget": "ssp:build:development,en"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"
}, },
"test": { "test": {
"builder": "@angular/build:unit-test", "builder": "@angular/build:unit-test",
"options": {
"tsConfig": "apps/ssp/tsconfig.spec.json"
},
"configurations": { "configurations": {
"coverage": { "coverage": {
"coverage": true, "coverage": true,
@@ -100,9 +106,8 @@
"**/*.spec.ts", "**/*.spec.ts",
"**/*.stories.ts", "**/*.stories.ts",
"**/contracts/**", "**/contracts/**",
"src/app/shared/infrastructure/api-client.ts", "libs/shared/src/infrastructure/api-client.ts",
"src/main.ts", "apps/ssp/src/main.ts",
"src/test-setup.ts",
"**/*.d.ts" "**/*.d.ts"
] ]
} }
@@ -111,8 +116,8 @@
"storybook": { "storybook": {
"builder": "@storybook/angular:start-storybook", "builder": "@storybook/angular:start-storybook",
"options": { "options": {
"configDir": ".storybook", "configDir": ".storybook-ssp",
"browserTarget": "atomic-design-poc:build", "browserTarget": "ssp:build",
"compodoc": false, "compodoc": false,
"port": 6006 "port": 6006
} }
@@ -120,14 +125,217 @@
"build-storybook": { "build-storybook": {
"builder": "@storybook/angular:build-storybook", "builder": "@storybook/angular:build-storybook",
"options": { "options": {
"configDir": ".storybook", "configDir": ".storybook-ssp",
"browserTarget": "atomic-design-poc:build", "browserTarget": "ssp:build",
"compodoc": true, "compodoc": true,
"compodocArgs": ["-e", "json", "-d", "."], "compodocArgs": ["-e", "json", "-d", ".storybook-ssp"],
"outputDir": "storybook-static" "outputDir": "storybook-static"
} }
} }
} }
},
"behandelportal": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "apps/behandelportal",
"sourceRoot": "apps/behandelportal/src",
"prefix": "app",
"i18n": {
"sourceLocale": { "code": "nl", "subPath": "" },
"locales": {
"en": {
"translation": "apps/behandelportal/src/locale/messages.en.xlf"
}
}
},
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "apps/behandelportal/src/main.ts",
"tsConfig": "apps/behandelportal/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": ["libs/shared/styles.scss"],
"polyfills": ["@angular/localize/init"],
"i18nMissingTranslation": "error"
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1.5MB",
"maximumError": "2MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all",
"fileReplacements": [
{
"replace": "libs/shared/src/environments/environment.ts",
"with": "libs/shared/src/environments/environment.prod.ts"
}
]
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
},
"en": {
"localize": ["en"]
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"port": 4201,
"proxyConfig": "apps/behandelportal/proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "behandelportal:build:production"
},
"development": {
"buildTarget": "behandelportal:build:development"
},
"en": {
"buildTarget": "behandelportal:build:development,en"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "apps/behandelportal/tsconfig.spec.json"
},
"configurations": {
"coverage": {
"coverage": true,
"coverageReporters": ["text-summary", "html", "lcov"],
"coverageExclude": [
"**/*.spec.ts",
"**/*.stories.ts",
"**/contracts/**",
"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 { SessionStore } from '@auth/application/session.store';
import { provideRouteFocus } from '@shared/layout/route-focus'; import { provideRouteFocus } from '@shared/layout/route-focus';
import { provideUnloadFlush } from '@shared/application/pending-saves'; import { provideUnloadFlush } from '@shared/application/pending-saves';
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from '@shared/layout/site-header/nav-config';
import { 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. // Both locales' data so DatePipe/number pipes work for whichever bundle is active.
registerLocaleData(localeNl); registerLocaleData(localeNl);
@@ -59,5 +63,8 @@ export const appConfig: ApplicationConfig = {
{ provide: LOCALE_ID, useFactory: () => $localize.locale ?? 'nl' }, { provide: LOCALE_ID, useFactory: () => $localize.locale ?? 'nl' },
provideRouteFocus(), provideRouteFocus(),
provideUnloadFlush(), provideUnloadFlush(),
{ provide: HEADER_NAV_ITEMS, useValue: NAV_ITEMS },
{ provide: HEADER_ADMIN_LINKS, useValue: ADMIN_LINKS },
{ 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