Compare commits

...
2 Commits
Author SHA1 Message Date
ehoandClaude Sonnet 5 fe69caee63 feat(behandelportal): WP-64 werkvoorraad (queue) screen
CI / changes (pull_request) Successful in 15s
CI / lint (pull_request) Successful in 57s
CI / frontend (pull_request) Successful in 2m36s
CI / storybook-a11y (pull_request) Failing after 3m14s
CI / backend (pull_request) Successful in 2m1s
CI / semgrep (pull_request) Successful in 1m10s
CI / e2e (pull_request) Successful in 3m3s
CI / api-client-drift (pull_request) Successful in 2m1s
New GET /werkvoorraad endpoint lists aanvragen still open (Ingediend/InBehandeling),
gated by the medewerker capability (CanBeoordelen) rather than the admin role — reuses
the existing ApplicationSummaryDto, no new DTO. GET /me now surfaces aanvraag:beoordelen
for a behandelaar so the FE can gate with the same AccessStore/capabilityGuard idiom
every other page uses.

FE: a behandeling domain type deliberately narrower than ssp's full AanvraagStatus
union (only the two open tags — illegal states unrepresentable), composed into a
werkvoorraad-list organism from existing shared/ui molecules. Replaces WP-61's
scaffold placeholder as the app's real landing page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 22:02:35 +02:00
ehoandClaude Sonnet 5 e7156c5132 feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects)
plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's
separate sibling repo. That split had already produced real drift: a
hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree
forked and silently diverging (7 files), and beheer + the styles.scss
token bridge duplicated byte-for-byte across both repos.

- git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/,
  environments/, the Storybook docs/*.mdx, and styles.scss into
  libs/shared + libs/beheer (all confirmed identical between the two
  repos before merging). auth stays deliberately duplicated per
  ADR-0002 (actor-specific, expected to diverge) - amended there.
- One generated API client (libs/shared), no more vendored swagger.json.
- .dependency-cruiser split into a base factory + one config per app,
  and Storybook into .storybook-ssp/.storybook-behandelportal - both
  forced by the @auth/* alias resolving to different directories per app.
- SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/
  HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies
  its own nav/admin-links/dev-panel instead of one being hardcoded.
- CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated;
  WP-67 backlog entry documents the full decision trail.

npm run ci green (lint, dep:check x2, 360 tests across ssp/
behandelportal/shared/beheer, both localized builds, backend tests,
snippet + api-client drift); both dev servers, both Storybook
instances, and docker compose verified working.

The old sibling repo (/home/eho/repos/behandelportal) is left
untouched, not deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 21:01:57 +02:00
412 changed files with 7869 additions and 60923 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*
+13 -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
@@ -300,5 +302,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"
]
}
}
}
}
} }
} }
} }
@@ -19,6 +19,8 @@ import { SESSION_PORT } from '@shared/application/session.port';
import { SessionStore } from '@auth/application/session.store'; import { SessionStore } from '@auth/application/session.store';
import { provideRouteFocus } from '@shared/layout/route-focus'; import { provideRouteFocus } from '@shared/layout/route-focus';
import { provideUnloadFlush } from '@shared/application/pending-saves'; import { provideUnloadFlush } from '@shared/application/pending-saves';
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from '@shared/layout/site-header/nav-config';
import { ADMIN_LINKS, NAV_ITEMS } from './shell/nav.config';
// Both locales' data so DatePipe/number pipes work for whichever bundle is active. // Both locales' data so DatePipe/number pipes work for whichever bundle is active.
registerLocaleData(localeNl); registerLocaleData(localeNl);
@@ -59,5 +61,7 @@ export const appConfig: ApplicationConfig = {
{ provide: LOCALE_ID, useFactory: () => $localize.locale ?? 'nl' }, { provide: LOCALE_ID, useFactory: () => $localize.locale ?? 'nl' },
provideRouteFocus(), provideRouteFocus(),
provideUnloadFlush(), provideUnloadFlush(),
{ provide: HEADER_NAV_ITEMS, useValue: NAV_ITEMS },
{ provide: HEADER_ADMIN_LINKS, useValue: ADMIN_LINKS },
], ],
}; };
+46
View File
@@ -0,0 +1,46 @@
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: '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,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,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,44 @@
import { formatDatumNl } from '@shared/kernel/datum';
import { WerkvoorraadItem, WerkvoorraadStatus, AanvraagType } from './werkvoorraad-item';
/** 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. */
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: WerkvoorraadStatus): 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`;
}
}
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,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,31 @@
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. Rows are informational only
(no `to`): opening a case's detail is 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"
></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
+777
View File
@@ -0,0 +1,777 @@
<?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/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">8</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/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">9</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/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">10</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/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">16</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/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">19</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/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">20</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">36</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">41</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">57</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">58</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">59</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">60</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">61</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">62</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="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
}
}
+70
View File
@@ -0,0 +1,70 @@
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 { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { SESSION_PORT } from '@shared/application/session.port';
import { SessionStore } from '@auth/application/session.store';
import { provideRouteFocus } from '@shared/layout/route-focus';
import { provideUnloadFlush } from '@shared/application/pending-saves';
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from '@shared/layout/site-header/nav-config';
import { DEBUG_PANEL } from '@shared/layout/shell/shell.component';
import { ADMIN_LINKS, NAV_ITEMS } from './shell/nav.config';
import { DebugStateComponent } from './shell/debug-state/debug-state.component';
// Both locales' data so DatePipe/number pipes work for whichever bundle is active.
registerLocaleData(localeNl);
registerLocaleData(localeEn);
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(
routes,
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
// Cross-fade page-to-page navigations only. A silent same-route nav — e.g.
// draft-sync stamping `?aanvraag=<id>` into the URL mid-wizard — must NOT
// animate: for the transition's duration Firefox's `::view-transition`
// overlay swallows pointer events (Chrome sets pointer-events:none, so it
// doesn't), which loses a click landing on it and makes the wizard's "next"
// button need a second click. Skip the transition when the route is unchanged.
withViewTransitions({
onViewTransitionCreated: ({ transition, from, to }) => {
// `from`/`to` are the ROOT snapshots (the shared shell), so descend to the
// leaf before comparing — otherwise every navigation looks "same route".
const leaf = (r: ActivatedRouteSnapshot) => {
while (r.firstChild) r = r.firstChild;
return r;
};
if (leaf(from).routeConfig === leaf(to).routeConfig) transition.skipTransition();
},
}),
),
// Dev-only: the ?scenario= toggle must never reach a production build, where
// a query param could otherwise force errors on the live app.
provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor, roleInterceptor] : [])),
provideApiClient(),
{ provide: SESSION_PORT, useExisting: SessionStore },
// Per-bundle locale: the localize build sets `$localize.locale` ('nl'/'en'); the
// non-localized dev/source build leaves it undefined → fall back to 'nl'. (Was hardcoded
// 'nl', which mis-formatted dates/numbers in the en bundle.)
{ provide: LOCALE_ID, useFactory: () => $localize.locale ?? 'nl' },
provideRouteFocus(),
provideUnloadFlush(),
{ provide: HEADER_NAV_ITEMS, useValue: NAV_ITEMS },
{ provide: HEADER_ADMIN_LINKS, useValue: ADMIN_LINKS },
{ provide: DEBUG_PANEL, useValue: DebugStateComponent },
],
};
+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);
}
}
@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp'; import { Result, ok, err } from '@shared/kernel/fp';
import { currentRole } from '@shared/infrastructure/role'; import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error'; import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '../../../environments/environment'; import { environment } from '@shared/environments/environment';
const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning kon niet worden geopend.`; const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning kon niet worden geopend.`;
@@ -3,7 +3,7 @@ import { Result, ok, err } from '@shared/kernel/fp';
import { runSubmit } from '@shared/application/submit'; import { runSubmit } from '@shared/application/submit';
import { currentRole } from '@shared/infrastructure/role'; import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error'; import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '../../../environments/environment'; import { environment } from '@shared/environments/environment';
import { import {
ApiClient, ApiClient,
OrgTemplateAdminViewDto, OrgTemplateAdminViewDto,
@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp'; import { Result, ok, err } from '@shared/kernel/fp';
import { currentRole } from '@shared/infrastructure/role'; import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error'; import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '../../../environments/environment'; import { environment } from '@shared/environments/environment';
const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`; const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;

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