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,463 @@
import {
Component,
DestroyRef,
ElementRef,
computed,
effect,
inject,
input,
linkedSignal,
output,
signal,
viewChild,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
import { formatDatumNl } from '@shared/kernel/datum';
import { Paragraph } from '@shared/kernel/rich-text';
import { Brief, LetterBlock } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { Diagnostic } from '@brief/domain/placeholders';
import { BlockDiffKind } from '@brief/domain/brief-diff';
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
type PreviewSegment = {
readonly list: 'bullet' | 'number' | null;
readonly items: readonly Paragraph[];
};
function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];
for (const para of paras) {
const kind = para.list ?? null;
const last = out[out.length - 1];
if (kind && last && last.list === kind) last.items.push(para);
else out.push({ list: kind, items: [para] });
}
return out;
}
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
const SAMPLE_VALUES: Record<string, string> = {
naam_zorgverlener: 'J. Jansen',
big_nummer: '12345678901',
};
/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */
const A4_HEIGHT_PX = (297 * 96) / 25.4;
/** Organism: the letter as one surface — the org template's letterhead, signature and
footer around the case-type template's sections. `editableRegions` picks who edits
what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`
renders everything read-only (approver/locked, absorbs the old letter-preview),
`'template'` reserves the org-identity regions for the admin editor (WP-26).
Letter typography/geometry come from the shared `public/letter.css` contract —
the same file the backend preview renderer inlines (WP-25). */
@Component({
selector: 'app-letter-canvas',
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent],
styles: [
`
:host {
display: block;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--rhc-space-max-sm);
margin-block-end: var(--rhc-space-max-sm);
}
.zoom {
display: flex;
align-items: center;
gap: var(--rhc-space-max-sm);
}
.zoom-pct {
min-width: 3.5ch;
text-align: center;
color: var(--rhc-color-foreground-subtle);
font-variant-numeric: tabular-nums;
}
/* Rejection-diff badge (WP-27): a small pill above a changed/added block. */
.diff-block.diff-changed {
border-inline-start: 3px solid var(--rhc-color-oranje-500);
padding-inline-start: var(--rhc-space-max-sm);
}
.diff-badge {
display: inline-block;
margin-block-end: 1mm;
padding: 0 1.5mm;
border-radius: var(--rhc-border-radius-sm);
font-size: 7.5pt;
/* changed = dark text on oranje-500 (4.79:1); white on oranje-500 fails (3.23:1). */
color: var(--rhc-color-foreground-default);
background: var(--rhc-color-oranje-500);
}
.diff-badge.added {
/* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (WP-29 axe). */
color: var(--rhc-color-wit);
background: var(--rhc-color-groen-700);
}
/* Portal-side chrome around the letter surface (not part of the contract file). */
.surface {
background: var(--rhc-color-grijs-100);
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-lg);
overflow-x: auto;
}
.surface .letter {
box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */
}
/* Admin edit-in-place (editableRegions='template'): the org-identity fields
become controls styled to sit in the letter, with a visible editable affordance. */
.tmpl-input,
.tmpl-textarea {
font: inherit;
color: inherit;
width: 100%;
box-sizing: border-box;
background: var(--rhc-color-geel-100);
border: var(--rhc-border-width-sm) dashed var(--rhc-color-border-strong);
border-radius: var(--rhc-border-radius-sm);
padding: 0.5mm 1mm;
}
.tmpl-textarea {
resize: vertical;
}
.org-logo {
max-height: 20mm;
max-width: 60mm;
margin-block-end: 3mm;
}
`,
],
template: `
<ng-template #line let-nodes>
@for (node of nodes; track $index) {
@switch (node.type) {
@case ('text') {
<span>{{ node.text }}</span>
}
@case ('lineBreak') {
<br />
}
@case ('placeholder') {
@if (showSample() && autoFor(node.key)) {
<span>{{ sampleFor(node.key) }}</span>
} @else {
<app-placeholder-chip
[label]="labelFor(node.key)"
[autoResolvable]="autoFor(node.key)"
[state]="stateFor(node.key)"
/>
}
}
}
}
</ng-template>
@if (editableRegions() !== 'template') {
<div class="toolbar">
<div class="zoom" role="group" [attr.aria-label]="zoomGroupLabel()">
<app-button
variant="subtle"
[disabled]="zoomLevel() <= 0.5"
[attr.aria-label]="zoomOutLabel()"
(click)="zoomBy(-0.1)"
>−</app-button
>
<span class="zoom-pct">{{ zoomPct() }}</span>
<app-button
variant="subtle"
[disabled]="zoomLevel() >= 1.5"
[attr.aria-label]="zoomInLabel()"
(click)="zoomBy(0.1)"
>+</app-button
>
<app-button variant="subtle" (click)="zoomLevel.set(1)">{{
zoomResetLabel()
}}</app-button>
</div>
@if (editableRegions() === 'none') {
<app-button
variant="subtle"
(click)="showSample.set(!showSample())"
[attr.aria-pressed]="showSample()"
>
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
</app-button>
}
</div>
}
<div class="surface">
<div class="letter" #page [style]="marginStyle()" [style.zoom]="zoomLevel()">
<!-- div, not <header>/<footer>: the CIBG huisstijl styles those bare elements
(robijn footer background) — the letter surface must stay letter.css-only. -->
<div class="letter__letterhead">
@if (logoUrl()) {
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
}
@if (editing()) {
<input
class="tmpl-input org-wordmark"
[value]="orgTemplate().orgName"
[attr.aria-label]="orgNameLabel()"
(input)="emitEdit('orgName', $event)"
/>
<textarea
class="tmpl-textarea return-address"
rows="2"
[value]="orgTemplate().returnAddress"
[attr.aria-label]="returnAddressLabel()"
(input)="emitEdit('returnAddress', $event)"
></textarea>
} @else {
<p class="org-wordmark">{{ orgTemplate().orgName }}</p>
<address class="return-address">{{ orgTemplate().returnAddress }}</address>
}
<address class="address-window">{{ recipientText() }}</address>
<dl class="reference">
<div>
<dt>{{ referenceLabel() }}</dt>
<dd>{{ brief().briefId }}</dd>
</div>
<div>
<dt>{{ dateLabel() }}</dt>
<dd>{{ letterDate }}</dd>
</div>
</dl>
</div>
<div class="letter__body">
@for (section of brief().sections; track section.sectionKey) {
<section>
<h3>{{ section.title }}</h3>
@for (block of section.blocks; track block.blockId) {
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
<div class="diff-block" [class.diff-changed]="!!diffKind">
@if (diffKind) {
<span class="diff-badge" [class.added]="diffKind === 'added'">{{
diffLabel(diffKind)
}}</span>
}
@for (seg of segmentsOf(block); track $index) {
@if (seg.list === 'bullet') {
<ul>
@for (para of seg.items; track $index) {
<li>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
/>
</li>
}
</ul>
} @else if (seg.list === 'number') {
<ol>
@for (para of seg.items; track $index) {
<li>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
/>
</li>
}
</ol>
} @else {
<p>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }"
/>
</p>
}
}
</div>
}
</section>
}
</div>
<div class="letter__signature">
@if (editing()) {
<input
class="tmpl-input"
[value]="orgTemplate().signatureClosing"
[attr.aria-label]="signatureClosingLabel()"
(input)="emitEdit('signatureClosing', $event)"
/>
<input
class="tmpl-input signature-name"
[value]="orgTemplate().signatureName"
[attr.aria-label]="signatureNameLabel()"
(input)="emitEdit('signatureName', $event)"
/>
<input
class="tmpl-input"
[value]="orgTemplate().signatureRole"
[attr.aria-label]="signatureRoleLabel()"
(input)="emitEdit('signatureRole', $event)"
/>
} @else {
<p>{{ orgTemplate().signatureClosing }}</p>
<p class="signature-name">{{ orgTemplate().signatureName }}</p>
<p>{{ orgTemplate().signatureRole }}</p>
}
</div>
<div class="letter__footer">
@if (editing()) {
<textarea
class="tmpl-textarea footer-contact"
rows="2"
[value]="orgTemplate().footerContact"
[attr.aria-label]="footerContactLabel()"
(input)="emitEdit('footerContact', $event)"
></textarea>
<input
class="tmpl-input footer-legal"
[value]="orgTemplate().footerLegal"
[attr.aria-label]="footerLegalLabel()"
(input)="emitEdit('footerLegal', $event)"
/>
} @else {
<div class="footer-contact">{{ orgTemplate().footerContact }}</div>
<div class="footer-legal">{{ orgTemplate().footerLegal }}</div>
}
</div>
@for (top of pageBreaks(); track $index) {
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
<span>{{ pageBreakCaption() }}</span>
</div>
}
</div>
</div>
`,
})
export class LetterCanvasComponent {
brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>();
/** Who edits what on the surface: read-only ('none', the drafter preview + approver
view) or admin editor ('template', WP-26). Authoring moved to letter-editor. */
editableRegions = input<'template' | 'none'>('none');
diagnostics = input<readonly Diagnostic[]>([]);
/** Initial zoom; the in-canvas controls take over from here (WP-27). */
zoom = input(1);
/** Blocks changed/added/removed since the letter was rejected (WP-27); badged when
`showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer
exist in the letter — the composer surfaces them as a count. */
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
showDiff = input(false);
/** The org logo's content URL (letterhead), or null when none is set. */
logoUrl = input<string | null>(null);
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
pageBreakCaption = input(
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,
);
recipientText = input(
$localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`,
);
referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);
dateLabel = input($localize`:@@brief.canvas.date:Datum`);
logoAlt = input($localize`:@@brief.canvas.logoAlt:Logo van de organisatie`);
orgNameLabel = input($localize`:@@brief.canvas.orgName:Organisatienaam`);
returnAddressLabel = input($localize`:@@brief.canvas.returnAddress:Retouradres`);
signatureClosingLabel = input($localize`:@@brief.canvas.signatureClosing:Afsluiting`);
signatureNameLabel = input($localize`:@@brief.canvas.signatureName:Naam ondertekenaar`);
signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);
footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);
footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);
zoomGroupLabel = input($localize`:@@brief.canvas.zoom:Zoomniveau`);
zoomInLabel = input($localize`:@@brief.canvas.zoomIn:Inzoomen`);
zoomOutLabel = input($localize`:@@brief.canvas.zoomOut:Uitzoomen`);
zoomResetLabel = input($localize`:@@brief.canvas.zoomReset:100%`);
addedLabel = input($localize`:@@brief.diff.added:nieuw`);
changedLabel = input($localize`:@@brief.diff.changed:gewijzigd sinds afwijzing`);
protected showSample = signal(false);
protected letterDate = formatDatumNl(new Date());
/** Zoom seeded from the input; the +/−/reset controls drive it from there. */
protected zoomLevel = linkedSignal(() => this.zoom());
protected zoomPct = computed(() => `${Math.round(this.zoomLevel() * 100)}%`);
protected zoomBy(delta: number) {
// clamp 0.5–1.5; round to avoid float drift accumulating on repeated clicks.
this.zoomLevel.update((z) => Math.round(Math.min(1.5, Math.max(0.5, z + delta)) * 10) / 10);
}
protected diffLabel = (kind: BlockDiffKind) =>
kind === 'added' ? this.addedLabel() : this.changedLabel();
/** Admin edit-in-place: the org-identity regions render as controls. */
protected editing = computed(() => this.editableRegions() === 'template');
protected emitEdit(field: OrgTemplateTextField, event: Event) {
this.templateEdit.emit({
field,
value: (event.target as HTMLInputElement | HTMLTextAreaElement).value,
});
}
protected marginStyle = computed(() => {
const m = this.orgTemplate().margins;
return {
'--letter-margin-top': `${m.topMm}mm`,
'--letter-margin-right': `${m.rightMm}mm`,
'--letter-margin-bottom': `${m.bottomMm}mm`,
'--letter-margin-left': `${m.leftMm}mm`,
};
});
// --- read-only rendering helpers (migrated from the superseded letter-preview) ---
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
private worst = computed(() => {
const m = new Map<string, 'error' | 'warning'>();
for (const d of this.diagnostics()) {
if (!d.placeholderKey) continue;
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
}
return m;
});
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
protected sampleFor = (key: string) =>
SAMPLE_VALUES[key] ?? (key === 'datum' ? this.letterDate : this.labelFor(key));
// --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) ---
private page = viewChild<ElementRef<HTMLElement>>('page');
protected pageBreaks = signal<readonly number[]>([]);
constructor() {
// ponytail: whole-surface height / A4-interval — ignores that a break never truly
// falls mid-line; the caption says "±" and WP-25's server preview is authoritative.
const observer = new ResizeObserver(([entry]) => {
// ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.
const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);
this.pageBreaks.set(
Array.from({ length: Math.max(0, pages - 1) }, (_, i) => (i + 1) * A4_HEIGHT_PX),
);
});
effect((onCleanup) => {
const el = this.page()?.nativeElement;
if (!el) return;
observer.observe(el);
onCleanup(() => observer.unobserve(el));
});
inject(DestroyRef).onDestroy(() => observer.disconnect());
}
}