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>
This commit is contained in:
eho
2026-08-02 21:01:57 +02:00
co-authored by Claude Sonnet 5
parent d3f3b13345
commit e7156c5132
403 changed files with 7103 additions and 60917 deletions
@@ -0,0 +1,205 @@
import { Component, ElementRef, effect, input, output, untracked, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
/** CIBG procesnavigatie primary-button copy for a non-final step: "Naar stap 2 - Werk".
Shared so every wizard's `primaryLabel` reads the same way. */
export const naarStapLabel = (stepNumber: number, stepLabel: string) =>
$localize`:@@wizard.naarStap:Naar stap ${stepNumber}:nummer: - ${stepLabel}:label:`;
/** A flat validation error pointing at a field: `id` matches the field's anchor. */
export interface WizardError {
readonly id: string;
readonly message: string;
}
export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
/**
* Template: the canonical shell every wizard renders into, so they cannot drift.
* It owns the consistent outline — CIBG stappenindicator (title merged in) + error
* summary + the horizontal <form> + the CIBG procesnavigatie button row + the
* submitting/submitted/failed states — and the a11y focus management.
*
* Presentational and unidirectional: all state stays in the wizard container
* (the Elm-style store). Inputs flow down; the container reacts to the outputs
* and dispatches messages. The step's own fields are projected as the default
* slot; the success screen is projected via [wizardSuccess].
*/
@Component({
selector: 'app-wizard-shell',
imports: [FormsModule, ButtonComponent, AlertComponent, SpinnerComponent, StepperComponent],
// CIBG-GAP EXTENSION: Foutmelding — the vendored build has no error-summary/
// Veldvalidatie list pattern (verified absent from huisstijl.min.css); the
// .es-title/.es-list rules below are the hand-rolled surface, see cibg-gaps.mdx.
// They render inside a vendored `.feedback-error` alert (app-alert).
styles: [
`
.es-title {
margin: 0 0 var(--rhc-space-max-sm);
}
.es-list {
margin: 0;
padding-inline-start: var(--rhc-space-max-xl);
}
/* Default link color doesn't meet contrast on the error-alert's light-red surface. */
.es-list a {
color: var(--rhc-color-lintblauw-700);
}
`,
],
template: `
@switch (status()) {
@case ('editing') {
<app-stepper
class="app-section"
[steps]="steps()"
[current]="current()"
[processName]="processName()"
[stepTitle]="stepTitle()"
(stepSelected)="goToStep.emit($event)"
/>
@if (errors().length) {
<div
#errorSummary
tabindex="-1"
role="alert"
aria-labelledby="wizard-error-title"
class="app-section"
>
<app-alert type="error">
<h3 id="wizard-error-title" class="es-title" i18n="@@wizard.errorTitle">
Er ging iets mis met uw invoer
</h3>
<ul class="es-list">
@for (e of errors(); track e.id) {
<li>
<a [href]="'#' + e.id" (click)="goToField($event, e.id)">{{ e.message }}</a>
</li>
}
</ul>
</app-alert>
</div>
}
<form (ngSubmit)="primary.emit()" class="form-horizontal app-section">
<div class="form-header">
<div class="form-action">
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
</div>
</div>
<!-- Wizard pages wrap their field groups in <fieldset>s; CIBG's
".form-horizontal fieldset" gives each a grey #f1f5f9 surface with a 1.25em token-ok: hex named in prose, not a style value
gap. The shell stays group-agnostic and does NOT add its own fieldset (an
outer grey fieldset would hide the white gaps between the page groups). -->
<ng-content />
<hr />
<div class="d-flex flex-column flex-sm-row-reverse">
<div class="m-0">
<app-button type="submit" variant="primary">{{ primaryLabel() }}</app-button>
</div>
@if (canGoBack()) {
<app-button
type="button"
variant="subtle"
class="me-auto"
(click)="back.emit()"
i18n="@@wizard.terugVorige"
>Terug naar vorige stap</app-button
>
}
</div>
<div class="app-section">
<app-button
type="button"
variant="subtle"
(click)="cancel.emit()"
i18n="@@wizard.annuleren"
>Annuleren</app-button
>
</div>
</form>
}
@case ('submitting') {
<app-spinner /> <span>{{ submittingLabel() }}</span>
}
@case ('submitted') {
<ng-content select="[wizardSuccess]" />
}
@case ('failed') {
<app-alert type="error">{{ errorMessage() }}</app-alert>
<div class="app-section">
<app-button variant="secondary" (click)="retry.emit()" i18n="@@wizard.opnieuwProberen"
>Opnieuw proberen</app-button
>
</div>
}
}
`,
})
export class WizardShellComponent {
steps = input.required<string[]>();
current = input.required<number>();
stepTitle = input.required<string>();
/** Overall process name, shown above the step title (e.g. "Herregistratie aanvragen"). */
processName = input('');
status = input.required<WizardStatus>();
primaryLabel = input.required<string>();
canGoBack = input(false);
errors = input<readonly WizardError[]>([]);
errorMessage = input('');
submittingLabel = input($localize`:@@wizard.submitting:Aanvraag wordt verwerkt…`);
primary = output<void>();
back = output<void>();
cancel = output<void>();
retry = output<void>();
/** A visited step number was clicked in the stepper — back-navigation only. */
goToStep = output<number>();
/** Error-summary link: focus the field instead of letting the browser navigate.
A fragment href resolves against <base href="/">, not the current route, so
a real navigation would reload to "/" and bounce to login. */
protected goToField(ev: Event, id: string) {
ev.preventDefault();
document.getElementById(id)?.focus(); // focus() scrolls the input into view
}
private stepper = viewChild(StepperComponent);
private errorSummary = viewChild<ElementRef<HTMLElement>>('errorSummary');
constructor() {
// A11y: move focus to the step title when the step changes (skip first run
// so we don't grab focus on initial load). Tracks current(), which is value-
// stable across keystrokes, so typing never steals focus.
let firstStep = true;
effect(() => {
this.current();
if (firstStep) {
firstStep = false;
return;
}
untracked(() => queueMicrotask(() => this.stepper()?.focusTitle()));
});
// A11y: when validation errors first appear (after a failed submit), move
// focus to the error summary so it's announced. Only on the rising edge
// (none → some): typing rebuilds the errors array each keystroke, and
// re-focusing then would scroll the page up mid-edit. The summary keeps
// role="alert", so content changes are still announced without the jump.
let firstErr = true;
let hadErrors = false;
effect(() => {
const has = this.errors().length > 0;
if (firstErr) {
firstErr = false;
hadErrors = has;
return;
}
if (has && !hadErrors)
untracked(() => queueMicrotask(() => this.errorSummary()?.nativeElement.focus()));
hadErrors = has;
});
}
}