Compare commits
10
Commits
dff5f96bb3
...
7a8eab917b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a8eab917b | ||
|
|
bcac3789d3 | ||
|
|
cf1f641534 | ||
|
|
5aed15bb98 | ||
|
|
630d68045f | ||
|
|
d5a7a25a78 | ||
|
|
11e3191099 | ||
|
|
8e1de38c68 | ||
|
|
4b3e6a6cfd | ||
|
|
8a3e42015b |
@@ -0,0 +1,51 @@
|
|||||||
|
import { Brief } from './brief';
|
||||||
|
|
||||||
|
/** A minimal read-only sample letter, so the admin sees the org identity in context
|
||||||
|
while editing (content itself is not the admin's to change). Production content —
|
||||||
|
the letter the org-template editor previews — not a test fixture, so it lives here
|
||||||
|
rather than in `brief.testing.ts` (dependency-cruiser's no-testing-in-production
|
||||||
|
rule forbids production code from reaching any `*.testing.ts`). */
|
||||||
|
export const SAMPLE_LETTER_BRIEF: Brief = {
|
||||||
|
briefId: 'VOORBEELD-0001',
|
||||||
|
beroep: 'arts',
|
||||||
|
templateId: 'sample',
|
||||||
|
drafterId: 'sample',
|
||||||
|
status: { tag: 'draft' },
|
||||||
|
placeholders: [
|
||||||
|
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||||
|
{ key: 'datum', label: 'Datum', autoResolvable: true },
|
||||||
|
],
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
sectionKey: 'body',
|
||||||
|
title: 'Voorbeeldinhoud',
|
||||||
|
required: true,
|
||||||
|
locked: true,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: 'freeText',
|
||||||
|
blockId: 'sample-1',
|
||||||
|
content: {
|
||||||
|
paragraphs: [
|
||||||
|
{
|
||||||
|
nodes: [
|
||||||
|
{ type: 'text', text: 'Geachte ' },
|
||||||
|
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||||
|
{ type: 'text', text: ',' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
/* eslint-disable max-lines */ // 77 lines are CSS, the rest is one letter — RD-26 rewrites this reason, keeps the disable
|
/* eslint-disable max-lines */ // 77 lines of CSS + one letter's markup; splitting it into
|
||||||
|
// letterhead/body/signature/footer makes "what does the letter look like" a five-file
|
||||||
|
// question for no behavioural seam. Deliberate, not deferred.
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
@@ -12,9 +14,7 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgTemplateOutlet } from '@angular/common';
|
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
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 { formatDatumNl } from '@shared/kernel/datum';
|
||||||
import { Paragraph } from '@shared/kernel/rich-text';
|
import { Paragraph } from '@shared/kernel/rich-text';
|
||||||
import { Brief, LetterBlock } from '@brief/domain/brief';
|
import { Brief, LetterBlock } from '@brief/domain/brief';
|
||||||
@@ -22,6 +22,7 @@ import { OrgTemplate } from '@brief/domain/org-template';
|
|||||||
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
||||||
import { Diagnostic } from '@brief/domain/placeholders';
|
import { Diagnostic } from '@brief/domain/placeholders';
|
||||||
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
||||||
|
import { LetterLineComponent } from './letter-line.component';
|
||||||
|
|
||||||
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
|
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
|
||||||
type PreviewSegment = {
|
type PreviewSegment = {
|
||||||
@@ -40,12 +41,6 @@ function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
|
|||||||
return out;
|
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. */
|
/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */
|
||||||
const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||||
|
|
||||||
@@ -55,10 +50,12 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
renders everything read-only (approver/locked, absorbs the old letter-preview),
|
renders everything read-only (approver/locked, absorbs the old letter-preview),
|
||||||
`'template'` reserves the org-identity regions for the admin editor.
|
`'template'` reserves the org-identity regions for the admin editor.
|
||||||
Letter typography/geometry come from the shared `public/letter.css` contract —
|
Letter typography/geometry come from the shared `public/letter.css` contract —
|
||||||
the same file the backend preview renderer inlines. */
|
the same file the backend preview renderer inlines. Each rendered line is its own
|
||||||
|
`app-letter-line` (RD-26), replacing the outlet-template indirection that stood
|
||||||
|
in for it. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-letter-canvas',
|
selector: 'app-letter-canvas',
|
||||||
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent],
|
imports: [ButtonComponent, LetterLineComponent],
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
:host {
|
:host {
|
||||||
@@ -137,37 +134,19 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
`,
|
`,
|
||||||
],
|
],
|
||||||
template: `
|
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') {
|
@if (editableRegions() !== 'template') {
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="zoom" role="group" [attr.aria-label]="zoomGroupLabel()">
|
<div
|
||||||
|
class="zoom"
|
||||||
|
role="group"
|
||||||
|
aria-label="Zoomniveau"
|
||||||
|
i18n-aria-label="@@brief.canvas.zoom"
|
||||||
|
>
|
||||||
<app-button
|
<app-button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
[disabled]="zoomLevel() <= 0.5"
|
[disabled]="zoomLevel() <= 0.5"
|
||||||
[attr.aria-label]="zoomOutLabel()"
|
aria-label="Uitzoomen"
|
||||||
|
i18n-aria-label="@@brief.canvas.zoomOut"
|
||||||
(click)="zoomBy(-0.1)"
|
(click)="zoomBy(-0.1)"
|
||||||
>−</app-button
|
>−</app-button
|
||||||
>
|
>
|
||||||
@@ -175,13 +154,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
<app-button
|
<app-button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
[disabled]="zoomLevel() >= 1.5"
|
[disabled]="zoomLevel() >= 1.5"
|
||||||
[attr.aria-label]="zoomInLabel()"
|
aria-label="Inzoomen"
|
||||||
|
i18n-aria-label="@@brief.canvas.zoomIn"
|
||||||
(click)="zoomBy(0.1)"
|
(click)="zoomBy(0.1)"
|
||||||
>+</app-button
|
>+</app-button
|
||||||
>
|
>
|
||||||
<app-button variant="subtle" (click)="zoomLevel.set(1)">{{
|
<app-button variant="subtle" (click)="zoomLevel.set(1)" i18n="@@brief.canvas.zoomReset"
|
||||||
zoomResetLabel()
|
>100%</app-button
|
||||||
}}</app-button>
|
>
|
||||||
</div>
|
</div>
|
||||||
@if (editableRegions() === 'none') {
|
@if (editableRegions() === 'none') {
|
||||||
<app-button
|
<app-button
|
||||||
@@ -189,7 +169,13 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
(click)="showSample.set(!showSample())"
|
(click)="showSample.set(!showSample())"
|
||||||
[attr.aria-pressed]="showSample()"
|
[attr.aria-pressed]="showSample()"
|
||||||
>
|
>
|
||||||
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
|
@if (showSample()) {
|
||||||
|
<ng-container i18n="@@brief.preview.hideSample">Testwaarden verbergen</ng-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container i18n="@@brief.preview.showSample"
|
||||||
|
>Voorbeeld met testwaarden</ng-container
|
||||||
|
>
|
||||||
|
}
|
||||||
</app-button>
|
</app-button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -201,20 +187,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
(robijn footer background) — the letter surface must stay letter.css-only. -->
|
(robijn footer background) — the letter surface must stay letter.css-only. -->
|
||||||
<div class="letter__letterhead">
|
<div class="letter__letterhead">
|
||||||
@if (logoUrl()) {
|
@if (logoUrl()) {
|
||||||
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
|
<img
|
||||||
|
class="org-logo"
|
||||||
|
[src]="logoUrl()"
|
||||||
|
alt="Logo van de organisatie"
|
||||||
|
i18n-alt="@@brief.canvas.logoAlt"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
@if (editing()) {
|
@if (editing()) {
|
||||||
<input
|
<input
|
||||||
class="tmpl-input org-wordmark"
|
class="tmpl-input org-wordmark"
|
||||||
[value]="orgTemplate().orgName"
|
[value]="orgTemplate().orgName"
|
||||||
[attr.aria-label]="orgNameLabel()"
|
aria-label="Organisatienaam"
|
||||||
|
i18n-aria-label="@@brief.canvas.orgName"
|
||||||
(input)="emitEdit('orgName', $event)"
|
(input)="emitEdit('orgName', $event)"
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
class="tmpl-textarea return-address"
|
class="tmpl-textarea return-address"
|
||||||
rows="2"
|
rows="2"
|
||||||
[value]="orgTemplate().returnAddress"
|
[value]="orgTemplate().returnAddress"
|
||||||
[attr.aria-label]="returnAddressLabel()"
|
aria-label="Retouradres"
|
||||||
|
i18n-aria-label="@@brief.canvas.returnAddress"
|
||||||
(input)="emitEdit('returnAddress', $event)"
|
(input)="emitEdit('returnAddress', $event)"
|
||||||
></textarea>
|
></textarea>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -224,11 +217,11 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
<address class="address-window">{{ recipientText() }}</address>
|
<address class="address-window">{{ recipientText() }}</address>
|
||||||
<dl class="reference">
|
<dl class="reference">
|
||||||
<div>
|
<div>
|
||||||
<dt>{{ referenceLabel() }}</dt>
|
<dt i18n="@@brief.canvas.reference">Ons kenmerk</dt>
|
||||||
<dd>{{ brief().briefId }}</dd>
|
<dd>{{ brief().briefId }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>{{ dateLabel() }}</dt>
|
<dt i18n="@@brief.canvas.date">Datum</dt>
|
||||||
<dd>{{ letterDate }}</dd>
|
<dd>{{ letterDate }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -242,18 +235,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
|
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
|
||||||
<div class="diff-block" [class.diff-changed]="!!diffKind">
|
<div class="diff-block" [class.diff-changed]="!!diffKind">
|
||||||
@if (diffKind) {
|
@if (diffKind) {
|
||||||
<span class="diff-badge" [class.added]="diffKind === 'added'">{{
|
<span class="diff-badge" [class.added]="diffKind === 'added'">
|
||||||
diffLabel(diffKind)
|
@if (diffKind === 'added') {
|
||||||
}}</span>
|
<ng-container i18n="@@brief.diff.added">nieuw</ng-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container i18n="@@brief.diff.changed"
|
||||||
|
>gewijzigd sinds afwijzing</ng-container
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
@for (seg of segmentsOf(block); track $index) {
|
@for (seg of segmentsOf(block); track $index) {
|
||||||
@if (seg.list === 'bullet') {
|
@if (seg.list === 'bullet') {
|
||||||
<ul>
|
<ul>
|
||||||
@for (para of seg.items; track $index) {
|
@for (para of seg.items; track $index) {
|
||||||
<li>
|
<li>
|
||||||
<ng-container
|
<app-letter-line
|
||||||
[ngTemplateOutlet]="line"
|
[nodes]="para.nodes"
|
||||||
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
|
[showSample]="showSample()"
|
||||||
|
[placeholders]="brief().placeholders"
|
||||||
|
[diagnostics]="diagnostics()"
|
||||||
|
[sampleDate]="letterDate"
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -262,18 +264,24 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
<ol>
|
<ol>
|
||||||
@for (para of seg.items; track $index) {
|
@for (para of seg.items; track $index) {
|
||||||
<li>
|
<li>
|
||||||
<ng-container
|
<app-letter-line
|
||||||
[ngTemplateOutlet]="line"
|
[nodes]="para.nodes"
|
||||||
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
|
[showSample]="showSample()"
|
||||||
|
[placeholders]="brief().placeholders"
|
||||||
|
[diagnostics]="diagnostics()"
|
||||||
|
[sampleDate]="letterDate"
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ol>
|
</ol>
|
||||||
} @else {
|
} @else {
|
||||||
<p>
|
<p>
|
||||||
<ng-container
|
<app-letter-line
|
||||||
[ngTemplateOutlet]="line"
|
[nodes]="seg.items[0].nodes"
|
||||||
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }"
|
[showSample]="showSample()"
|
||||||
|
[placeholders]="brief().placeholders"
|
||||||
|
[diagnostics]="diagnostics()"
|
||||||
|
[sampleDate]="letterDate"
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
@@ -289,19 +297,22 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
<input
|
<input
|
||||||
class="tmpl-input"
|
class="tmpl-input"
|
||||||
[value]="orgTemplate().signatureClosing"
|
[value]="orgTemplate().signatureClosing"
|
||||||
[attr.aria-label]="signatureClosingLabel()"
|
aria-label="Afsluiting"
|
||||||
|
i18n-aria-label="@@brief.canvas.signatureClosing"
|
||||||
(input)="emitEdit('signatureClosing', $event)"
|
(input)="emitEdit('signatureClosing', $event)"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
class="tmpl-input signature-name"
|
class="tmpl-input signature-name"
|
||||||
[value]="orgTemplate().signatureName"
|
[value]="orgTemplate().signatureName"
|
||||||
[attr.aria-label]="signatureNameLabel()"
|
aria-label="Naam ondertekenaar"
|
||||||
|
i18n-aria-label="@@brief.canvas.signatureName"
|
||||||
(input)="emitEdit('signatureName', $event)"
|
(input)="emitEdit('signatureName', $event)"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
class="tmpl-input"
|
class="tmpl-input"
|
||||||
[value]="orgTemplate().signatureRole"
|
[value]="orgTemplate().signatureRole"
|
||||||
[attr.aria-label]="signatureRoleLabel()"
|
aria-label="Functie ondertekenaar"
|
||||||
|
i18n-aria-label="@@brief.canvas.signatureRole"
|
||||||
(input)="emitEdit('signatureRole', $event)"
|
(input)="emitEdit('signatureRole', $event)"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -317,13 +328,15 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
class="tmpl-textarea footer-contact"
|
class="tmpl-textarea footer-contact"
|
||||||
rows="2"
|
rows="2"
|
||||||
[value]="orgTemplate().footerContact"
|
[value]="orgTemplate().footerContact"
|
||||||
[attr.aria-label]="footerContactLabel()"
|
aria-label="Contactgegevens (voettekst)"
|
||||||
|
i18n-aria-label="@@brief.canvas.footerContact"
|
||||||
(input)="emitEdit('footerContact', $event)"
|
(input)="emitEdit('footerContact', $event)"
|
||||||
></textarea>
|
></textarea>
|
||||||
<input
|
<input
|
||||||
class="tmpl-input footer-legal"
|
class="tmpl-input footer-legal"
|
||||||
[value]="orgTemplate().footerLegal"
|
[value]="orgTemplate().footerLegal"
|
||||||
[attr.aria-label]="footerLegalLabel()"
|
aria-label="Juridische voettekst"
|
||||||
|
i18n-aria-label="@@brief.canvas.footerLegal"
|
||||||
(input)="emitEdit('footerLegal', $event)"
|
(input)="emitEdit('footerLegal', $event)"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -334,7 +347,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
|
|
||||||
@for (top of pageBreaks(); track $index) {
|
@for (top of pageBreaks(); track $index) {
|
||||||
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
|
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
|
||||||
<span>{{ pageBreakCaption() }}</span>
|
<span i18n="@@brief.canvas.pageBreak">±pagina-einde — afdrukvoorbeeld is leidend</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -360,30 +373,13 @@ export class LetterCanvasComponent {
|
|||||||
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
|
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
|
||||||
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
|
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
|
||||||
|
|
||||||
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
|
/** The one label kept as an `input()` rather than inlined `i18n` (RD-26, decision 2):
|
||||||
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
its message embeds a literal `\n`. As template text that `\n` becomes a source
|
||||||
pageBreakCaption = input(
|
line break, a different string to Angular's extractor — so inlining it would
|
||||||
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,
|
change the extracted source text, unlike the other 19 labels this ticket inlines. */
|
||||||
);
|
|
||||||
recipientText = input(
|
recipientText = input(
|
||||||
$localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`,
|
$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 showSample = signal(false);
|
||||||
protected letterDate = formatDatumNl(new Date());
|
protected letterDate = formatDatumNl(new Date());
|
||||||
@@ -395,9 +391,6 @@ export class LetterCanvasComponent {
|
|||||||
// clamp 0.5–1.5; round to avoid float drift accumulating on repeated clicks.
|
// 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);
|
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. */
|
/** Admin edit-in-place: the org-identity regions render as controls. */
|
||||||
protected editing = computed(() => this.editableRegions() === 'template');
|
protected editing = computed(() => this.editableRegions() === 'template');
|
||||||
|
|
||||||
@@ -418,25 +411,9 @@ export class LetterCanvasComponent {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- read-only rendering helpers (migrated from the superseded letter-preview) ---
|
// --- read-only rendering helper (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 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) ---
|
// --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) ---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Component, computed, input } from '@angular/core';
|
||||||
|
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
||||||
|
import { RichTextNode } from '@shared/kernel/rich-text';
|
||||||
|
import { Diagnostic, PlaceholderDef } from '@brief/domain/placeholders';
|
||||||
|
|
||||||
|
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
|
||||||
|
const SAMPLE_VALUES: Record<string, string> = {
|
||||||
|
naam_zorgverlener: 'J. Jansen',
|
||||||
|
big_nummer: '12345678901',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `brief().placeholders` keyed by `key` for O(1) lookup from a node. */
|
||||||
|
export function placeholderDefs(
|
||||||
|
placeholders: readonly PlaceholderDef[],
|
||||||
|
): Map<string, PlaceholderDef> {
|
||||||
|
return new Map(placeholders.map((p) => [p.key, p]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The worst (error over warning) diagnostic severity per placeholder key. */
|
||||||
|
export function worstSeverities(
|
||||||
|
diagnostics: readonly Diagnostic[],
|
||||||
|
): Map<string, 'error' | 'warning'> {
|
||||||
|
const m = new Map<string, 'error' | 'warning'>();
|
||||||
|
for (const d of 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLabel(defs: ReadonlyMap<string, PlaceholderDef>, key: string): string {
|
||||||
|
return defs.get(key)?.label ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAuto(defs: ReadonlyMap<string, PlaceholderDef>, key: string): boolean {
|
||||||
|
return defs.get(key)?.autoResolvable ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveState(
|
||||||
|
worst: ReadonlyMap<string, 'error' | 'warning'>,
|
||||||
|
key: string,
|
||||||
|
): 'ok' | 'warning' | 'error' {
|
||||||
|
return worst.get(key) ?? 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "Voorbeeld" toggle's stand-in for an auto-resolvable placeholder: a canned
|
||||||
|
sample, the caller's sample date for `datum`, or the field's own label. */
|
||||||
|
export function resolveSample(
|
||||||
|
defs: ReadonlyMap<string, PlaceholderDef>,
|
||||||
|
sampleDate: string,
|
||||||
|
key: string,
|
||||||
|
): string {
|
||||||
|
return SAMPLE_VALUES[key] ?? (key === 'datum' ? sampleDate : resolveLabel(defs, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Organism: one rendered line of letter content — text runs, line breaks and
|
||||||
|
placeholder chips (an auto-resolvable one swaps to a sample value when
|
||||||
|
`showSample` is on). Extracted from `letter-canvas` (RD-26): the `#line`
|
||||||
|
template plus the label/auto/state/sample helpers it needs, so the canvas's
|
||||||
|
three `ngTemplateOutlet` incantations become one tag each. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-letter-line',
|
||||||
|
imports: [PlaceholderChipComponent],
|
||||||
|
template: `
|
||||||
|
@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)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class LetterLineComponent {
|
||||||
|
nodes = input.required<readonly RichTextNode[]>();
|
||||||
|
showSample = input(false);
|
||||||
|
placeholders = input<readonly PlaceholderDef[]>([]);
|
||||||
|
diagnostics = input<readonly Diagnostic[]>([]);
|
||||||
|
/** The canvas's `formatDatumNl(new Date())`, passed down so every line agrees. */
|
||||||
|
sampleDate = input('');
|
||||||
|
|
||||||
|
private defs = computed(() => placeholderDefs(this.placeholders()));
|
||||||
|
private worst = computed(() => worstSeverities(this.diagnostics()));
|
||||||
|
|
||||||
|
protected labelFor = (key: string) => resolveLabel(this.defs(), key);
|
||||||
|
protected autoFor = (key: string) => resolveAuto(this.defs(), key);
|
||||||
|
protected stateFor = (key: string) => resolveState(this.worst(), key);
|
||||||
|
protected sampleFor = (key: string) => resolveSample(this.defs(), this.sampleDate(), key);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { PlaceholderDef, Diagnostic } from '@brief/domain/placeholders';
|
||||||
|
import {
|
||||||
|
placeholderDefs,
|
||||||
|
resolveAuto,
|
||||||
|
resolveLabel,
|
||||||
|
resolveSample,
|
||||||
|
resolveState,
|
||||||
|
worstSeverities,
|
||||||
|
} from './letter-line.component';
|
||||||
|
|
||||||
|
const DEFS: readonly PlaceholderDef[] = [
|
||||||
|
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||||
|
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOCATION = { blockId: 'b1', paragraphIndex: 0, nodeIndex: 0 };
|
||||||
|
|
||||||
|
function diagnostic(placeholderKey: string, severity: 'error' | 'warning'): Diagnostic {
|
||||||
|
return { severity, code: 'unresolved-at-send', message: 'x', placeholderKey, location: LOCATION };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('placeholderDefs', () => {
|
||||||
|
it('keys the placeholder list by its key', () => {
|
||||||
|
const defs = placeholderDefs(DEFS);
|
||||||
|
expect(defs.get('naam_zorgverlener')?.label).toBe('Naam zorgverlener');
|
||||||
|
expect(defs.get('unknown')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('worstSeverities', () => {
|
||||||
|
it('ignores a diagnostic with no placeholder key', () => {
|
||||||
|
const worst = worstSeverities([
|
||||||
|
{ severity: 'error', code: 'malformed', message: 'x', location: LOCATION },
|
||||||
|
]);
|
||||||
|
expect(worst.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps error over a warning already recorded for the same key', () => {
|
||||||
|
const worst = worstSeverities([
|
||||||
|
diagnostic('naam_zorgverlener', 'warning'),
|
||||||
|
diagnostic('naam_zorgverlener', 'error'),
|
||||||
|
]);
|
||||||
|
expect(worst.get('naam_zorgverlener')).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a later warning downgrade an error', () => {
|
||||||
|
const worst = worstSeverities([
|
||||||
|
diagnostic('naam_zorgverlener', 'error'),
|
||||||
|
diagnostic('naam_zorgverlener', 'warning'),
|
||||||
|
]);
|
||||||
|
expect(worst.get('naam_zorgverlener')).toBe('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveLabel', () => {
|
||||||
|
it('returns the field label for a known key', () => {
|
||||||
|
expect(resolveLabel(placeholderDefs(DEFS), 'reden_besluit')).toBe('Reden besluit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the bare key when the field is unknown', () => {
|
||||||
|
expect(resolveLabel(placeholderDefs(DEFS), 'unknown')).toBe('unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveAuto', () => {
|
||||||
|
it('reads autoResolvable off the field', () => {
|
||||||
|
const defs = placeholderDefs(DEFS);
|
||||||
|
expect(resolveAuto(defs, 'naam_zorgverlener')).toBe(true);
|
||||||
|
expect(resolveAuto(defs, 'reden_besluit')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to false for an unknown key', () => {
|
||||||
|
expect(resolveAuto(placeholderDefs(DEFS), 'unknown')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveState', () => {
|
||||||
|
it('defaults to ok when the key has no diagnostic', () => {
|
||||||
|
expect(resolveState(worstSeverities([]), 'naam_zorgverlener')).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the worst recorded severity', () => {
|
||||||
|
const worst = worstSeverities([diagnostic('naam_zorgverlener', 'warning')]);
|
||||||
|
expect(resolveState(worst, 'naam_zorgverlener')).toBe('warning');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveSample', () => {
|
||||||
|
it('prefers the canned sample value over the label', () => {
|
||||||
|
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'naam_zorgverlener')).toBe(
|
||||||
|
'J. Jansen',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves datum to the caller-supplied sample date', () => {
|
||||||
|
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'datum')).toBe(
|
||||||
|
'4 september 2026',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the field label for anything else', () => {
|
||||||
|
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'reden_besluit')).toBe(
|
||||||
|
'Reden besluit',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
|
import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';
|
||||||
|
import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';
|
||||||
|
import { UploadState } from '@shared/domain/upload.machine';
|
||||||
|
|
||||||
|
const LOGO_CATEGORY = 'org-logo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organism: the org-template editor's logo-upload block, split out of
|
||||||
|
* `org-template-editor.component.ts` (RD-25) — one of its two self-contained
|
||||||
|
* mutation clusters. Presentational: every mutation is an output the parent
|
||||||
|
* re-emits unchanged.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-logo-upload',
|
||||||
|
imports: [HeadingComponent, AlertComponent, FileInputComponent, SingleUploadComponent],
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-block-start: var(--rhc-space-max-xl);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<section class="section">
|
||||||
|
<app-heading [level]="3" i18n="@@orgTemplate.logo">Logo</app-heading>
|
||||||
|
@if (logoCategory()) {
|
||||||
|
<app-file-input
|
||||||
|
inputId="org-logo-input"
|
||||||
|
[accept]="logoCategory()!.acceptedTypes"
|
||||||
|
[maxSizeMb]="logoCategory()!.maxSizeMb"
|
||||||
|
i18n-label="@@orgTemplate.logo"
|
||||||
|
label="Logo"
|
||||||
|
(filesSelected)="logoSelected.emit($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@if (logoRejection()) {
|
||||||
|
<app-alert type="error">{{ logoRejection() }}</app-alert>
|
||||||
|
}
|
||||||
|
@if (logoUploads().length) {
|
||||||
|
<ul class="file-list">
|
||||||
|
@for (u of logoUploads(); track u.localId) {
|
||||||
|
<li
|
||||||
|
app-single-upload
|
||||||
|
[upload]="u"
|
||||||
|
[previewUrlFor]="previewUrlFor()"
|
||||||
|
(remove)="logoRemoved.emit(u.localId)"
|
||||||
|
(retry)="logoRetry.emit(u.localId)"
|
||||||
|
></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class LogoUploadComponent {
|
||||||
|
logoUrl = input<string | null>(null);
|
||||||
|
uploadState = input.required<UploadState>();
|
||||||
|
previewUrlFor = input<(documentId: string) => string | undefined>();
|
||||||
|
|
||||||
|
logoSelected = output<File[]>();
|
||||||
|
logoRemoved = output<string>();
|
||||||
|
logoRetry = output<string>();
|
||||||
|
|
||||||
|
protected logoCategory = computed(() =>
|
||||||
|
this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),
|
||||||
|
);
|
||||||
|
protected logoUploads = computed(() =>
|
||||||
|
this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),
|
||||||
|
);
|
||||||
|
protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);
|
||||||
|
}
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
/* eslint-disable max-lines */ // sample letter + labels + editor in one file — removed by RD-25
|
|
||||||
import { Component, computed, input, output } from '@angular/core';
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
import { DatePipe } from '@angular/common';
|
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';
|
|
||||||
import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';
|
|
||||||
import { UploadState } from '@shared/domain/upload.machine';
|
import { UploadState } from '@shared/domain/upload.machine';
|
||||||
import { Brief } from '@brief/domain/brief';
|
import { Brief } from '@brief/domain/brief';
|
||||||
|
import { SAMPLE_LETTER_BRIEF } from '@brief/domain/sample-letter';
|
||||||
import {
|
import {
|
||||||
MARGIN_MAX_MM,
|
MARGIN_MAX_MM,
|
||||||
MARGIN_MIN_MM,
|
MARGIN_MIN_MM,
|
||||||
@@ -18,74 +14,29 @@ import {
|
|||||||
} from '@brief/domain/org-template';
|
} from '@brief/domain/org-template';
|
||||||
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
||||||
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
||||||
|
import { LogoUploadComponent } from './logo-upload.component';
|
||||||
|
import { VersionHistoryComponent } from './version-history.component';
|
||||||
|
|
||||||
const LOGO_CATEGORY = 'org-logo';
|
|
||||||
const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm'];
|
const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm'];
|
||||||
|
|
||||||
/** A minimal read-only sample letter, so the admin sees the org identity in context
|
|
||||||
while editing (content itself is not the admin's to change). */
|
|
||||||
export const SAMPLE_LETTER_BRIEF: Brief = {
|
|
||||||
briefId: 'VOORBEELD-0001',
|
|
||||||
beroep: 'arts',
|
|
||||||
templateId: 'sample',
|
|
||||||
drafterId: 'sample',
|
|
||||||
status: { tag: 'draft' },
|
|
||||||
placeholders: [
|
|
||||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
|
||||||
{ key: 'datum', label: 'Datum', autoResolvable: true },
|
|
||||||
],
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
sectionKey: 'body',
|
|
||||||
title: 'Voorbeeldinhoud',
|
|
||||||
required: true,
|
|
||||||
locked: true,
|
|
||||||
blocks: [
|
|
||||||
{
|
|
||||||
type: 'freeText',
|
|
||||||
blockId: 'sample-1',
|
|
||||||
content: {
|
|
||||||
paragraphs: [
|
|
||||||
{
|
|
||||||
nodes: [
|
|
||||||
{ type: 'text', text: 'Geachte ' },
|
|
||||||
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
|
||||||
{ type: 'text', text: ',' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Organism: the admin org-template editor. The mirror of the drafter's
|
* Organism: the admin org-template editor. The mirror of the drafter's
|
||||||
* composer — the letter canvas runs in `editableRegions='template'` so the
|
* composer — the letter canvas runs in `editableRegions='template'` so the
|
||||||
* letterhead/signature/footer are edited in place, while the content is a read-only
|
* letterhead/signature/footer are edited in place, while the content is a read-only
|
||||||
* sample. Margins, logo upload, version history and the publish bar sit around it.
|
* sample. Margins and the publish bar sit around it; the logo uploader and version
|
||||||
* Presentational: every mutation is an output the store turns into a command.
|
* history are their own children (`app-logo-upload`, `app-version-history`, RD-25) —
|
||||||
|
* each a self-contained mutation cluster. Presentational: every mutation is an
|
||||||
|
* output the store turns into a command, whether sourced here or re-emitted from
|
||||||
|
* a child.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-org-template-editor',
|
selector: 'app-org-template-editor',
|
||||||
imports: [
|
imports: [
|
||||||
DatePipe,
|
|
||||||
HeadingComponent,
|
|
||||||
ButtonComponent,
|
ButtonComponent,
|
||||||
AlertComponent,
|
AlertComponent,
|
||||||
FileInputComponent,
|
|
||||||
SingleUploadComponent,
|
|
||||||
LetterCanvasComponent,
|
LetterCanvasComponent,
|
||||||
|
LogoUploadComponent,
|
||||||
|
VersionHistoryComponent,
|
||||||
],
|
],
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
@@ -123,22 +74,6 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
|
|||||||
.margins input {
|
.margins input {
|
||||||
width: 6rem;
|
width: 6rem;
|
||||||
}
|
}
|
||||||
.history-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--rhc-space-max-sm);
|
|
||||||
}
|
|
||||||
.history-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--rhc-space-max-md);
|
|
||||||
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
|
||||||
padding-block-end: var(--rhc-space-max-sm);
|
|
||||||
}
|
|
||||||
.bar {
|
.bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -154,7 +89,7 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
|
|||||||
template: `
|
template: `
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>{{ subOrgLabel() }}</span>
|
<span i18n="@@orgTemplate.subOrg">Organisatieonderdeel</span>
|
||||||
<select class="form-select" (change)="onSelectSubOrg($event)">
|
<select class="form-select" (change)="onSelectSubOrg($event)">
|
||||||
@for (o of subOrgs(); track o.subOrgId) {
|
@for (o of subOrgs(); track o.subOrgId) {
|
||||||
<option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()">
|
<option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()">
|
||||||
@@ -191,80 +126,62 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
|
|||||||
}
|
}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<section class="section">
|
<app-logo-upload
|
||||||
<app-heading [level]="3">{{ logoHeading() }}</app-heading>
|
[logoUrl]="logoUrl()"
|
||||||
@if (logoCategory()) {
|
[uploadState]="uploadState()"
|
||||||
<app-file-input
|
[previewUrlFor]="previewUrlFor()"
|
||||||
inputId="org-logo-input"
|
(logoSelected)="logoSelected.emit($event)"
|
||||||
[accept]="logoCategory()!.acceptedTypes"
|
(logoRemoved)="logoRemoved.emit($event)"
|
||||||
[maxSizeMb]="logoCategory()!.maxSizeMb"
|
(logoRetry)="logoRetry.emit($event)"
|
||||||
[label]="logoHeading()"
|
/>
|
||||||
(filesSelected)="logoSelected.emit($event)"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
@if (logoRejection()) {
|
|
||||||
<app-alert type="error">{{ logoRejection() }}</app-alert>
|
|
||||||
}
|
|
||||||
@if (logoUploads().length) {
|
|
||||||
<ul class="file-list">
|
|
||||||
@for (u of logoUploads(); track u.localId) {
|
|
||||||
<li
|
|
||||||
app-single-upload
|
|
||||||
[upload]="u"
|
|
||||||
[previewUrlFor]="previewUrlFor()"
|
|
||||||
(remove)="logoRemoved.emit(u.localId)"
|
|
||||||
(retry)="logoRetry.emit(u.localId)"
|
|
||||||
></li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
<app-version-history
|
||||||
<app-heading [level]="3">{{ historyHeading() }}</app-heading>
|
[history]="history()"
|
||||||
@if (history().length === 0) {
|
[publishedVersion]="publishedVersion()"
|
||||||
<p class="published">{{ noHistory() }}</p>
|
[busy]="busy()"
|
||||||
} @else {
|
(rollback)="rollback.emit($event)"
|
||||||
<ul class="history-list">
|
/>
|
||||||
@for (v of history(); track v.version) {
|
|
||||||
<li class="history-row">
|
|
||||||
<span
|
|
||||||
>{{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}</span
|
|
||||||
>
|
|
||||||
<app-button variant="subtle" [disabled]="busy()" (click)="rollback.emit(v.version)">
|
|
||||||
{{ rollbackLabel() }}
|
|
||||||
</app-button>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
<span class="published">{{ publishedLabel() }} {{ publishedVersion() }}</span>
|
<span class="published"
|
||||||
|
><span i18n="@@orgTemplate.published">Gepubliceerde versie:</span>
|
||||||
|
{{ publishedVersion() }}</span
|
||||||
|
>
|
||||||
@if (pendingPublish()) {
|
@if (pendingPublish()) {
|
||||||
<app-alert type="warning">{{ impactText() }}</app-alert>
|
<app-alert type="warning">{{ impactText() }}</app-alert>
|
||||||
<app-button variant="primary" [disabled]="busy()" (click)="confirmPublish.emit()">
|
<app-button
|
||||||
{{ confirmLabel() }}
|
variant="primary"
|
||||||
</app-button>
|
[disabled]="busy()"
|
||||||
<app-button variant="subtle" [disabled]="busy()" (click)="cancelPublish.emit()">
|
(click)="confirmPublish.emit()"
|
||||||
{{ cancelLabel() }}
|
i18n="@@orgTemplate.publish.confirm"
|
||||||
</app-button>
|
>Bevestigen</app-button
|
||||||
|
>
|
||||||
|
<app-button
|
||||||
|
variant="subtle"
|
||||||
|
[disabled]="busy()"
|
||||||
|
(click)="cancelPublish.emit()"
|
||||||
|
i18n="@@orgTemplate.publish.cancel"
|
||||||
|
>Annuleren</app-button
|
||||||
|
>
|
||||||
} @else {
|
} @else {
|
||||||
<app-button
|
<app-button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
[disabled]="!draftValid() || busy()"
|
[disabled]="!draftValid() || busy()"
|
||||||
(click)="requestPublish.emit()"
|
(click)="requestPublish.emit()"
|
||||||
|
i18n="@@orgTemplate.publish"
|
||||||
|
>Publiceren</app-button
|
||||||
>
|
>
|
||||||
{{ publishLabel() }}
|
|
||||||
</app-button>
|
|
||||||
@if (!draftValid()) {
|
@if (!draftValid()) {
|
||||||
<span class="published">{{ invalidHint() }}</span>
|
<span class="published">{{ invalidHint() }}</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
<app-button variant="secondary" [disabled]="busy()" (click)="proefbrief.emit()">
|
<app-button
|
||||||
{{ proefbriefLabel() }}
|
variant="secondary"
|
||||||
</app-button>
|
[disabled]="busy()"
|
||||||
|
(click)="proefbrief.emit()"
|
||||||
|
i18n="@@orgTemplate.proefbrief"
|
||||||
|
>Proefbrief</app-button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -300,14 +217,6 @@ export class OrgTemplateEditorComponent {
|
|||||||
protected readonly MIN = MARGIN_MIN_MM;
|
protected readonly MIN = MARGIN_MIN_MM;
|
||||||
protected readonly MAX = MARGIN_MAX_MM;
|
protected readonly MAX = MARGIN_MAX_MM;
|
||||||
|
|
||||||
protected logoCategory = computed(() =>
|
|
||||||
this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),
|
|
||||||
);
|
|
||||||
protected logoUploads = computed(() =>
|
|
||||||
this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),
|
|
||||||
);
|
|
||||||
protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);
|
|
||||||
|
|
||||||
protected onSelectSubOrg(event: Event) {
|
protected onSelectSubOrg(event: Event) {
|
||||||
this.selectSubOrg.emit((event.target as HTMLSelectElement).value);
|
this.selectSubOrg.emit((event.target as HTMLSelectElement).value);
|
||||||
}
|
}
|
||||||
@@ -334,20 +243,9 @@ export class OrgTemplateEditorComponent {
|
|||||||
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
|
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
|
||||||
);
|
);
|
||||||
|
|
||||||
protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);
|
|
||||||
protected marginsLegend = input(
|
protected marginsLegend = input(
|
||||||
$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`,
|
$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`,
|
||||||
);
|
);
|
||||||
protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`);
|
|
||||||
protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);
|
|
||||||
protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`);
|
|
||||||
protected versionLabel = input($localize`:@@orgTemplate.version:Versie`);
|
|
||||||
protected rollbackLabel = input($localize`:@@orgTemplate.rollback:Terugzetten in concept`);
|
|
||||||
protected publishedLabel = input($localize`:@@orgTemplate.published:Gepubliceerde versie:`);
|
|
||||||
protected publishLabel = input($localize`:@@orgTemplate.publish:Publiceren`);
|
|
||||||
protected confirmLabel = input($localize`:@@orgTemplate.publish.confirm:Bevestigen`);
|
|
||||||
protected cancelLabel = input($localize`:@@orgTemplate.publish.cancel:Annuleren`);
|
|
||||||
protected proefbriefLabel = input($localize`:@@orgTemplate.proefbrief:Proefbrief`);
|
|
||||||
protected invalidHint = input(
|
protected invalidHint = input(
|
||||||
$localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,
|
$localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Component, input, output } from '@angular/core';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
|
import { OrgTemplateVersion } from '@brief/domain/org-template';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organism: the org-template editor's version-history block, split out of
|
||||||
|
* `org-template-editor.component.ts` (RD-25) — one of its two self-contained
|
||||||
|
* mutation clusters. Presentational: `rollback` is the parent's own output,
|
||||||
|
* re-emitted unchanged.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-version-history',
|
||||||
|
imports: [DatePipe, HeadingComponent, ButtonComponent],
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-block-start: var(--rhc-space-max-xl);
|
||||||
|
}
|
||||||
|
.history-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--rhc-space-max-sm);
|
||||||
|
}
|
||||||
|
.history-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--rhc-space-max-md);
|
||||||
|
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||||
|
padding-block-end: var(--rhc-space-max-sm);
|
||||||
|
}
|
||||||
|
.published {
|
||||||
|
color: var(--rhc-color-foreground-subtle);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<section class="section">
|
||||||
|
<app-heading [level]="3" i18n="@@orgTemplate.history">Versiegeschiedenis</app-heading>
|
||||||
|
@if (history().length === 0) {
|
||||||
|
<p class="published" i18n="@@orgTemplate.history.none">Nog niets gepubliceerd.</p>
|
||||||
|
} @else {
|
||||||
|
<ul class="history-list">
|
||||||
|
@for (v of history(); track v.version) {
|
||||||
|
<li class="history-row">
|
||||||
|
<span
|
||||||
|
><span i18n="@@orgTemplate.version">Versie</span> {{ v.version }} ·
|
||||||
|
{{ v.publishedAt | date: 'longDate' }}</span
|
||||||
|
>
|
||||||
|
<app-button
|
||||||
|
variant="subtle"
|
||||||
|
[disabled]="busy()"
|
||||||
|
(click)="rollback.emit(v.version)"
|
||||||
|
i18n="@@orgTemplate.rollback"
|
||||||
|
>Terugzetten in concept</app-button
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class VersionHistoryComponent {
|
||||||
|
history = input<readonly OrgTemplateVersion[]>([]);
|
||||||
|
publishedVersion = input(0);
|
||||||
|
busy = input(false);
|
||||||
|
|
||||||
|
rollback = output<number>();
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
|
|||||||
// #endregion showcase:steps
|
// #endregion showcase:steps
|
||||||
|
|
||||||
/** Per-field error map: one message per question, since a step holds several. */
|
/** Per-field error map: one message per question, since a step holds several. */
|
||||||
type Errors = Partial<Record<keyof Answers, string>>;
|
export type Errors = Partial<Record<keyof Answers, string>>;
|
||||||
|
|
||||||
export type IntakeState =
|
export type IntakeState =
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Component, input, 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 { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component';
|
||||||
|
import { Answers, Errors } from '@herregistratie/domain/intake.machine';
|
||||||
|
|
||||||
|
/** Step: the intake wizard's first screen (foreign work in the last 5 years).
|
||||||
|
Pure & presentational — values in via `answers`/`errors`, every keystroke out
|
||||||
|
via `answerChange`. No store, no services, no internal state; the parent owns
|
||||||
|
the Model and decides what a change means. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-intake-buitenland-step',
|
||||||
|
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent],
|
||||||
|
template: `
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@intake.q.buitenland"
|
||||||
|
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
|
||||||
|
fieldId="buitenlandGewerkt"
|
||||||
|
required
|
||||||
|
[error]="err('buitenlandGewerkt')"
|
||||||
|
>
|
||||||
|
<app-radio-group
|
||||||
|
name="buitenlandGewerkt"
|
||||||
|
[options]="jaNee"
|
||||||
|
[ngModel]="answers().buitenlandGewerkt ?? ''"
|
||||||
|
(ngModelChange)="answerChange.emit({ key: 'buitenlandGewerkt', value: $event })"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
@if (answers().buitenlandGewerkt === 'ja') {
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@intake.q.land"
|
||||||
|
label="In welk land?"
|
||||||
|
fieldId="land"
|
||||||
|
required
|
||||||
|
[error]="err('land')"
|
||||||
|
>
|
||||||
|
<app-text-input
|
||||||
|
inputId="land"
|
||||||
|
[ngModel]="answers().land ?? ''"
|
||||||
|
(ngModelChange)="answerChange.emit({ key: 'land', value: $event })"
|
||||||
|
name="land"
|
||||||
|
i18n-placeholder="@@intake.q.landPlaceholder"
|
||||||
|
placeholder="bijv. België"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@intake.q.buitenlandseUren"
|
||||||
|
label="Hoeveel uur heeft u daar gewerkt?"
|
||||||
|
fieldId="buitenlandseUren"
|
||||||
|
required
|
||||||
|
[error]="err('buitenlandseUren')"
|
||||||
|
>
|
||||||
|
<app-text-input
|
||||||
|
inputId="buitenlandseUren"
|
||||||
|
[ngModel]="answers().buitenlandseUren ?? ''"
|
||||||
|
(ngModelChange)="answerChange.emit({ key: 'buitenlandseUren', value: $event })"
|
||||||
|
name="buitenlandseUren"
|
||||||
|
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
|
||||||
|
placeholder="bijv. 800"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class BuitenlandStep {
|
||||||
|
answers = input.required<Answers>();
|
||||||
|
errors = input.required<Errors>();
|
||||||
|
answerChange = output<{ key: keyof Answers; value: string }>();
|
||||||
|
|
||||||
|
readonly jaNee = JA_NEE;
|
||||||
|
protected err = (k: keyof Answers) => this.errors()[k] ?? '';
|
||||||
|
}
|
||||||
@@ -1,13 +1,5 @@
|
|||||||
/* eslint-disable max-lines */ // one wizard shell for the intake steps — removed by RD-22
|
|
||||||
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
|
import { Component, computed, effect, inject, input, untracked } 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 { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component';
|
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
|
||||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
|
||||||
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
|
|
||||||
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
||||||
import {
|
import {
|
||||||
WizardShellComponent,
|
WizardShellComponent,
|
||||||
@@ -23,16 +15,19 @@ import {
|
|||||||
IntakeState,
|
IntakeState,
|
||||||
IntakeMsg,
|
IntakeMsg,
|
||||||
Answers,
|
Answers,
|
||||||
|
Errors,
|
||||||
StepId,
|
StepId,
|
||||||
initial,
|
initial,
|
||||||
reduce,
|
reduce,
|
||||||
STEPS,
|
STEPS,
|
||||||
lageUren,
|
|
||||||
hasProgress,
|
hasProgress,
|
||||||
SCHOLING_THRESHOLD_DEFAULT,
|
SCHOLING_THRESHOLD_DEFAULT,
|
||||||
} from '@herregistratie/domain/intake.machine';
|
} from '@herregistratie/domain/intake.machine';
|
||||||
import { createDraftSync } from '@registratie/application/draft-sync';
|
import { createDraftSync } from '@registratie/application/draft-sync';
|
||||||
import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';
|
import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';
|
||||||
|
import { BuitenlandStep } from './buitenland.step';
|
||||||
|
import { WerkStep } from './werk.step';
|
||||||
|
import { ReviewStep } from './review.step';
|
||||||
|
|
||||||
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
|
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
|
||||||
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
|
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
|
||||||
@@ -42,16 +37,12 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-intake-wizard',
|
selector: 'app-intake-wizard',
|
||||||
imports: [
|
imports: [
|
||||||
FormsModule,
|
|
||||||
FormFieldComponent,
|
|
||||||
TextInputComponent,
|
|
||||||
RadioGroupComponent,
|
|
||||||
ButtonComponent,
|
ButtonComponent,
|
||||||
AlertComponent,
|
|
||||||
DataRowComponent,
|
|
||||||
ReviewSectionComponent,
|
|
||||||
ConfirmationComponent,
|
ConfirmationComponent,
|
||||||
WizardShellComponent,
|
WizardShellComponent,
|
||||||
|
BuitenlandStep,
|
||||||
|
WerkStep,
|
||||||
|
ReviewStep,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<app-wizard-shell
|
<app-wizard-shell
|
||||||
@@ -72,180 +63,26 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
|
|||||||
>
|
>
|
||||||
@switch (step()) {
|
@switch (step()) {
|
||||||
@case ('buitenland') {
|
@case ('buitenland') {
|
||||||
<fieldset>
|
<app-intake-buitenland-step
|
||||||
<app-form-field
|
[answers]="answers()"
|
||||||
i18n-label="@@intake.q.buitenland"
|
[errors]="errors()"
|
||||||
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
|
(answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
|
||||||
fieldId="buitenlandGewerkt"
|
/>
|
||||||
required
|
|
||||||
[error]="err('buitenlandGewerkt')"
|
|
||||||
>
|
|
||||||
<app-radio-group
|
|
||||||
name="buitenlandGewerkt"
|
|
||||||
[options]="jaNee"
|
|
||||||
[ngModel]="answers().buitenlandGewerkt ?? ''"
|
|
||||||
(ngModelChange)="set('buitenlandGewerkt', $event)"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
@if (answers().buitenlandGewerkt === 'ja') {
|
|
||||||
<fieldset>
|
|
||||||
<app-form-field
|
|
||||||
i18n-label="@@intake.q.land"
|
|
||||||
label="In welk land?"
|
|
||||||
fieldId="land"
|
|
||||||
required
|
|
||||||
[error]="err('land')"
|
|
||||||
>
|
|
||||||
<app-text-input
|
|
||||||
inputId="land"
|
|
||||||
[ngModel]="answers().land ?? ''"
|
|
||||||
(ngModelChange)="set('land', $event)"
|
|
||||||
name="land"
|
|
||||||
i18n-placeholder="@@intake.q.landPlaceholder"
|
|
||||||
placeholder="bijv. België"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
<app-form-field
|
|
||||||
i18n-label="@@intake.q.buitenlandseUren"
|
|
||||||
label="Hoeveel uur heeft u daar gewerkt?"
|
|
||||||
fieldId="buitenlandseUren"
|
|
||||||
required
|
|
||||||
[error]="err('buitenlandseUren')"
|
|
||||||
>
|
|
||||||
<app-text-input
|
|
||||||
inputId="buitenlandseUren"
|
|
||||||
[ngModel]="answers().buitenlandseUren ?? ''"
|
|
||||||
(ngModelChange)="set('buitenlandseUren', $event)"
|
|
||||||
name="buitenlandseUren"
|
|
||||||
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
|
|
||||||
placeholder="bijv. 800"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@case ('werk') {
|
@case ('werk') {
|
||||||
<fieldset>
|
<app-intake-werk-step
|
||||||
<app-form-field
|
[answers]="answers()"
|
||||||
i18n-label="@@intake.q.urenNl"
|
[errors]="errors()"
|
||||||
label="Gewerkte uren in Nederland (afgelopen 5 jaar)"
|
[scholingThreshold]="scholingThreshold()"
|
||||||
fieldId="uren"
|
(answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
|
||||||
required
|
/>
|
||||||
[error]="err('uren')"
|
|
||||||
>
|
|
||||||
<app-text-input
|
|
||||||
inputId="uren"
|
|
||||||
[ngModel]="answers().uren ?? ''"
|
|
||||||
(ngModelChange)="set('uren', $event)"
|
|
||||||
name="uren"
|
|
||||||
i18n-placeholder="@@intake.q.urenNlPlaceholder"
|
|
||||||
placeholder="bijv. 4160"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
@if (scholingZichtbaar()) {
|
|
||||||
<fieldset>
|
|
||||||
<app-form-field
|
|
||||||
i18n-label="@@intake.q.scholing"
|
|
||||||
label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?"
|
|
||||||
fieldId="scholingGevolgd"
|
|
||||||
required
|
|
||||||
[error]="err('scholingGevolgd')"
|
|
||||||
>
|
|
||||||
<app-radio-group
|
|
||||||
name="scholingGevolgd"
|
|
||||||
[options]="jaNee"
|
|
||||||
[ngModel]="answers().scholingGevolgd ?? ''"
|
|
||||||
(ngModelChange)="set('scholingGevolgd', $event)"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
}
|
|
||||||
@if (answers().scholingGevolgd === 'ja') {
|
|
||||||
<fieldset>
|
|
||||||
<app-form-field
|
|
||||||
i18n-label="@@intake.q.punten"
|
|
||||||
label="Behaalde nascholingspunten"
|
|
||||||
fieldId="punten"
|
|
||||||
required
|
|
||||||
[error]="err('punten')"
|
|
||||||
>
|
|
||||||
<app-text-input
|
|
||||||
inputId="punten"
|
|
||||||
[ngModel]="answers().punten ?? ''"
|
|
||||||
(ngModelChange)="set('punten', $event)"
|
|
||||||
name="punten"
|
|
||||||
i18n-placeholder="@@intake.q.puntenPlaceholder"
|
|
||||||
placeholder="bijv. 200"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@case ('review') {
|
@case ('review') {
|
||||||
<app-alert type="info" i18n="@@intake.review.controleer"
|
<app-intake-review-step
|
||||||
>Controleer uw antwoorden en dien de aanvraag in.</app-alert
|
[answers]="answers()"
|
||||||
>
|
[scholingThreshold]="scholingThreshold()"
|
||||||
<app-review-section
|
(edit)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
|
||||||
i18n-heading="@@intake.sectie.buitenland"
|
/>
|
||||||
heading="Buitenland"
|
|
||||||
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
|
|
||||||
editAriaLabel="Wijzigen buitenland"
|
|
||||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@intake.review.buitenNl"
|
|
||||||
key="Buiten NL gewerkt"
|
|
||||||
[value]="answers().buitenlandGewerkt ?? '—'"
|
|
||||||
></div>
|
|
||||||
@if (answers().buitenlandGewerkt === 'ja') {
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@intake.review.land"
|
|
||||||
key="Land"
|
|
||||||
[value]="answers().land ?? ''"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@intake.review.buitenlandseUren"
|
|
||||||
key="Buitenlandse uren"
|
|
||||||
[value]="answers().buitenlandseUren ?? ''"
|
|
||||||
></div>
|
|
||||||
}
|
|
||||||
</app-review-section>
|
|
||||||
<app-review-section
|
|
||||||
class="app-section"
|
|
||||||
i18n-heading="@@intake.sectie.werk"
|
|
||||||
heading="Werk in Nederland"
|
|
||||||
i18n-editAriaLabel="@@intake.werkWijzigenAria"
|
|
||||||
editAriaLabel="Wijzigen werk in Nederland"
|
|
||||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@intake.review.urenNl"
|
|
||||||
key="Uren NL"
|
|
||||||
[value]="answers().uren ?? ''"
|
|
||||||
></div>
|
|
||||||
@if (scholingZichtbaar()) {
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@intake.review.scholing"
|
|
||||||
key="Aanvullende scholing"
|
|
||||||
[value]="answers().scholingGevolgd ?? ''"
|
|
||||||
></div>
|
|
||||||
}
|
|
||||||
@if (answers().scholingGevolgd === 'ja') {
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@intake.review.punten"
|
|
||||||
key="Nascholingspunten"
|
|
||||||
[value]="answers().punten ?? ''"
|
|
||||||
></div>
|
|
||||||
}
|
|
||||||
</app-review-section>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +132,6 @@ export class IntakeWizardComponent {
|
|||||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||||
seed = input<IntakeState>(initial);
|
seed = input<IntakeState>(initial);
|
||||||
|
|
||||||
readonly jaNee = JA_NEE;
|
|
||||||
readonly state = this.store.model;
|
readonly state = this.store.model;
|
||||||
readonly dispatch = this.store.dispatch;
|
readonly dispatch = this.store.dispatch;
|
||||||
|
|
||||||
@@ -321,8 +157,7 @@ export class IntakeWizardComponent {
|
|||||||
protected scholingThreshold = computed(
|
protected scholingThreshold = computed(
|
||||||
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
|
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
|
||||||
);
|
);
|
||||||
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
|
protected errors = computed<Errors>(() => this.answering()?.errors ?? {});
|
||||||
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
|
||||||
|
|
||||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||||
readonly stepLabels = [
|
readonly stepLabels = [
|
||||||
@@ -365,10 +200,6 @@ export class IntakeWizardComponent {
|
|||||||
toWizardErrors(this.answering()?.errors ?? {}),
|
toWizardErrors(this.answering()?.errors ?? {}),
|
||||||
);
|
);
|
||||||
|
|
||||||
protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
|
|
||||||
protected set = (key: keyof Answers, value: string) =>
|
|
||||||
this.dispatch({ tag: 'SetAnswer', key, value });
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
|
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
|
||||||
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
|
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
|
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||||
|
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
|
||||||
|
import { Answers, lageUren } from '@herregistratie/domain/intake.machine';
|
||||||
|
|
||||||
|
/** Step: the intake wizard's review screen. Pure & presentational — values in via
|
||||||
|
`answers`/`scholingThreshold`, the cursor to jump back to out via `edit`. No
|
||||||
|
store, no services, no internal state; the parent maps the cursor onto its own
|
||||||
|
`GaNaarStap` message. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-intake-review-step',
|
||||||
|
imports: [AlertComponent, DataRowComponent, ReviewSectionComponent],
|
||||||
|
template: `
|
||||||
|
<app-alert type="info" i18n="@@intake.review.controleer"
|
||||||
|
>Controleer uw antwoorden en dien de aanvraag in.</app-alert
|
||||||
|
>
|
||||||
|
<app-review-section
|
||||||
|
i18n-heading="@@intake.sectie.buitenland"
|
||||||
|
heading="Buitenland"
|
||||||
|
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
|
||||||
|
editAriaLabel="Wijzigen buitenland"
|
||||||
|
(edit)="edit.emit(0)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@intake.review.buitenNl"
|
||||||
|
key="Buiten NL gewerkt"
|
||||||
|
[value]="answers().buitenlandGewerkt ?? '—'"
|
||||||
|
></div>
|
||||||
|
@if (answers().buitenlandGewerkt === 'ja') {
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@intake.review.land"
|
||||||
|
key="Land"
|
||||||
|
[value]="answers().land ?? ''"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@intake.review.buitenlandseUren"
|
||||||
|
key="Buitenlandse uren"
|
||||||
|
[value]="answers().buitenlandseUren ?? ''"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
</app-review-section>
|
||||||
|
<app-review-section
|
||||||
|
class="app-section"
|
||||||
|
i18n-heading="@@intake.sectie.werk"
|
||||||
|
heading="Werk in Nederland"
|
||||||
|
i18n-editAriaLabel="@@intake.werkWijzigenAria"
|
||||||
|
editAriaLabel="Wijzigen werk in Nederland"
|
||||||
|
(edit)="edit.emit(1)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@intake.review.urenNl"
|
||||||
|
key="Uren NL"
|
||||||
|
[value]="answers().uren ?? ''"
|
||||||
|
></div>
|
||||||
|
@if (scholingZichtbaar()) {
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@intake.review.scholing"
|
||||||
|
key="Aanvullende scholing"
|
||||||
|
[value]="answers().scholingGevolgd ?? ''"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
@if (answers().scholingGevolgd === 'ja') {
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@intake.review.punten"
|
||||||
|
key="Nascholingspunten"
|
||||||
|
[value]="answers().punten ?? ''"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
</app-review-section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ReviewStep {
|
||||||
|
answers = input.required<Answers>();
|
||||||
|
scholingThreshold = input.required<number>();
|
||||||
|
edit = output<number>();
|
||||||
|
|
||||||
|
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Component, computed, input, 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 { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component';
|
||||||
|
import { Answers, Errors, lageUren } from '@herregistratie/domain/intake.machine';
|
||||||
|
|
||||||
|
/** Step: the intake wizard's second screen (work experience in the Netherlands,
|
||||||
|
with the inline scholing follow-up). Pure & presentational — values in via
|
||||||
|
`answers`/`errors`/`scholingThreshold`, every keystroke out via `answerChange`.
|
||||||
|
No store, no services, no internal state; the parent owns the Model and
|
||||||
|
decides what a change means. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-intake-werk-step',
|
||||||
|
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent],
|
||||||
|
template: `
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@intake.q.urenNl"
|
||||||
|
label="Gewerkte uren in Nederland (afgelopen 5 jaar)"
|
||||||
|
fieldId="uren"
|
||||||
|
required
|
||||||
|
[error]="err('uren')"
|
||||||
|
>
|
||||||
|
<app-text-input
|
||||||
|
inputId="uren"
|
||||||
|
[ngModel]="answers().uren ?? ''"
|
||||||
|
(ngModelChange)="answerChange.emit({ key: 'uren', value: $event })"
|
||||||
|
name="uren"
|
||||||
|
i18n-placeholder="@@intake.q.urenNlPlaceholder"
|
||||||
|
placeholder="bijv. 4160"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
@if (scholingZichtbaar()) {
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@intake.q.scholing"
|
||||||
|
label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?"
|
||||||
|
fieldId="scholingGevolgd"
|
||||||
|
required
|
||||||
|
[error]="err('scholingGevolgd')"
|
||||||
|
>
|
||||||
|
<app-radio-group
|
||||||
|
name="scholingGevolgd"
|
||||||
|
[options]="jaNee"
|
||||||
|
[ngModel]="answers().scholingGevolgd ?? ''"
|
||||||
|
(ngModelChange)="answerChange.emit({ key: 'scholingGevolgd', value: $event })"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
@if (answers().scholingGevolgd === 'ja') {
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@intake.q.punten"
|
||||||
|
label="Behaalde nascholingspunten"
|
||||||
|
fieldId="punten"
|
||||||
|
required
|
||||||
|
[error]="err('punten')"
|
||||||
|
>
|
||||||
|
<app-text-input
|
||||||
|
inputId="punten"
|
||||||
|
[ngModel]="answers().punten ?? ''"
|
||||||
|
(ngModelChange)="answerChange.emit({ key: 'punten', value: $event })"
|
||||||
|
name="punten"
|
||||||
|
i18n-placeholder="@@intake.q.puntenPlaceholder"
|
||||||
|
placeholder="bijv. 200"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class WerkStep {
|
||||||
|
answers = input.required<Answers>();
|
||||||
|
errors = input.required<Errors>();
|
||||||
|
scholingThreshold = input.required<number>();
|
||||||
|
answerChange = output<{ key: keyof Answers; value: string }>();
|
||||||
|
|
||||||
|
readonly jaNee = JA_NEE;
|
||||||
|
protected err = (k: keyof Answers) => this.errors()[k] ?? '';
|
||||||
|
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Component, inject, input, 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 { RadioGroupComponent } from '@shared/ui/radio-group/radio-group.component';
|
||||||
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
|
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||||
|
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
|
||||||
|
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
|
||||||
|
import { Draft, DraftField, Errors } from '@registratie/domain/registratie-wizard.machine';
|
||||||
|
|
||||||
|
const KANALEN = [
|
||||||
|
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
|
||||||
|
{ value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Step: the registratie wizard's first screen (adres + correspondentievoorkeur).
|
||||||
|
Injects RegistratieLookupStore directly for the BRP lookup banner — the
|
||||||
|
sanctioned exception (it is `providedIn: 'root'`, so every injection is the
|
||||||
|
same instance): the step owns its own async presentation rather than making
|
||||||
|
the parent a pass-through for it. Values otherwise in via `draft`/`errors`,
|
||||||
|
every change out via `fieldChange`/`kanaalChange`. No internal state; the
|
||||||
|
parent owns the Model and decides what a change means. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-reg-adres-step',
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
FormFieldComponent,
|
||||||
|
TextInputComponent,
|
||||||
|
RadioGroupComponent,
|
||||||
|
AlertComponent,
|
||||||
|
SkeletonComponent,
|
||||||
|
AddressFieldsComponent,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
@if (adresStatus() === 'laden') {
|
||||||
|
<app-skeleton height="2.5rem" [count]="4" />
|
||||||
|
} @else {
|
||||||
|
@switch (adresStatus()) {
|
||||||
|
@case ('gevonden') {
|
||||||
|
<app-alert type="info" i18n="@@regWizard.brpGevonden"
|
||||||
|
>Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert
|
||||||
|
>
|
||||||
|
}
|
||||||
|
@case ('geen') {
|
||||||
|
<app-alert type="warning" i18n="@@regWizard.brpGeen"
|
||||||
|
>We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert
|
||||||
|
>
|
||||||
|
}
|
||||||
|
@case ('fout') {
|
||||||
|
<app-alert type="warning" i18n="@@regWizard.brpFout"
|
||||||
|
>We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in.</app-alert
|
||||||
|
>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<app-address-fields
|
||||||
|
[value]="{
|
||||||
|
straat: draft().straat ?? '',
|
||||||
|
postcode: draft().postcode ?? '',
|
||||||
|
woonplaats: draft().woonplaats ?? '',
|
||||||
|
}"
|
||||||
|
[errors]="{
|
||||||
|
straat: err('straat'),
|
||||||
|
postcode: err('postcode'),
|
||||||
|
woonplaats: err('woonplaats'),
|
||||||
|
}"
|
||||||
|
(fieldChange)="fieldChange.emit($event)"
|
||||||
|
/>
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@regWizard.correspondentieLabel"
|
||||||
|
label="Hoe wilt u correspondentie ontvangen?"
|
||||||
|
fieldId="correspondentie"
|
||||||
|
required
|
||||||
|
[error]="err('correspondentie')"
|
||||||
|
>
|
||||||
|
<app-radio-group
|
||||||
|
name="correspondentie"
|
||||||
|
[options]="kanalen"
|
||||||
|
[invalid]="!!err('correspondentie')"
|
||||||
|
[ngModel]="draft().correspondentie ?? ''"
|
||||||
|
(ngModelChange)="kanaalChange.emit($event)"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
@if (draft().correspondentie === 'email') {
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@regWizard.emailLabel"
|
||||||
|
label="E-mailadres"
|
||||||
|
fieldId="email"
|
||||||
|
required
|
||||||
|
[error]="err('email')"
|
||||||
|
>
|
||||||
|
<app-text-input
|
||||||
|
inputId="email"
|
||||||
|
type="email"
|
||||||
|
[invalid]="!!err('email')"
|
||||||
|
[ngModel]="draft().email ?? ''"
|
||||||
|
(ngModelChange)="fieldChange.emit({ key: 'email', value: $event })"
|
||||||
|
name="email"
|
||||||
|
i18n-placeholder="@@regWizard.emailPlaceholder"
|
||||||
|
placeholder="naam@voorbeeld.nl"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class AdresStep {
|
||||||
|
private lookup = inject(RegistratieLookupStore);
|
||||||
|
|
||||||
|
draft = input.required<Draft>();
|
||||||
|
errors = input.required<Errors>();
|
||||||
|
fieldChange = output<{ key: DraftField; value: string }>();
|
||||||
|
kanaalChange = output<string>();
|
||||||
|
|
||||||
|
protected adresStatus = this.lookup.adresStatus;
|
||||||
|
readonly kanalen = KANALEN;
|
||||||
|
protected err = (k: DraftField | 'correspondentie') => this.errors()[k] ?? '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { Component, computed, inject, input, 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 { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component';
|
||||||
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
|
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||||
|
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||||
|
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
|
||||||
|
import { ASYNC } from '@shared/ui/async/async.component';
|
||||||
|
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
|
||||||
|
import { createUploadController } from '@shared/application/upload-controller';
|
||||||
|
import { UploadMsg, UploadState } from '@shared/domain/upload.machine';
|
||||||
|
import { RemoteData, successOr } from '@shared/application/remote-data';
|
||||||
|
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
|
||||||
|
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
|
||||||
|
import { Draft, Errors } from '@registratie/domain/registratie-wizard.machine';
|
||||||
|
|
||||||
|
/** The server-owned geldigheidsvraag whose "ja" answer requires a Dutch-taalvaardigheid
|
||||||
|
upload (proof of the confirmed B2 level). Stable id shared with the backend. */
|
||||||
|
const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
|
||||||
|
/** Sentinel option: "my diploma isn't listed". Exported so the parent's
|
||||||
|
`onDiplomaKeuze` (registratie-wizard.component.ts) can recognize it too. */
|
||||||
|
export const HANDMATIG = '__handmatig__';
|
||||||
|
|
||||||
|
/** Step: the registratie wizard's second screen (beroep op basis van diploma).
|
||||||
|
Injects RegistratieLookupStore directly for the DUO lookup — the sanctioned
|
||||||
|
exception (it is `providedIn: 'root'`, so every injection is the same
|
||||||
|
instance) — and owns its own `<app-async>` over it. Also owns the upload
|
||||||
|
controller, moved here from the parent: this is what gets the parent under
|
||||||
|
the line limit. Values in via `draft`/`errors`/`upload`; every user intent
|
||||||
|
leaves as one of four outputs. No store beyond the lookup, and no machine
|
||||||
|
message built here; the parent maps each output onto its own message. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-reg-beroep-step',
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
FormFieldComponent,
|
||||||
|
TextInputComponent,
|
||||||
|
RadioGroupComponent,
|
||||||
|
AlertComponent,
|
||||||
|
SkeletonComponent,
|
||||||
|
DataRowComponent,
|
||||||
|
DataBlockComponent,
|
||||||
|
DocumentUploadComponent,
|
||||||
|
...ASYNC,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<app-async [data]="lookupRd()">
|
||||||
|
<ng-template appAsyncLoaded>
|
||||||
|
@if (duoData(); as data) {
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@regWizard.diplomaLabel"
|
||||||
|
label="Kies het diploma waarmee u zich wilt registreren"
|
||||||
|
fieldId="diploma"
|
||||||
|
required
|
||||||
|
[error]="err('diploma')"
|
||||||
|
>
|
||||||
|
<app-radio-group
|
||||||
|
name="diploma"
|
||||||
|
[options]="diplomaOptions(data)"
|
||||||
|
[invalid]="!!err('diploma')"
|
||||||
|
[ngModel]="diplomaKeuze()"
|
||||||
|
(ngModelChange)="diplomaChosen.emit($event)"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
@if (handmatigActief()) {
|
||||||
|
<app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing"
|
||||||
|
>Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw
|
||||||
|
beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig
|
||||||
|
beoordeeld.</app-alert
|
||||||
|
>
|
||||||
|
<fieldset>
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@regWizard.beroepLabel"
|
||||||
|
label="Voor welk beroep wilt u zich registreren?"
|
||||||
|
fieldId="hm-beroep"
|
||||||
|
[error]="err('diploma')"
|
||||||
|
>
|
||||||
|
<app-radio-group
|
||||||
|
name="hm-beroep"
|
||||||
|
[options]="beroepOptions(data)"
|
||||||
|
[invalid]="!!err('diploma')"
|
||||||
|
[ngModel]="draft().beroep ?? ''"
|
||||||
|
(ngModelChange)="beroepDeclared.emit($event)"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
</fieldset>
|
||||||
|
} @else if (draft().beroep) {
|
||||||
|
<app-data-block class="app-section">
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@regWizard.beroepAfgeleid"
|
||||||
|
key="Beroep (afgeleid uit diploma)"
|
||||||
|
[value]="draft().beroep ?? ''"
|
||||||
|
></div>
|
||||||
|
</app-data-block>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (actieveVragen(data).length) {
|
||||||
|
<fieldset>
|
||||||
|
@for (q of actieveVragen(data); track q.id) {
|
||||||
|
<app-form-field
|
||||||
|
[label]="q.vraag"
|
||||||
|
[fieldId]="'vraag-' + q.id"
|
||||||
|
[error]="vraagErr(q.id)"
|
||||||
|
>
|
||||||
|
@if (q.type === 'ja-nee') {
|
||||||
|
<app-radio-group
|
||||||
|
[name]="'vraag-' + q.id"
|
||||||
|
[options]="jaNee"
|
||||||
|
[invalid]="!!vraagErr(q.id)"
|
||||||
|
[ngModel]="antwoord(q.id)"
|
||||||
|
(ngModelChange)="antwoordChange.emit({ vraagId: q.id, value: $event })"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<app-text-input
|
||||||
|
[inputId]="'vraag-' + q.id"
|
||||||
|
[invalid]="!!vraagErr(q.id)"
|
||||||
|
[ngModel]="antwoord(q.id)"
|
||||||
|
(ngModelChange)="antwoordChange.emit({ vraagId: q.id, value: $event })"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</app-form-field>
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
<ng-template appAsyncLoading>
|
||||||
|
<app-skeleton height="2.5rem" [count]="3" />
|
||||||
|
</ng-template>
|
||||||
|
</app-async>
|
||||||
|
|
||||||
|
<app-document-upload
|
||||||
|
class="app-section"
|
||||||
|
[state]="upload()"
|
||||||
|
[previewUrlFor]="previewUrlFor"
|
||||||
|
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
|
||||||
|
(removeUpload)="uploadCtl.onRemove($event)"
|
||||||
|
(retryUpload)="uploadCtl.onRetry($event)"
|
||||||
|
(deleteUpload)="uploadCtl.onDelete($event)"
|
||||||
|
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)"
|
||||||
|
/>
|
||||||
|
@if (err('documenten')) {
|
||||||
|
<app-alert type="warning">{{ err('documenten') }}</app-alert>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class BeroepStep {
|
||||||
|
private lookup = inject(RegistratieLookupStore);
|
||||||
|
|
||||||
|
draft = input.required<Draft>();
|
||||||
|
errors = input.required<Errors>();
|
||||||
|
upload = input.required<UploadState>();
|
||||||
|
|
||||||
|
uploadMsg = output<UploadMsg>();
|
||||||
|
antwoordChange = output<{ vraagId: string; value: string }>();
|
||||||
|
diplomaChosen = output<string>();
|
||||||
|
beroepDeclared = output<string>();
|
||||||
|
|
||||||
|
/** Preview/download link for a completed upload; delegates to the upload
|
||||||
|
controller (application layer), which knows the dev-simulation `demo-*`
|
||||||
|
ids have no stored bytes and returns no link for them. */
|
||||||
|
protected previewUrlFor = (documentId: string): string | undefined =>
|
||||||
|
this.uploadCtl.previewUrlFor(documentId);
|
||||||
|
|
||||||
|
protected uploadCtl = createUploadController({
|
||||||
|
wizardId: 'registratie',
|
||||||
|
getUpload: () => this.upload(),
|
||||||
|
dispatch: (msg) => this.uploadMsg.emit(msg),
|
||||||
|
// Required documents depend on answers (server decides): a diploma upload only for a
|
||||||
|
// manual diploma; a Dutch-taalvaardigheid upload only once the applicant confirms
|
||||||
|
// ("ja") the B2 language requirement.
|
||||||
|
getCategoryParams: () => ({
|
||||||
|
diplomaHerkomst: this.draft().diplomaHerkomst,
|
||||||
|
taalvaardigheid: this.draft().antwoorden[NL_TAALVAARDIGHEID_VRAAG],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Parsed DUO lookup (validated at the trust boundary by the application
|
||||||
|
facade — the step renders, it does not fetch/parse). */
|
||||||
|
protected lookupRd: () => RemoteData<Error | undefined, DuoLookupDto> = this.lookup.duoLookup;
|
||||||
|
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookupRd(), null));
|
||||||
|
|
||||||
|
readonly jaNee = JA_NEE;
|
||||||
|
|
||||||
|
protected err = (k: 'diploma' | 'documenten') => this.errors()[k] ?? '';
|
||||||
|
protected vraagErr = (id: string) => this.errors().antwoorden?.[id] ?? '';
|
||||||
|
protected antwoord = (id: string) => this.draft().antwoorden[id] ?? ''; // runtime guard: missing key → undefined
|
||||||
|
|
||||||
|
/** True while the user is entering a diploma manually (not in the DUO list). */
|
||||||
|
protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig');
|
||||||
|
/** The radio selection: a diploma id, or the "not listed" sentinel in manual mode. */
|
||||||
|
protected diplomaKeuze = computed(() =>
|
||||||
|
this.handmatigActief() ? HANDMATIG : (this.draft().diplomaId ?? ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected diplomaOptions = (data: DuoLookupDto) => [
|
||||||
|
...data.diplomas.map((d) => ({
|
||||||
|
value: d.id,
|
||||||
|
label: `${d.naam} — ${d.instelling} (${d.jaar})`,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
value: HANDMATIG,
|
||||||
|
label: $localize`:@@regWizard.diplomaNietBij:Mijn diploma staat er niet bij`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
protected beroepOptions = (data: DuoLookupDto) =>
|
||||||
|
data.handmatig.beroepen.map((b) => ({ value: b, label: b }));
|
||||||
|
|
||||||
|
/** The policy questions that apply to the current choice (server-decided). */
|
||||||
|
protected actieveVragen = (data: DuoLookupDto): PolicyQuestionDto[] => {
|
||||||
|
if (this.handmatigActief()) return data.handmatig.policyQuestions;
|
||||||
|
return data.diplomas.find((d) => d.id === this.draft().diplomaId)?.policyQuestions ?? [];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { Component, computed, inject, input, output } from '@angular/core';
|
||||||
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
|
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||||
|
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
|
||||||
|
import { successOr } from '@shared/application/remote-data';
|
||||||
|
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
|
||||||
|
import { DuoLookupDto } from '@registratie/contracts/duo-diplomas.dto';
|
||||||
|
import { Draft } from '@registratie/domain/registratie-wizard.machine';
|
||||||
|
|
||||||
|
/** Step: the registratie wizard's review screen (controle & indienen). Injects
|
||||||
|
RegistratieLookupStore directly to build `samenvattingVragen` — the
|
||||||
|
sanctioned exception (it is `providedIn: 'root'`, so every injection is the
|
||||||
|
same instance). Values otherwise in via `draft`, the cursor to jump back to
|
||||||
|
out via `edit`. No internal state; the parent maps the cursor onto its own
|
||||||
|
`GaNaarStap` message. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-reg-controle-step',
|
||||||
|
imports: [AlertComponent, DataRowComponent, ReviewSectionComponent],
|
||||||
|
template: `
|
||||||
|
<app-alert type="info" i18n="@@regWizard.controleer"
|
||||||
|
>Controleer uw gegevens en dien de registratie in.</app-alert
|
||||||
|
>
|
||||||
|
<app-review-section
|
||||||
|
i18n-heading="@@regWizard.sectie.adres"
|
||||||
|
heading="Adres en correspondentie"
|
||||||
|
i18n-editAriaLabel="@@regWizard.adresWijzigenAria"
|
||||||
|
editAriaLabel="Wijzigen adresgegevens"
|
||||||
|
(edit)="edit.emit(0)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@regWizard.summary.adres"
|
||||||
|
key="Adres"
|
||||||
|
[value]="adresSamenvatting()"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@regWizard.summary.herkomstAdres"
|
||||||
|
key="Herkomst adres"
|
||||||
|
[value]="adresHerkomstLabel()"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@regWizard.summary.correspondentie"
|
||||||
|
key="Correspondentie"
|
||||||
|
[value]="correspondentieLabel()"
|
||||||
|
></div>
|
||||||
|
@if (draft().correspondentie === 'email') {
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@regWizard.summary.email"
|
||||||
|
key="E-mailadres"
|
||||||
|
[value]="draft().email ?? ''"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
</app-review-section>
|
||||||
|
<app-review-section
|
||||||
|
class="app-section"
|
||||||
|
i18n-heading="@@regWizard.sectie.beroep"
|
||||||
|
heading="Beroep en diploma"
|
||||||
|
i18n-editAriaLabel="@@regWizard.diplomaWijzigenAria"
|
||||||
|
editAriaLabel="Wijzigen beroep en diploma"
|
||||||
|
(edit)="edit.emit(1)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@regWizard.summary.beroep"
|
||||||
|
key="Beroep"
|
||||||
|
[value]="draft().beroep ?? ''"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
app-data-row
|
||||||
|
i18n-key="@@regWizard.summary.herkomstDiploma"
|
||||||
|
key="Herkomst diploma"
|
||||||
|
[value]="diplomaHerkomstLabel()"
|
||||||
|
></div>
|
||||||
|
@for (item of samenvattingVragen(); track item.vraag) {
|
||||||
|
<div app-data-row [key]="item.vraag" [value]="item.antwoord"></div>
|
||||||
|
}
|
||||||
|
</app-review-section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ControleStep {
|
||||||
|
private lookup = inject(RegistratieLookupStore);
|
||||||
|
|
||||||
|
draft = input.required<Draft>();
|
||||||
|
edit = output<number>();
|
||||||
|
|
||||||
|
/** Parsed DUO lookup as a plain value (or null), needed here only to resolve
|
||||||
|
the answered policy questions' text for the summary. */
|
||||||
|
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookup.duoLookup(), null));
|
||||||
|
|
||||||
|
protected adresSamenvatting = computed(() => {
|
||||||
|
const d = this.draft();
|
||||||
|
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
});
|
||||||
|
// Readable labels for the controle summary (instead of raw enum values).
|
||||||
|
protected adresHerkomstLabel = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
brp: $localize`:@@regWizard.herkomst.adresBrp:Automatisch uit de BRP`,
|
||||||
|
handmatig: $localize`:@@regWizard.herkomst.adresHandmatig:Handmatig ingevoerd`,
|
||||||
|
})[this.draft().adresHerkomst ?? 'handmatig'],
|
||||||
|
);
|
||||||
|
protected correspondentieLabel = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
email: $localize`:@@regWizard.corr.email:Per e-mail`,
|
||||||
|
post: $localize`:@@regWizard.corr.post:Per post`,
|
||||||
|
})[this.draft().correspondentie ?? 'post'],
|
||||||
|
);
|
||||||
|
protected diplomaHerkomstLabel = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
duo: $localize`:@@regWizard.herkomst.diplomaDuo:Geverifieerd via DUO`,
|
||||||
|
handmatig: $localize`:@@regWizard.herkomst.diplomaHandmatig:Handmatig ingevoerd (wordt beoordeeld)`,
|
||||||
|
})[this.draft().diplomaHerkomst ?? 'handmatig'],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Answered policy questions for the controle summary (question text + answer). */
|
||||||
|
protected samenvattingVragen = computed(() => {
|
||||||
|
const data = this.duoData();
|
||||||
|
const d = this.draft();
|
||||||
|
if (!data) return [] as { vraag: string; antwoord: string }[];
|
||||||
|
const alle = [
|
||||||
|
...data.diplomas.flatMap((x) => x.policyQuestions),
|
||||||
|
...data.handmatig.policyQuestions,
|
||||||
|
];
|
||||||
|
return (d.vraagIds ?? []).map((id) => ({
|
||||||
|
vraag: alle.find((q) => q.id === id)?.vraag ?? id,
|
||||||
|
antwoord: d.antwoorden[id] ?? '',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
+43
-391
@@ -1,15 +1,5 @@
|
|||||||
/* eslint-disable max-lines */ // one wizard shell for 3 steps + upload — removed by RD-23
|
|
||||||
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
|
import { Component, computed, effect, inject, input, untracked } 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 { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component';
|
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
|
||||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
|
||||||
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
|
|
||||||
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
|
|
||||||
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
||||||
import {
|
import {
|
||||||
WizardShellComponent,
|
WizardShellComponent,
|
||||||
@@ -18,19 +8,17 @@ import {
|
|||||||
naarStapLabel,
|
naarStapLabel,
|
||||||
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||||
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
|
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
|
||||||
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
|
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
import { whenTag } from '@shared/kernel/fp';
|
import { whenTag } from '@shared/kernel/fp';
|
||||||
import { RemoteData, successOr } from '@shared/application/remote-data';
|
import { successOr } from '@shared/application/remote-data';
|
||||||
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
|
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
|
||||||
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
|
import { DuoLookupDto } from '@registratie/contracts/duo-diplomas.dto';
|
||||||
import {
|
import {
|
||||||
RegistratieState,
|
RegistratieState,
|
||||||
RegistratieMsg,
|
RegistratieMsg,
|
||||||
Draft,
|
Draft,
|
||||||
DraftField,
|
|
||||||
Correspondentie,
|
Correspondentie,
|
||||||
|
Errors,
|
||||||
StepId,
|
StepId,
|
||||||
initial,
|
initial,
|
||||||
reduce,
|
reduce,
|
||||||
@@ -38,18 +26,10 @@ import {
|
|||||||
STEPS,
|
STEPS,
|
||||||
} from '@registratie/domain/registratie-wizard.machine';
|
} from '@registratie/domain/registratie-wizard.machine';
|
||||||
import { createDraftSync } from '@registratie/application/draft-sync';
|
import { createDraftSync } from '@registratie/application/draft-sync';
|
||||||
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
|
|
||||||
import { createUploadController } from '@shared/application/upload-controller';
|
|
||||||
import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine';
|
import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine';
|
||||||
|
import { AdresStep } from './adres.step';
|
||||||
const KANALEN = [
|
import { BeroepStep, HANDMATIG } from './beroep.step';
|
||||||
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
|
import { ControleStep } from './controle.step';
|
||||||
{ value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },
|
|
||||||
];
|
|
||||||
const HANDMATIG = '__handmatig__'; // sentinel option:"my diploma isn't listed"
|
|
||||||
/** The server-owned geldigheidsvraag whose"ja" answer requires a Dutch-taalvaardigheid
|
|
||||||
upload (proof of the confirmed B2 level). Stable id shared with the backend. */
|
|
||||||
const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
|
|
||||||
|
|
||||||
/** Organism: the BIG-registration wizard. All state lives in one signal driven by
|
/** Organism: the BIG-registration wizard. All state lives in one signal driven by
|
||||||
the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the
|
the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the
|
||||||
@@ -61,21 +41,12 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-registratie-wizard',
|
selector: 'app-registratie-wizard',
|
||||||
imports: [
|
imports: [
|
||||||
FormsModule,
|
|
||||||
FormFieldComponent,
|
|
||||||
TextInputComponent,
|
|
||||||
RadioGroupComponent,
|
|
||||||
ButtonComponent,
|
ButtonComponent,
|
||||||
AlertComponent,
|
|
||||||
SkeletonComponent,
|
|
||||||
DataRowComponent,
|
|
||||||
DataBlockComponent,
|
|
||||||
ReviewSectionComponent,
|
|
||||||
ConfirmationComponent,
|
ConfirmationComponent,
|
||||||
WizardShellComponent,
|
WizardShellComponent,
|
||||||
AddressFieldsComponent,
|
AdresStep,
|
||||||
DocumentUploadComponent,
|
BeroepStep,
|
||||||
...ASYNC,
|
ControleStep,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<app-wizard-shell
|
<app-wizard-shell
|
||||||
@@ -98,253 +69,31 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
|
|||||||
>
|
>
|
||||||
@switch (step()) {
|
@switch (step()) {
|
||||||
@case ('adres') {
|
@case ('adres') {
|
||||||
@if (adresStatus() === 'laden') {
|
<app-reg-adres-step
|
||||||
<app-skeleton height="2.5rem" [count]="4" />
|
[draft]="draft()"
|
||||||
} @else {
|
[errors]="errors()"
|
||||||
@switch (adresStatus()) {
|
(fieldChange)="dispatch({ tag: 'SetField', key: $event.key, value: $event.value })"
|
||||||
@case ('gevonden') {
|
(kanaalChange)="onKanaalChange($event)"
|
||||||
<app-alert type="info" i18n="@@regWizard.brpGevonden"
|
/>
|
||||||
>Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert
|
|
||||||
>
|
|
||||||
}
|
|
||||||
@case ('geen') {
|
|
||||||
<app-alert type="warning" i18n="@@regWizard.brpGeen"
|
|
||||||
>We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert
|
|
||||||
>
|
|
||||||
}
|
|
||||||
@case ('fout') {
|
|
||||||
<app-alert type="warning" i18n="@@regWizard.brpFout"
|
|
||||||
>We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig
|
|
||||||
in.</app-alert
|
|
||||||
>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
<app-address-fields
|
|
||||||
[value]="{
|
|
||||||
straat: draft().straat ?? '',
|
|
||||||
postcode: draft().postcode ?? '',
|
|
||||||
woonplaats: draft().woonplaats ?? '',
|
|
||||||
}"
|
|
||||||
[errors]="{
|
|
||||||
straat: err('straat'),
|
|
||||||
postcode: err('postcode'),
|
|
||||||
woonplaats: err('woonplaats'),
|
|
||||||
}"
|
|
||||||
(fieldChange)="set($event.key, $event.value)"
|
|
||||||
/>
|
|
||||||
<fieldset>
|
|
||||||
<app-form-field
|
|
||||||
i18n-label="@@regWizard.correspondentieLabel"
|
|
||||||
label="Hoe wilt u correspondentie ontvangen?"
|
|
||||||
fieldId="correspondentie"
|
|
||||||
required
|
|
||||||
[error]="err('correspondentie')"
|
|
||||||
>
|
|
||||||
<app-radio-group
|
|
||||||
name="correspondentie"
|
|
||||||
[options]="kanalen"
|
|
||||||
[invalid]="!!err('correspondentie')"
|
|
||||||
[ngModel]="draft().correspondentie ?? ''"
|
|
||||||
(ngModelChange)="setKanaal($event)"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
@if (draft().correspondentie === 'email') {
|
|
||||||
<fieldset>
|
|
||||||
<app-form-field
|
|
||||||
i18n-label="@@regWizard.emailLabel"
|
|
||||||
label="E-mailadres"
|
|
||||||
fieldId="email"
|
|
||||||
required
|
|
||||||
[error]="err('email')"
|
|
||||||
>
|
|
||||||
<app-text-input
|
|
||||||
inputId="email"
|
|
||||||
type="email"
|
|
||||||
[invalid]="!!err('email')"
|
|
||||||
[ngModel]="draft().email ?? ''"
|
|
||||||
(ngModelChange)="set('email', $event)"
|
|
||||||
name="email"
|
|
||||||
i18n-placeholder="@@regWizard.emailPlaceholder"
|
|
||||||
placeholder="naam@voorbeeld.nl"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@case ('beroep') {
|
@case ('beroep') {
|
||||||
<app-async [data]="lookupRd()">
|
<app-reg-beroep-step
|
||||||
<ng-template appAsyncLoaded>
|
[draft]="draft()"
|
||||||
@if (duoData(); as data) {
|
[errors]="errors()"
|
||||||
<fieldset>
|
[upload]="upload()"
|
||||||
<app-form-field
|
(uploadMsg)="dispatch({ tag: 'Upload', msg: $event })"
|
||||||
i18n-label="@@regWizard.diplomaLabel"
|
(antwoordChange)="
|
||||||
label="Kies het diploma waarmee u zich wilt registreren"
|
dispatch({ tag: 'SetAntwoord', vraagId: $event.vraagId, value: $event.value })
|
||||||
fieldId="diploma"
|
"
|
||||||
required
|
(diplomaChosen)="onDiplomaKeuze($event)"
|
||||||
[error]="err('diploma')"
|
(beroepDeclared)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })"
|
||||||
>
|
|
||||||
<app-radio-group
|
|
||||||
name="diploma"
|
|
||||||
[options]="diplomaOptions(data)"
|
|
||||||
[invalid]="!!err('diploma')"
|
|
||||||
[ngModel]="diplomaKeuze()"
|
|
||||||
(ngModelChange)="onDiplomaKeuze(data, $event)"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
@if (handmatigActief()) {
|
|
||||||
<app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing"
|
|
||||||
>Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies
|
|
||||||
uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna
|
|
||||||
handmatig beoordeeld.</app-alert
|
|
||||||
>
|
|
||||||
<fieldset>
|
|
||||||
<app-form-field
|
|
||||||
i18n-label="@@regWizard.beroepLabel"
|
|
||||||
label="Voor welk beroep wilt u zich registreren?"
|
|
||||||
fieldId="hm-beroep"
|
|
||||||
[error]="err('diploma')"
|
|
||||||
>
|
|
||||||
<app-radio-group
|
|
||||||
name="hm-beroep"
|
|
||||||
[options]="beroepOptions(data)"
|
|
||||||
[invalid]="!!err('diploma')"
|
|
||||||
[ngModel]="draft().beroep ?? ''"
|
|
||||||
(ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })"
|
|
||||||
/>
|
|
||||||
</app-form-field>
|
|
||||||
</fieldset>
|
|
||||||
} @else if (draft().beroep) {
|
|
||||||
<app-data-block class="app-section">
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@regWizard.beroepAfgeleid"
|
|
||||||
key="Beroep (afgeleid uit diploma)"
|
|
||||||
[value]="draft().beroep ?? ''"
|
|
||||||
></div>
|
|
||||||
</app-data-block>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (actieveVragen(data).length) {
|
|
||||||
<fieldset>
|
|
||||||
@for (q of actieveVragen(data); track q.id) {
|
|
||||||
<app-form-field
|
|
||||||
[label]="q.vraag"
|
|
||||||
[fieldId]="'vraag-' + q.id"
|
|
||||||
[error]="vraagErr(q.id)"
|
|
||||||
>
|
|
||||||
@if (q.type === 'ja-nee') {
|
|
||||||
<app-radio-group
|
|
||||||
[name]="'vraag-' + q.id"
|
|
||||||
[options]="jaNee"
|
|
||||||
[invalid]="!!vraagErr(q.id)"
|
|
||||||
[ngModel]="antwoord(q.id)"
|
|
||||||
(ngModelChange)="
|
|
||||||
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
|
|
||||||
"
|
|
||||||
[ngModelOptions]="{ standalone: true }"
|
|
||||||
/>
|
|
||||||
} @else {
|
|
||||||
<app-text-input
|
|
||||||
[inputId]="'vraag-' + q.id"
|
|
||||||
[invalid]="!!vraagErr(q.id)"
|
|
||||||
[ngModel]="antwoord(q.id)"
|
|
||||||
(ngModelChange)="
|
|
||||||
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
|
|
||||||
"
|
|
||||||
[ngModelOptions]="{ standalone: true }"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</app-form-field>
|
|
||||||
}
|
|
||||||
</fieldset>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</ng-template>
|
|
||||||
<ng-template appAsyncLoading>
|
|
||||||
<app-skeleton height="2.5rem" [count]="3" />
|
|
||||||
</ng-template>
|
|
||||||
</app-async>
|
|
||||||
|
|
||||||
<app-document-upload
|
|
||||||
class="app-section"
|
|
||||||
[state]="upload()"
|
|
||||||
[previewUrlFor]="previewUrlFor"
|
|
||||||
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
|
|
||||||
(removeUpload)="uploadCtl.onRemove($event)"
|
|
||||||
(retryUpload)="uploadCtl.onRetry($event)"
|
|
||||||
(deleteUpload)="uploadCtl.onDelete($event)"
|
|
||||||
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)"
|
|
||||||
/>
|
/>
|
||||||
@if (err('documenten')) {
|
|
||||||
<app-alert type="warning">{{ err('documenten') }}</app-alert>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@case ('controle') {
|
@case ('controle') {
|
||||||
<app-alert type="info" i18n="@@regWizard.controleer"
|
<app-reg-controle-step
|
||||||
>Controleer uw gegevens en dien de registratie in.</app-alert
|
[draft]="draft()"
|
||||||
>
|
(edit)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
|
||||||
<app-review-section
|
/>
|
||||||
i18n-heading="@@regWizard.sectie.adres"
|
|
||||||
heading="Adres en correspondentie"
|
|
||||||
i18n-editAriaLabel="@@regWizard.adresWijzigenAria"
|
|
||||||
editAriaLabel="Wijzigen adresgegevens"
|
|
||||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@regWizard.summary.adres"
|
|
||||||
key="Adres"
|
|
||||||
[value]="adresSamenvatting()"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@regWizard.summary.herkomstAdres"
|
|
||||||
key="Herkomst adres"
|
|
||||||
[value]="adresHerkomstLabel()"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@regWizard.summary.correspondentie"
|
|
||||||
key="Correspondentie"
|
|
||||||
[value]="correspondentieLabel()"
|
|
||||||
></div>
|
|
||||||
@if (draft().correspondentie === 'email') {
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@regWizard.summary.email"
|
|
||||||
key="E-mailadres"
|
|
||||||
[value]="draft().email ?? ''"
|
|
||||||
></div>
|
|
||||||
}
|
|
||||||
</app-review-section>
|
|
||||||
<app-review-section
|
|
||||||
class="app-section"
|
|
||||||
i18n-heading="@@regWizard.sectie.beroep"
|
|
||||||
heading="Beroep en diploma"
|
|
||||||
i18n-editAriaLabel="@@regWizard.diplomaWijzigenAria"
|
|
||||||
editAriaLabel="Wijzigen beroep en diploma"
|
|
||||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@regWizard.summary.beroep"
|
|
||||||
key="Beroep"
|
|
||||||
[value]="draft().beroep ?? ''"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
app-data-row
|
|
||||||
i18n-key="@@regWizard.summary.herkomstDiploma"
|
|
||||||
key="Herkomst diploma"
|
|
||||||
[value]="diplomaHerkomstLabel()"
|
|
||||||
></div>
|
|
||||||
@for (item of samenvattingVragen(); track item.vraag) {
|
|
||||||
<div app-data-row [key]="item.vraag" [value]="item.antwoord"></div>
|
|
||||||
}
|
|
||||||
</app-review-section>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,16 +130,9 @@ export class RegistratieWizardComponent {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Preview/download link for a completed upload; delegates to the upload
|
|
||||||
controller (application layer), which knows the dev-simulation `demo-*` ids
|
|
||||||
have no stored bytes and returns no link for them. */
|
|
||||||
protected previewUrlFor = (documentId: string): string | undefined =>
|
|
||||||
this.uploadCtl.previewUrlFor(documentId);
|
|
||||||
|
|
||||||
/** Optional seed so Storybook / tests can mount any state directly. */
|
/** Optional seed so Storybook / tests can mount any state directly. */
|
||||||
seed = input<RegistratieState>(initial);
|
seed = input<RegistratieState>(initial);
|
||||||
|
|
||||||
readonly kanalen = KANALEN;
|
|
||||||
readonly stepLabels = [
|
readonly stepLabels = [
|
||||||
$localize`:@@regWizard.step.adres:Adres`,
|
$localize`:@@regWizard.step.adres:Adres`,
|
||||||
$localize`:@@regWizard.step.beroep:Beroep`,
|
$localize`:@@regWizard.step.beroep:Beroep`,
|
||||||
@@ -407,19 +149,8 @@ export class RegistratieWizardComponent {
|
|||||||
private invullen = computed(() => whenTag(this.state(), 'Invullen'));
|
private invullen = computed(() => whenTag(this.state(), 'Invullen'));
|
||||||
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
|
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
|
||||||
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
|
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
|
||||||
|
protected errors = computed<Errors>(() => this.invullen()?.errors ?? {});
|
||||||
protected upload = computed<UploadState>(() => this.invullen()?.upload ?? initialUpload);
|
protected upload = computed<UploadState>(() => this.invullen()?.upload ?? initialUpload);
|
||||||
protected uploadCtl = createUploadController({
|
|
||||||
wizardId: 'registratie',
|
|
||||||
getUpload: () => this.upload(),
|
|
||||||
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
|
|
||||||
// Required documents depend on answers (server decides): a diploma upload only for a
|
|
||||||
// manual diploma; a Dutch-taalvaardigheid upload only once the applicant confirms
|
|
||||||
// ("ja") the B2 language requirement.
|
|
||||||
getCategoryParams: () => ({
|
|
||||||
diplomaHerkomst: this.draft().diplomaHerkomst,
|
|
||||||
taalvaardigheid: this.draft().antwoorden[NL_TAALVAARDIGHEID_VRAAG],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
// Backend draft-sync (replaces sessionStorage): create a Concept once the user has
|
// Backend draft-sync (replaces sessionStorage): create a Concept once the user has
|
||||||
// made progress, then debounced-sync the whole machine snapshot; resume by `?aanvraag`.
|
// made progress, then debounced-sync the whole machine snapshot; resume by `?aanvraag`.
|
||||||
private draftSync = createDraftSync({
|
private draftSync = createDraftSync({
|
||||||
@@ -471,101 +202,16 @@ export class RegistratieWizardComponent {
|
|||||||
const e = this.invullen()?.errors ?? {};
|
const e = this.invullen()?.errors ?? {};
|
||||||
return [...toWizardErrors(e), ...toWizardErrors(e.antwoorden ?? {}, 'vraag-')];
|
return [...toWizardErrors(e), ...toWizardErrors(e.antwoorden ?? {}, 'vraag-')];
|
||||||
});
|
});
|
||||||
protected adresSamenvatting = computed(() => {
|
|
||||||
const d = this.draft();
|
|
||||||
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(', ');
|
|
||||||
});
|
|
||||||
// Readable labels for the controle summary (instead of raw enum values).
|
|
||||||
protected adresHerkomstLabel = computed(
|
|
||||||
() =>
|
|
||||||
({
|
|
||||||
brp: $localize`:@@regWizard.herkomst.adresBrp:Automatisch uit de BRP`,
|
|
||||||
handmatig: $localize`:@@regWizard.herkomst.adresHandmatig:Handmatig ingevoerd`,
|
|
||||||
})[this.draft().adresHerkomst ?? 'handmatig'],
|
|
||||||
);
|
|
||||||
protected correspondentieLabel = computed(
|
|
||||||
() =>
|
|
||||||
({
|
|
||||||
email: $localize`:@@regWizard.corr.email:Per e-mail`,
|
|
||||||
post: $localize`:@@regWizard.corr.post:Per post`,
|
|
||||||
})[this.draft().correspondentie ?? 'post'],
|
|
||||||
);
|
|
||||||
protected diplomaHerkomstLabel = computed(
|
|
||||||
() =>
|
|
||||||
({
|
|
||||||
duo: $localize`:@@regWizard.herkomst.diplomaDuo:Geverifieerd via DUO`,
|
|
||||||
handmatig: $localize`:@@regWizard.herkomst.diplomaHandmatig:Handmatig ingevoerd (wordt beoordeeld)`,
|
|
||||||
})[this.draft().diplomaHerkomst ?? 'handmatig'],
|
|
||||||
);
|
|
||||||
|
|
||||||
/** BRP lookup outcome (laden/gevonden/geen/fout) and the parsed DUO lookup, both
|
/** Parsed lookup as a plain value (or null) — needed here only to resolve
|
||||||
served by the application facade — the wizard renders, it does not fetch/parse. */
|
`onDiplomaKeuze`'s message from an id (the DUO payload maps an id to a
|
||||||
protected adresStatus = this.lookup.adresStatus;
|
beroep and its question ids; that is machine-message construction, and it
|
||||||
protected lookupRd: () => RemoteData<Error | undefined, DuoLookupDto> = this.lookup.duoLookup;
|
belongs in the container, not the beroep step). */
|
||||||
|
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookup.duoLookup(), null));
|
||||||
|
|
||||||
/** Parsed lookup as a plain value (or null) — used outside the beroep step (the
|
protected onDiplomaKeuze(id: string) {
|
||||||
controle summary) where the <app-async> template variable isn't in scope, and
|
|
||||||
inside it too: `<ng-template appAsyncLoaded>`'s own context can't inherit a
|
|
||||||
generic from the sibling [data] input (Angular only infers a structural
|
|
||||||
directive's type parameter from an input on that same node). */
|
|
||||||
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookupRd(), null));
|
|
||||||
|
|
||||||
readonly jaNee = JA_NEE;
|
|
||||||
|
|
||||||
protected err = (k: DraftField | 'correspondentie' | 'diploma' | 'documenten') =>
|
|
||||||
this.invullen()?.errors[k] ?? '';
|
|
||||||
protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? '';
|
|
||||||
protected antwoord = (id: string) => this.draft().antwoorden[id] ?? ''; // runtime guard: missing key → undefined
|
|
||||||
protected set = (key: DraftField, value: string) =>
|
|
||||||
this.dispatch({ tag: 'SetField', key, value });
|
|
||||||
protected setKanaal = (value: string) =>
|
|
||||||
this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
|
|
||||||
|
|
||||||
/** True while the user is entering a diploma manually (not in the DUO list). */
|
|
||||||
protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig');
|
|
||||||
/** The radio selection: a diploma id, or the"not listed" sentinel in manual mode. */
|
|
||||||
protected diplomaKeuze = computed(() =>
|
|
||||||
this.handmatigActief() ? HANDMATIG : (this.draft().diplomaId ?? ''),
|
|
||||||
);
|
|
||||||
|
|
||||||
protected diplomaOptions = (data: DuoLookupDto) => [
|
|
||||||
...data.diplomas.map((d) => ({
|
|
||||||
value: d.id,
|
|
||||||
label: `${d.naam} — ${d.instelling} (${d.jaar})`,
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
value: HANDMATIG,
|
|
||||||
label: $localize`:@@regWizard.diplomaNietBij:Mijn diploma staat er niet bij`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
protected beroepOptions = (data: DuoLookupDto) =>
|
|
||||||
data.handmatig.beroepen.map((b) => ({ value: b, label: b }));
|
|
||||||
|
|
||||||
/** The policy questions that apply to the current choice (server-decided). */
|
|
||||||
protected actieveVragen = (data: DuoLookupDto): PolicyQuestionDto[] => {
|
|
||||||
if (this.handmatigActief()) return data.handmatig.policyQuestions;
|
|
||||||
return data.diplomas.find((d) => d.id === this.draft().diplomaId)?.policyQuestions ?? [];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Answered policy questions for the controle summary (question text + answer). */
|
|
||||||
protected samenvattingVragen = computed(() => {
|
|
||||||
const data = this.duoData();
|
const data = this.duoData();
|
||||||
const d = this.draft();
|
if (!data) return;
|
||||||
if (!data) return [] as { vraag: string; antwoord: string }[];
|
|
||||||
const alle = [
|
|
||||||
...data.diplomas.flatMap((x) => x.policyQuestions),
|
|
||||||
...data.handmatig.policyQuestions,
|
|
||||||
];
|
|
||||||
return (d.vraagIds ?? []).map((id) => ({
|
|
||||||
vraag: alle.find((q) => q.id === id)?.vraag ?? id,
|
|
||||||
antwoord: d.antwoorden[id] ?? '',
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
protected onDiplomaKeuze(data: DuoLookupDto, id: string) {
|
|
||||||
if (id === HANDMATIG) {
|
if (id === HANDMATIG) {
|
||||||
this.dispatch({
|
this.dispatch({
|
||||||
tag: 'KiesHandmatig',
|
tag: 'KiesHandmatig',
|
||||||
@@ -583,6 +229,12 @@ export class RegistratieWizardComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Narrows the beroep step's plain-string `kanaalChange` into the machine's
|
||||||
|
`Correspondentie` union before dispatching. */
|
||||||
|
protected onKanaalChange(value: string) {
|
||||||
|
this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// An explicit seed (stories/tests) wins; otherwise resume from the backend draft
|
// An explicit seed (stories/tests) wins; otherwise resume from the backend draft
|
||||||
// (`?aanvraag=<id>`), or start fresh. Persistence is the draftSync controller's job.
|
// (`?aanvraag=<id>`), or start fresh. Persistence is the draftSync controller's job.
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
|
||||||
|
/** Molecule-shaped teaching card: the showcase's "before/after" box. Renders its own
|
||||||
|
tag pill and, when `code` is set, the highlighted snippet — everything else comes in
|
||||||
|
via `<ng-content />`. Angular does not style projected content from the receiving
|
||||||
|
component, so any other card-shaped block inside a section (a sub-label, a nested
|
||||||
|
ok/err result) nests another `<app-concept-card>` rather than writing raw `.tag`
|
||||||
|
markup — that markup would carry the SECTION's scope, not this component's, and the
|
||||||
|
rule here would never match it. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-concept-card',
|
||||||
|
imports: [],
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--rhc-color-grijs-200);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--rhc-color-wit);
|
||||||
|
}
|
||||||
|
.card--bad {
|
||||||
|
border-color: var(--rhc-color-rood-300);
|
||||||
|
}
|
||||||
|
.card--good {
|
||||||
|
border-color: var(--rhc-color-groen-300);
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
.tag::before {
|
||||||
|
content: '';
|
||||||
|
width: 0.6rem;
|
||||||
|
height: 0.6rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.tag.bad {
|
||||||
|
color: var(--rhc-color-rood-600);
|
||||||
|
}
|
||||||
|
.tag.bad::before {
|
||||||
|
background: var(--rhc-color-rood-500);
|
||||||
|
}
|
||||||
|
.tag.good {
|
||||||
|
color: var(--rhc-color-groen-700);
|
||||||
|
}
|
||||||
|
.tag.good::before {
|
||||||
|
background: var(--rhc-color-groen-500);
|
||||||
|
}
|
||||||
|
.tag.plain {
|
||||||
|
color: var(--rhc-color-grijs-700);
|
||||||
|
}
|
||||||
|
.tag.plain::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.linked {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
.linked .src {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--rhc-color-grijs-700);
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div
|
||||||
|
class="card"
|
||||||
|
[class.card--good]="variant() === 'good'"
|
||||||
|
[class.card--bad]="variant() === 'bad'"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="tag"
|
||||||
|
[class.good]="variant() === 'good'"
|
||||||
|
[class.bad]="variant() === 'bad'"
|
||||||
|
[class.plain]="variant() === 'plain'"
|
||||||
|
>
|
||||||
|
{{ tag() }}
|
||||||
|
</p>
|
||||||
|
@if (code(); as c) {
|
||||||
|
@if (src(); as s) {
|
||||||
|
<figure class="linked">
|
||||||
|
<pre class="app-code" [innerHTML]="c"></pre>
|
||||||
|
<figcaption class="src">↳ {{ s }}</figcaption>
|
||||||
|
</figure>
|
||||||
|
} @else {
|
||||||
|
<pre class="app-code" [innerHTML]="c"></pre>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<ng-content />
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ConceptCardComponent {
|
||||||
|
variant = input<'good' | 'bad' | 'plain'>('plain');
|
||||||
|
tag = input.required<string>();
|
||||||
|
code = input<string | undefined>();
|
||||||
|
src = input<string | undefined>();
|
||||||
|
}
|
||||||
@@ -1,497 +1,38 @@
|
|||||||
/* eslint-disable max-lines */ // teaching page covering every concept — removed by RD-24
|
import { Component } from '@angular/core';
|
||||||
import { Component, computed, signal } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import type { Resource } from '@angular/core';
|
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { UnionsSection } from './unions.section';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
import { RemoteDataSection } from './remote-data.section';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ParseSection } from './parse.section';
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
import { FormMachineSection } from './form-machine.section';
|
||||||
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
import { VragenlijstSection } from './vragenlijst.section';
|
||||||
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
|
import { PiiSection } from './pii.section';
|
||||||
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
|
|
||||||
import { Registration } from '@registratie/domain/registration';
|
|
||||||
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
|
|
||||||
import { parseBsn } from '@shared/kernel/bsn';
|
|
||||||
import { maskBsn } from '@shared/kernel/pii';
|
|
||||||
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
|
|
||||||
import { SNIPPETS } from './snippets.generated';
|
|
||||||
import { highlightTs } from './highlight-ts';
|
|
||||||
|
|
||||||
/** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */
|
/** Teaching showcase: each section pairs the impossible-state-permitting "before"
|
||||||
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
|
with the "after" where the type system rules it out. Composition-only. */
|
||||||
return {
|
|
||||||
value: () => value as T,
|
|
||||||
status: () => status,
|
|
||||||
error: () => error,
|
|
||||||
hasValue: () => value !== undefined,
|
|
||||||
reload: () => {},
|
|
||||||
} as unknown as Resource<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Teaching showcase: each section pairs the impossible-state-permitting"before"
|
|
||||||
with the"after" where the type system rules it out. Composition-only. */
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-concepts-page',
|
selector: 'app-concepts-page',
|
||||||
imports: [
|
imports: [
|
||||||
FormsModule,
|
|
||||||
PageShellComponent,
|
PageShellComponent,
|
||||||
HeadingComponent,
|
UnionsSection,
|
||||||
TextInputComponent,
|
RemoteDataSection,
|
||||||
...ASYNC,
|
ParseSection,
|
||||||
SkeletonComponent,
|
FormMachineSection,
|
||||||
RegistrationSummaryComponent,
|
VragenlijstSection,
|
||||||
HerregistratieWizardComponent,
|
PiiSection,
|
||||||
IntakeWizardComponent,
|
|
||||||
MaskedValueComponent,
|
|
||||||
],
|
|
||||||
styles: [
|
|
||||||
`
|
|
||||||
.section {
|
|
||||||
margin: 0 0 3rem;
|
|
||||||
}
|
|
||||||
.lead {
|
|
||||||
color: var(--rhc-color-grijs-700);
|
|
||||||
max-width: 46rem;
|
|
||||||
margin: 0.25rem 0 1.25rem;
|
|
||||||
}
|
|
||||||
.cols {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
border: 1px solid var(--rhc-color-grijs-200, #e5e5e5);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 1.25rem;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
.card--bad {
|
|
||||||
border-color: var(--rhc-color-rood-300, #f0b4b4);
|
|
||||||
}
|
|
||||||
.card--good {
|
|
||||||
border-color: var(--rhc-color-groen-300, #b4e0b4);
|
|
||||||
}
|
|
||||||
.tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
}
|
|
||||||
.tag::before {
|
|
||||||
content: '';
|
|
||||||
width: 0.6rem;
|
|
||||||
height: 0.6rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
.tag.bad {
|
|
||||||
color: var(--rhc-color-rood-600, #a30000);
|
|
||||||
}
|
|
||||||
.tag.bad::before {
|
|
||||||
background: var(--rhc-color-rood-500, #d52b1e);
|
|
||||||
}
|
|
||||||
.tag.good {
|
|
||||||
color: var(--rhc-color-groen-700, #277337);
|
|
||||||
}
|
|
||||||
.tag.good::before {
|
|
||||||
background: var(--rhc-color-groen-500, #39870c);
|
|
||||||
}
|
|
||||||
.tag.plain {
|
|
||||||
color: var(--rhc-color-grijs-700);
|
|
||||||
}
|
|
||||||
.tag.plain::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
pre {
|
|
||||||
background: #1e2430;
|
|
||||||
color: #e6e9ef;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: auto;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
pre .k {
|
|
||||||
color: #c792ea;
|
|
||||||
}
|
|
||||||
pre .s {
|
|
||||||
color: #c3e88d;
|
|
||||||
}
|
|
||||||
pre .c {
|
|
||||||
color: #7e8aa0;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.note {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--rhc-color-grijs-700);
|
|
||||||
margin: 0.75rem 0 0;
|
|
||||||
}
|
|
||||||
/* live state diagram */
|
|
||||||
.machine {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
.node {
|
|
||||||
padding: 0.4rem 0.8rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--rhc-color-grijs-300, #ccc);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--rhc-color-grijs-700);
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.node.on {
|
|
||||||
background: var(--rhc-color-hemelblauw-100, #e5f1fb);
|
|
||||||
border-color: var(--rhc-color-hemelblauw-500, #007bc7);
|
|
||||||
color: var(--rhc-color-hemelblauw-700, #00567d);
|
|
||||||
font-weight: 700;
|
|
||||||
/* teaching motion: the active state pops as the wizard transitions (the .node
|
|
||||||
transition above animates it; reduced-motion is handled globally). */
|
|
||||||
transform: scale(1.06);
|
|
||||||
}
|
|
||||||
.linked {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
.linked .src {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--rhc-color-grijs-700);
|
|
||||||
margin: 0.35rem 0 0;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
.steplist {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.4rem;
|
|
||||||
align-items: center;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
.pill {
|
|
||||||
padding: 0.3rem 0.7rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--rhc-color-grijs-100, #f3f3f3);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.pill.extra {
|
|
||||||
background: var(--rhc-color-geel-100, #fff6d6);
|
|
||||||
border: 1px dashed var(--rhc-color-geel-600, #c79a00);
|
|
||||||
}
|
|
||||||
.arrow {
|
|
||||||
color: var(--rhc-color-grijs-400, #999);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<app-page-shell heading="Onmogelijke toestanden onmogelijk maken" backLink="/dashboard">
|
<app-page-shell heading="Onmogelijke toestanden onmogelijk maken" backLink="/dashboard">
|
||||||
<p class="lead">
|
<p class="app-lead">
|
||||||
Vijf functionele patronen die atomic design makkelijker maakt om te tonen — telkens "fout"
|
Vijf functionele patronen die atomic design makkelijker maakt om te tonen — telkens "fout"
|
||||||
(de oude vorm liet het toe) naast"goed" (het type maakt het onmogelijk).
|
(de oude vorm liet het toe) naast"goed" (het type maakt het onmogelijk).
|
||||||
</p>
|
</p>
|
||||||
|
<app-concepts-unions-section />
|
||||||
<!-- 1. Discriminated unions -->
|
<app-concepts-remote-data-section />
|
||||||
<section class="section">
|
<app-concepts-parse-section />
|
||||||
<app-heading [level]="2">1 · Discriminated unions</app-heading>
|
<app-concepts-form-machine-section />
|
||||||
<p class="lead">Laat elke variant precies de gegevens dragen die kloppen — niets meer.</p>
|
<app-concepts-vragenlijst-section />
|
||||||
<div class="cols">
|
<app-concepts-pii-section />
|
||||||
<div class="card card--bad">
|
|
||||||
<p class="tag bad">Fout — vlakke interface</p>
|
|
||||||
<pre [innerHTML]="code['unionBad']"></pre>
|
|
||||||
<p class="note">
|
|
||||||
Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="card card--good">
|
|
||||||
<p class="tag good">Goed — sum type</p>
|
|
||||||
<figure class="linked">
|
|
||||||
<pre [innerHTML]="code['union']"></pre>
|
|
||||||
<figcaption class="src">↳ {{ src['union'] }}</figcaption>
|
|
||||||
</figure>
|
|
||||||
<app-registration-summary [reg]="doorgehaald" />
|
|
||||||
<p class="note">
|
|
||||||
De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat
|
|
||||||
simpelweg niet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 2. RemoteData fold -->
|
|
||||||
<section class="section">
|
|
||||||
<app-heading [level]="2">2 · RemoteData fold</app-heading>
|
|
||||||
<p class="lead">
|
|
||||||
Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.
|
|
||||||
</p>
|
|
||||||
<div class="cols">
|
|
||||||
<div class="card card--good">
|
|
||||||
<p class="tag good">Vier toestanden, één molecuul</p>
|
|
||||||
<p class="tag plain">Loading</p>
|
|
||||||
<app-async [resource]="loadingRes"
|
|
||||||
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template
|
|
||||||
><ng-template appAsyncLoading
|
|
||||||
><app-skeleton [count]="2" height="1.2rem" [delay]="0" /></ng-template
|
|
||||||
></app-async>
|
|
||||||
<p class="tag plain">Empty</p>
|
|
||||||
<app-async [resource]="emptyRes" [isEmpty]="isEmpty"
|
|
||||||
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
|
|
||||||
>
|
|
||||||
<p class="tag plain">Failure</p>
|
|
||||||
<app-async [resource]="errorRes"
|
|
||||||
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
|
|
||||||
>
|
|
||||||
<p class="tag plain">Success</p>
|
|
||||||
<app-async [resource]="successRes" [isEmpty]="isEmpty"
|
|
||||||
><ng-template appAsyncLoaded
|
|
||||||
><ul>
|
|
||||||
@for (i of successRes.value(); track i) {
|
|
||||||
<li>{{ i }}</li>
|
|
||||||
}
|
|
||||||
</ul></ng-template
|
|
||||||
></app-async
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="card card--good">
|
|
||||||
<p class="tag good">De exhaustieve fold</p>
|
|
||||||
<figure class="linked">
|
|
||||||
<pre [innerHTML]="code['fold']"></pre>
|
|
||||||
<figcaption class="src">↳ {{ src['fold'] }}</figcaption>
|
|
||||||
</figure>
|
|
||||||
<p class="note">
|
|
||||||
Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem
|
|
||||||
afhandelt.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 3. Parse, don't validate -->
|
|
||||||
<section class="section">
|
|
||||||
<app-heading [level]="2">3 · Parse, don't validate</app-heading>
|
|
||||||
<p class="lead">Na het parsen onthoudt het <em>type</em> dat de waarde geldig is.</p>
|
|
||||||
<div class="cols">
|
|
||||||
<div class="card">
|
|
||||||
<p class="tag good">Smart constructor → Result</p>
|
|
||||||
<figure class="linked">
|
|
||||||
<pre [innerHTML]="code['parse']"></pre>
|
|
||||||
<figcaption class="src">↳ {{ src['parse'] }}</figcaption>
|
|
||||||
</figure>
|
|
||||||
<app-text-input
|
|
||||||
inputId="pc"
|
|
||||||
[ngModel]="raw()"
|
|
||||||
(ngModelChange)="raw.set($event)"
|
|
||||||
name="pc"
|
|
||||||
placeholder="Typ een postcode, bijv. 1234 AB"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
@let r = parsed();
|
|
||||||
<div class="card" [class.card--good]="r.ok" [class.card--bad]="!r.ok">
|
|
||||||
@if (r.ok) {
|
|
||||||
<div animate.enter="app-item-enter">
|
|
||||||
<p class="tag good">ok</p>
|
|
||||||
<pre>Postcode ="{{ r.value }}"</pre>
|
|
||||||
<p class="note">
|
|
||||||
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div animate.enter="app-item-enter">
|
|
||||||
<p class="tag bad">err</p>
|
|
||||||
<pre>{{ r.error }}</pre>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 4. State machine / wizard (live state diagram) -->
|
|
||||||
<section class="section">
|
|
||||||
<app-heading [level]="2">4 · Form als state machine</app-heading>
|
|
||||||
<p class="lead">
|
|
||||||
Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de
|
|
||||||
huidige.
|
|
||||||
</p>
|
|
||||||
<div class="cols">
|
|
||||||
<div class="card card--bad">
|
|
||||||
<p class="tag bad">Fout — losse booleans</p>
|
|
||||||
<pre [innerHTML]="code['machineBad']"></pre>
|
|
||||||
<p class="note">
|
|
||||||
Niets verhindert"submitting" mét validatiefouten of een successcherm met errors.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="card card--good">
|
|
||||||
<p class="tag good">Goed — één tagged union</p>
|
|
||||||
<figure class="linked">
|
|
||||||
<pre [innerHTML]="code['machine']"></pre>
|
|
||||||
<figcaption class="src">↳ {{ src['machine'] }}</figcaption>
|
|
||||||
</figure>
|
|
||||||
<div class="machine">
|
|
||||||
@for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) {
|
|
||||||
<span class="node" [class.on]="w.state().tag === n">{{ n }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<app-herregistratie-wizard #w />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 5. Fixed steps, questions revealed inline -->
|
|
||||||
<section class="section">
|
|
||||||
<app-heading [level]="2"
|
|
||||||
>5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen"</app-heading
|
|
||||||
>
|
|
||||||
<p class="lead">
|
|
||||||
Het aantal stappen ligt vast (<code>STEPS</code>); vervolgvragen verschijnen
|
|
||||||
<em>binnen</em> een stap op basis van eerdere antwoorden. Antwoord"ja" op buitenland of
|
|
||||||
vul weinig uren in, en er komt een extra vraag bij in dezelfde stap — de voortgang"van N"
|
|
||||||
blijft gelijk.
|
|
||||||
</p>
|
|
||||||
<div class="cols">
|
|
||||||
<div class="card card--good">
|
|
||||||
<p class="tag good">Vaste stappen</p>
|
|
||||||
<figure class="linked">
|
|
||||||
<pre [innerHTML]="code['steps']"></pre>
|
|
||||||
<figcaption class="src">↳ {{ src['steps'] }}</figcaption>
|
|
||||||
</figure>
|
|
||||||
<div class="steplist">
|
|
||||||
@for (s of iw.steps; track s; let last = $last) {
|
|
||||||
<span class="pill">{{ s }}</span>
|
|
||||||
@if (!last) {
|
|
||||||
<span class="arrow">→</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<p class="note">
|
|
||||||
De stappen zijn altijd dezelfde; alleen de vragen <em>binnen</em> een stap verschijnen
|
|
||||||
of verdwijnen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="card card--good">
|
|
||||||
<p class="tag good">De wizard</p>
|
|
||||||
<app-intake-wizard #iw />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 6. PII: mask + parse -->
|
|
||||||
<section class="section">
|
|
||||||
<app-heading [level]="2">6 · PII — maskeren & parsen</app-heading>
|
|
||||||
<p class="lead">
|
|
||||||
Een BSN is bijzondere persoonsgegevens (AVG art. 9). Dataminimalisatie: standaard
|
|
||||||
gemaskeerd tonen, alleen tonen na een vastgelegde handeling; en "parse, don't validate" op
|
|
||||||
het gevoeligste veld — een pure functie die de <em>elfproef</em> afdwingt.
|
|
||||||
</p>
|
|
||||||
<div class="cols">
|
|
||||||
<div class="card card--good">
|
|
||||||
<p class="tag good">Maskeren — atom</p>
|
|
||||||
<p>
|
|
||||||
BSN:
|
|
||||||
<app-masked-value
|
|
||||||
[value]="bsnShown()"
|
|
||||||
[canReveal]="true"
|
|
||||||
revealLabel="Toon BSN"
|
|
||||||
(reveal)="bsnRevealed.set(true)"
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
<figure class="linked">
|
|
||||||
<pre [innerHTML]="code['mask']"></pre>
|
|
||||||
<figcaption class="src">↳ {{ src['mask'] }}</figcaption>
|
|
||||||
</figure>
|
|
||||||
<p class="note">
|
|
||||||
Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het
|
|
||||||
behandelscherm). De atom bevat de maskeer-detectie — geen los <code>*</code>-gesnuffel
|
|
||||||
bij elke gebruiker.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<p class="tag good">Parse (elfproef) → Result</p>
|
|
||||||
<app-text-input
|
|
||||||
inputId="bsn"
|
|
||||||
[ngModel]="bsnRaw()"
|
|
||||||
(ngModelChange)="bsnRaw.set($event)"
|
|
||||||
name="bsn"
|
|
||||||
placeholder="Typ een BSN, bijv. 123456782"
|
|
||||||
/>
|
|
||||||
@let b = bsnParsed();
|
|
||||||
@if (bsnRaw()) {
|
|
||||||
<div animate.enter="app-item-enter">
|
|
||||||
@if (b.ok) {
|
|
||||||
<p class="tag good">ok</p>
|
|
||||||
<pre>Bsn ="{{ b.value }}"</pre>
|
|
||||||
} @else {
|
|
||||||
<p class="tag bad">err</p>
|
|
||||||
<pre>{{ b.error }}</pre>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<figure class="linked">
|
|
||||||
<pre [innerHTML]="code['parseBsn']"></pre>
|
|
||||||
<figcaption class="src">↳ {{ src['parseBsn'] }}</figcaption>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</app-page-shell>
|
</app-page-shell>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class ConceptsPage {
|
export class ConceptsPage {}
|
||||||
isEmpty = (v: string[]) => !v || v.length === 0;
|
|
||||||
|
|
||||||
doorgehaald: Registration = {
|
|
||||||
bigNummer: '19012345601',
|
|
||||||
naam: 'Dr. A. (Anna) de Vries',
|
|
||||||
beroep: 'Arts',
|
|
||||||
registratiedatum: '2012-09-01',
|
|
||||||
geboortedatum: '1985-03-14',
|
|
||||||
status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' },
|
|
||||||
};
|
|
||||||
|
|
||||||
loadingRes = fakeResource<string[]>('loading');
|
|
||||||
emptyRes = fakeResource<string[]>('resolved', []);
|
|
||||||
errorRes = fakeResource<string[]>('error', undefined, new Error('Demo'));
|
|
||||||
successRes = fakeResource<string[]>('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']);
|
|
||||||
|
|
||||||
raw = signal('');
|
|
||||||
parsed = computed(() => parsePostcode(this.raw()));
|
|
||||||
|
|
||||||
// 6 · PII demo. Masked-by-default value that reveals locally (the real reveal is
|
|
||||||
// step-up-gated + audited elsewhere); plus a live elfproef parse mirroring the postcode demo.
|
|
||||||
demoBsn = '123456782';
|
|
||||||
bsnRevealed = signal(false);
|
|
||||||
bsnShown = computed(() => (this.bsnRevealed() ? this.demoBsn : maskBsn(this.demoBsn)));
|
|
||||||
bsnRaw = signal('');
|
|
||||||
bsnParsed = computed(() => parseBsn(this.bsnRaw()));
|
|
||||||
|
|
||||||
// Deliberately-wrong illustrations (no real source to link — they show the anti-pattern).
|
|
||||||
private readonly illustrations: Record<string, string> = {
|
|
||||||
unionBad: `interface Registration {
|
|
||||||
status: 'Geregistreerd' | 'Doorgehaald';
|
|
||||||
herregistratieDatum: string; // altijd aanwezig 😬
|
|
||||||
}`,
|
|
||||||
machineBad: `submitting = signal(false);
|
|
||||||
submitted = signal(false);
|
|
||||||
errors = signal<...>({});
|
|
||||||
// submitting === true && errors.size > 0 ? 🤷`,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Highlighted HTML per snippet: the real ones come from SNIPPETS (extracted from source
|
|
||||||
by gen:snippets — they can't drift), the illustrations are authored above. */
|
|
||||||
protected readonly code: Record<string, string> = Object.fromEntries(
|
|
||||||
Object.entries({ ...SNIPPETS, ...this.illustrations }).map(([k, v]) => [k, highlightTs(v)]),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** The real file each linked snippet is extracted from (shown as a caption). */
|
|
||||||
protected readonly src: Record<string, string> = {
|
|
||||||
union: 'registratie/domain/registration.ts',
|
|
||||||
fold: 'shared/application/remote-data.ts',
|
|
||||||
parse: 'registratie/domain/value-objects/postcode.ts',
|
|
||||||
machine: 'registratie/domain/change-request.machine.ts',
|
|
||||||
steps: 'herregistratie/domain/intake.machine.ts',
|
|
||||||
parseBsn: 'shared/kernel/bsn.ts',
|
|
||||||
mask: 'shared/kernel/pii.ts',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
|
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
|
||||||
|
import { ConceptCardComponent } from './concept-card.component';
|
||||||
|
import { SNIPPETS } from './snippets.generated';
|
||||||
|
import { highlightTs } from './highlight-ts';
|
||||||
|
|
||||||
|
/** Section 4: form as a state machine, shown as a live state diagram. One tagged union
|
||||||
|
drives the UI — the marked state below is the wizard's current one. Composition-only. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-concepts-form-machine-section',
|
||||||
|
imports: [HeadingComponent, HerregistratieWizardComponent, ConceptCardComponent],
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.machine {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
.node {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--rhc-color-grijs-300);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--rhc-color-grijs-700);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.node.on {
|
||||||
|
background: var(--rhc-color-hemelblauw-100);
|
||||||
|
border-color: var(--rhc-color-hemelblauw-500);
|
||||||
|
color: var(--rhc-color-hemelblauw-700);
|
||||||
|
font-weight: 700;
|
||||||
|
/* teaching motion: the active state pops as the wizard transitions (the .node
|
||||||
|
transition above animates it; reduced-motion is handled globally). */
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<section class="app-section">
|
||||||
|
<app-heading [level]="2">4 · Form als state machine</app-heading>
|
||||||
|
<p class="app-lead">
|
||||||
|
Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de huidige.
|
||||||
|
</p>
|
||||||
|
<div class="app-cols">
|
||||||
|
<app-concept-card variant="bad" tag="Fout — losse booleans" [code]="code['machineBad']">
|
||||||
|
<p class="app-note">
|
||||||
|
Niets verhindert"submitting" mét validatiefouten of een successcherm met errors.
|
||||||
|
</p>
|
||||||
|
</app-concept-card>
|
||||||
|
<app-concept-card
|
||||||
|
variant="good"
|
||||||
|
tag="Goed — één tagged union"
|
||||||
|
[code]="code['machine']"
|
||||||
|
[src]="src['machine']"
|
||||||
|
>
|
||||||
|
<div class="machine">
|
||||||
|
@for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) {
|
||||||
|
<span class="node" [class.on]="w.state().tag === n">{{ n }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<app-herregistratie-wizard #w />
|
||||||
|
</app-concept-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class FormMachineSection {
|
||||||
|
// Deliberately-wrong illustration (no real source to link — it shows the anti-pattern).
|
||||||
|
private readonly machineBad = `submitting = signal(false);
|
||||||
|
submitted = signal(false);
|
||||||
|
errors = signal<...>({});
|
||||||
|
// submitting === true && errors.size > 0 ? 🤷`;
|
||||||
|
|
||||||
|
/** Highlighted HTML per snippet: `machine` comes from SNIPPETS (extracted from source by
|
||||||
|
gen:snippets — it can't drift), `machineBad` is authored above. */
|
||||||
|
protected readonly code: Record<string, string> = {
|
||||||
|
machineBad: highlightTs(this.machineBad),
|
||||||
|
machine: highlightTs(SNIPPETS['machine']),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The real file the linked snippet is extracted from (shown as a caption). */
|
||||||
|
protected readonly src: Record<string, string> = {
|
||||||
|
machine: 'registratie/domain/change-request.machine.ts',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Component, computed, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
|
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||||
|
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
|
||||||
|
import { ConceptCardComponent } from './concept-card.component';
|
||||||
|
import { SNIPPETS } from './snippets.generated';
|
||||||
|
import { highlightTs } from './highlight-ts';
|
||||||
|
|
||||||
|
/** Section 3: parse, don't validate. After parsing, the TYPE remembers the value is valid.
|
||||||
|
Composition-only; owns its own postcode demo. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-concepts-parse-section',
|
||||||
|
imports: [FormsModule, HeadingComponent, TextInputComponent, ConceptCardComponent],
|
||||||
|
template: `
|
||||||
|
<section class="app-section">
|
||||||
|
<app-heading [level]="2">3 · Parse, don't validate</app-heading>
|
||||||
|
<p class="app-lead">Na het parsen onthoudt het <em>type</em> dat de waarde geldig is.</p>
|
||||||
|
<div class="app-cols">
|
||||||
|
<app-concept-card
|
||||||
|
tag="Smart constructor → Result"
|
||||||
|
[code]="code['parse']"
|
||||||
|
[src]="src['parse']"
|
||||||
|
>
|
||||||
|
<app-text-input
|
||||||
|
inputId="pc"
|
||||||
|
[ngModel]="raw()"
|
||||||
|
(ngModelChange)="raw.set($event)"
|
||||||
|
name="pc"
|
||||||
|
placeholder="Typ een postcode, bijv. 1234 AB"
|
||||||
|
/>
|
||||||
|
</app-concept-card>
|
||||||
|
@let r = parsed();
|
||||||
|
<app-concept-card [variant]="r.ok ? 'good' : 'bad'" [tag]="r.ok ? 'ok' : 'err'">
|
||||||
|
@if (r.ok) {
|
||||||
|
<div animate.enter="app-item-enter">
|
||||||
|
<pre class="app-code">Postcode ="{{ r.value }}"</pre>
|
||||||
|
<p class="app-note">
|
||||||
|
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div animate.enter="app-item-enter">
|
||||||
|
<pre class="app-code">{{ r.error }}</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</app-concept-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ParseSection {
|
||||||
|
raw = signal('');
|
||||||
|
parsed = computed(() => parsePostcode(this.raw()));
|
||||||
|
|
||||||
|
protected readonly code: Record<string, string> = { parse: highlightTs(SNIPPETS['parse']) };
|
||||||
|
protected readonly src: Record<string, string> = {
|
||||||
|
parse: 'registratie/domain/value-objects/postcode.ts',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Component, computed, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
|
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||||
|
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
|
||||||
|
import { parseBsn } from '@shared/kernel/bsn';
|
||||||
|
import { maskBsn } from '@shared/kernel/pii';
|
||||||
|
import { ConceptCardComponent } from './concept-card.component';
|
||||||
|
import { SNIPPETS } from './snippets.generated';
|
||||||
|
import { highlightTs } from './highlight-ts';
|
||||||
|
|
||||||
|
/** Section 6: PII — masking and parsing. A BSN (Dutch citizen service number) is
|
||||||
|
special-category personal data (GDPR art. 9): masked by default, revealed only after
|
||||||
|
a logged action; "parse, don't validate" on the most sensitive field — a pure function
|
||||||
|
enforces the checksum. Composition-only. The ok/err result nests its own
|
||||||
|
`<app-concept-card>` for its label, for the same reason section 2 does. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-concepts-pii-section',
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
HeadingComponent,
|
||||||
|
TextInputComponent,
|
||||||
|
MaskedValueComponent,
|
||||||
|
ConceptCardComponent,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<section class="app-section">
|
||||||
|
<app-heading [level]="2">6 · PII — maskeren & parsen</app-heading>
|
||||||
|
<p class="app-lead">
|
||||||
|
Een BSN is bijzondere persoonsgegevens (AVG art. 9). Dataminimalisatie: standaard gemaskeerd
|
||||||
|
tonen, alleen tonen na een vastgelegde handeling; en"parse, don't validate" op het
|
||||||
|
gevoeligste veld — een pure functie die de <em>elfproef</em> afdwingt.
|
||||||
|
</p>
|
||||||
|
<div class="app-cols">
|
||||||
|
<app-concept-card
|
||||||
|
variant="good"
|
||||||
|
tag="Maskeren — atom"
|
||||||
|
[code]="code['mask']"
|
||||||
|
[src]="src['mask']"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
BSN:
|
||||||
|
<app-masked-value
|
||||||
|
[value]="bsnShown()"
|
||||||
|
[canReveal]="true"
|
||||||
|
revealLabel="Toon BSN"
|
||||||
|
(reveal)="bsnRevealed.set(true)"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<p class="app-note">
|
||||||
|
Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het
|
||||||
|
behandelscherm). De atom bevat de maskeer-detectie — geen los <code>*</code>-gesnuffel
|
||||||
|
bij elke gebruiker.
|
||||||
|
</p>
|
||||||
|
</app-concept-card>
|
||||||
|
<app-concept-card
|
||||||
|
tag="Parse (elfproef) → Result"
|
||||||
|
[code]="code['parseBsn']"
|
||||||
|
[src]="src['parseBsn']"
|
||||||
|
>
|
||||||
|
<app-text-input
|
||||||
|
inputId="bsn"
|
||||||
|
[ngModel]="bsnRaw()"
|
||||||
|
(ngModelChange)="bsnRaw.set($event)"
|
||||||
|
name="bsn"
|
||||||
|
placeholder="Typ een BSN, bijv. 123456782"
|
||||||
|
/>
|
||||||
|
@let b = bsnParsed();
|
||||||
|
@if (bsnRaw()) {
|
||||||
|
<div animate.enter="app-item-enter">
|
||||||
|
<app-concept-card [variant]="b.ok ? 'good' : 'bad'" [tag]="b.ok ? 'ok' : 'err'">
|
||||||
|
@if (b.ok) {
|
||||||
|
<pre class="app-code">Bsn ="{{ b.value }}"</pre>
|
||||||
|
} @else {
|
||||||
|
<pre class="app-code">{{ b.error }}</pre>
|
||||||
|
}
|
||||||
|
</app-concept-card>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</app-concept-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class PiiSection {
|
||||||
|
// Masked-by-default value that reveals locally (the real reveal is step-up-gated +
|
||||||
|
// audited elsewhere); plus a live elfproef parse mirroring the postcode demo.
|
||||||
|
demoBsn = '123456782';
|
||||||
|
bsnRevealed = signal(false);
|
||||||
|
bsnShown = computed(() => (this.bsnRevealed() ? this.demoBsn : maskBsn(this.demoBsn)));
|
||||||
|
bsnRaw = signal('');
|
||||||
|
bsnParsed = computed(() => parseBsn(this.bsnRaw()));
|
||||||
|
|
||||||
|
protected readonly code: Record<string, string> = {
|
||||||
|
mask: highlightTs(SNIPPETS['mask']),
|
||||||
|
parseBsn: highlightTs(SNIPPETS['parseBsn']),
|
||||||
|
};
|
||||||
|
protected readonly src: Record<string, string> = {
|
||||||
|
mask: 'shared/kernel/pii.ts',
|
||||||
|
parseBsn: 'shared/kernel/bsn.ts',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import type { Resource } from '@angular/core';
|
||||||
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
|
import { ASYNC } from '@shared/ui/async/async.component';
|
||||||
|
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||||
|
import { ConceptCardComponent } from './concept-card.component';
|
||||||
|
import { SNIPPETS } from './snippets.generated';
|
||||||
|
import { highlightTs } from './highlight-ts';
|
||||||
|
|
||||||
|
/** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */
|
||||||
|
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
|
||||||
|
return {
|
||||||
|
value: () => value as T,
|
||||||
|
status: () => status,
|
||||||
|
error: () => error,
|
||||||
|
hasValue: () => value !== undefined,
|
||||||
|
reload: () => {},
|
||||||
|
} as unknown as Resource<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Section 2: RemoteData fold. One value with four mutually exclusive states, instead of
|
||||||
|
three loose booleans. Composition-only; owns its own fake resources. Each of the four
|
||||||
|
demo states nests its own `<app-concept-card>` for its label — a plain `.tag` element
|
||||||
|
written here would carry this section's scope, not the card's, and stay unstyled. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-concepts-remote-data-section',
|
||||||
|
imports: [HeadingComponent, ...ASYNC, SkeletonComponent, ConceptCardComponent],
|
||||||
|
template: `
|
||||||
|
<section class="app-section">
|
||||||
|
<app-heading [level]="2">2 · RemoteData fold</app-heading>
|
||||||
|
<p class="app-lead">
|
||||||
|
Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.
|
||||||
|
</p>
|
||||||
|
<div class="app-cols">
|
||||||
|
<app-concept-card variant="good" tag="Vier toestanden, één molecuul">
|
||||||
|
<div class="app-stack">
|
||||||
|
<app-concept-card variant="plain" tag="Loading">
|
||||||
|
<app-async [resource]="loadingRes"
|
||||||
|
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template
|
||||||
|
><ng-template appAsyncLoading
|
||||||
|
><app-skeleton [count]="2" height="1.2rem" [delay]="0" /></ng-template
|
||||||
|
></app-async>
|
||||||
|
</app-concept-card>
|
||||||
|
<app-concept-card variant="plain" tag="Empty">
|
||||||
|
<app-async [resource]="emptyRes" [isEmpty]="isEmpty"
|
||||||
|
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
|
||||||
|
>
|
||||||
|
</app-concept-card>
|
||||||
|
<app-concept-card variant="plain" tag="Failure">
|
||||||
|
<app-async [resource]="errorRes"
|
||||||
|
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
|
||||||
|
>
|
||||||
|
</app-concept-card>
|
||||||
|
<app-concept-card variant="plain" tag="Success">
|
||||||
|
<app-async [resource]="successRes" [isEmpty]="isEmpty"
|
||||||
|
><ng-template appAsyncLoaded
|
||||||
|
><ul>
|
||||||
|
@for (i of successRes.value(); track i) {
|
||||||
|
<li>{{ i }}</li>
|
||||||
|
}
|
||||||
|
</ul></ng-template
|
||||||
|
></app-async
|
||||||
|
>
|
||||||
|
</app-concept-card>
|
||||||
|
</div>
|
||||||
|
</app-concept-card>
|
||||||
|
<app-concept-card
|
||||||
|
variant="good"
|
||||||
|
tag="De exhaustieve fold"
|
||||||
|
[code]="code['fold']"
|
||||||
|
[src]="src['fold']"
|
||||||
|
>
|
||||||
|
<p class="app-note">
|
||||||
|
Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem
|
||||||
|
afhandelt.
|
||||||
|
</p>
|
||||||
|
</app-concept-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class RemoteDataSection {
|
||||||
|
isEmpty = (v: string[]) => !v || v.length === 0;
|
||||||
|
|
||||||
|
loadingRes = fakeResource<string[]>('loading');
|
||||||
|
emptyRes = fakeResource<string[]>('resolved', []);
|
||||||
|
errorRes = fakeResource<string[]>('error', undefined, new Error('Demo'));
|
||||||
|
successRes = fakeResource<string[]>('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']);
|
||||||
|
|
||||||
|
protected readonly code: Record<string, string> = { fold: highlightTs(SNIPPETS['fold']) };
|
||||||
|
protected readonly src: Record<string, string> = { fold: 'shared/application/remote-data.ts' };
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
|
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
||||||
|
import { Registration } from '@registratie/domain/registration';
|
||||||
|
import { ConceptCardComponent } from './concept-card.component';
|
||||||
|
import { SNIPPETS } from './snippets.generated';
|
||||||
|
import { highlightTs } from './highlight-ts';
|
||||||
|
|
||||||
|
/** Section 1: discriminated unions. Each variant carries exactly the data that fits it —
|
||||||
|
nothing more. Composition-only; owns its own demo data. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-concepts-unions-section',
|
||||||
|
imports: [HeadingComponent, RegistrationSummaryComponent, ConceptCardComponent],
|
||||||
|
template: `
|
||||||
|
<section class="app-section">
|
||||||
|
<app-heading [level]="2">1 · Discriminated unions</app-heading>
|
||||||
|
<p class="app-lead">Laat elke variant precies de gegevens dragen die kloppen — niets meer.</p>
|
||||||
|
<div class="app-cols">
|
||||||
|
<app-concept-card variant="bad" tag="Fout — vlakke interface" [code]="code['unionBad']">
|
||||||
|
<p class="app-note">
|
||||||
|
Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.
|
||||||
|
</p>
|
||||||
|
</app-concept-card>
|
||||||
|
<app-concept-card
|
||||||
|
variant="good"
|
||||||
|
tag="Goed — sum type"
|
||||||
|
[code]="code['union']"
|
||||||
|
[src]="src['union']"
|
||||||
|
>
|
||||||
|
<app-registration-summary [reg]="doorgehaald" />
|
||||||
|
<p class="app-note">
|
||||||
|
De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat
|
||||||
|
simpelweg niet.
|
||||||
|
</p>
|
||||||
|
</app-concept-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class UnionsSection {
|
||||||
|
doorgehaald: Registration = {
|
||||||
|
bigNummer: '19012345601',
|
||||||
|
naam: 'Dr. A. (Anna) de Vries',
|
||||||
|
beroep: 'Arts',
|
||||||
|
registratiedatum: '2012-09-01',
|
||||||
|
geboortedatum: '1985-03-14',
|
||||||
|
status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deliberately-wrong illustration (no real source to link — it shows the anti-pattern).
|
||||||
|
private readonly unionBad = `interface Registration {
|
||||||
|
status: 'Geregistreerd' | 'Doorgehaald';
|
||||||
|
herregistratieDatum: string; // altijd aanwezig 😬
|
||||||
|
}`;
|
||||||
|
|
||||||
|
/** Highlighted HTML per snippet: `union` comes from SNIPPETS (extracted from source by
|
||||||
|
gen:snippets — it can't drift), `unionBad` is authored above. */
|
||||||
|
protected readonly code: Record<string, string> = {
|
||||||
|
unionBad: highlightTs(this.unionBad),
|
||||||
|
union: highlightTs(SNIPPETS['union']),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The real file the linked snippet is extracted from (shown as a caption). */
|
||||||
|
protected readonly src: Record<string, string> = {
|
||||||
|
union: 'registratie/domain/registration.ts',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
|
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
|
||||||
|
import { ConceptCardComponent } from './concept-card.component';
|
||||||
|
import { SNIPPETS } from './snippets.generated';
|
||||||
|
import { highlightTs } from './highlight-ts';
|
||||||
|
|
||||||
|
/** Section 5: a questionnaire with a fixed step count — "show questions, don't add
|
||||||
|
steps". The step count never changes; follow-up questions reveal inline based on
|
||||||
|
earlier answers. Composition-only. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-concepts-vragenlijst-section',
|
||||||
|
imports: [HeadingComponent, IntakeWizardComponent, ConceptCardComponent],
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.steplist {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--rhc-color-grijs-100);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.pill.extra {
|
||||||
|
background: var(--rhc-color-geel-100);
|
||||||
|
border: 1px dashed var(--rhc-color-geel-600);
|
||||||
|
}
|
||||||
|
.arrow {
|
||||||
|
color: var(--rhc-color-grijs-400);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<section class="app-section">
|
||||||
|
<app-heading [level]="2"
|
||||||
|
>5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen"</app-heading
|
||||||
|
>
|
||||||
|
<p class="app-lead">
|
||||||
|
Het aantal stappen ligt vast (<code>STEPS</code>); vervolgvragen verschijnen
|
||||||
|
<em>binnen</em> een stap op basis van eerdere antwoorden. Antwoord"ja" op buitenland of vul
|
||||||
|
weinig uren in, en er komt een extra vraag bij in dezelfde stap — de voortgang"van N" blijft
|
||||||
|
gelijk.
|
||||||
|
</p>
|
||||||
|
<div class="app-cols">
|
||||||
|
<app-concept-card
|
||||||
|
variant="good"
|
||||||
|
tag="Vaste stappen"
|
||||||
|
[code]="code['steps']"
|
||||||
|
[src]="src['steps']"
|
||||||
|
>
|
||||||
|
<div class="steplist">
|
||||||
|
@for (s of iw.steps; track s; let last = $last) {
|
||||||
|
<span class="pill">{{ s }}</span>
|
||||||
|
@if (!last) {
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="app-note">
|
||||||
|
De stappen zijn altijd dezelfde; alleen de vragen <em>binnen</em> een stap verschijnen
|
||||||
|
of verdwijnen.
|
||||||
|
</p>
|
||||||
|
</app-concept-card>
|
||||||
|
<app-concept-card variant="good" tag="De wizard">
|
||||||
|
<app-intake-wizard #iw />
|
||||||
|
</app-concept-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class VragenlijstSection {
|
||||||
|
protected readonly code: Record<string, string> = { steps: highlightTs(SNIPPETS['steps']) };
|
||||||
|
protected readonly src: Record<string, string> = {
|
||||||
|
steps: 'herregistratie/domain/intake.machine.ts',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -264,6 +264,21 @@ Also settle the two deviations the refactor left behind:
|
|||||||
an indirection and adds a barrel-shaped thing to a repo that deliberately has none.
|
an indirection and adds a barrel-shaped thing to a repo that deliberately has none.
|
||||||
- Three of six sections have no story (`beheer-links`, `wat-moet-ik-regelen`,
|
- Three of six sections have no story (`beheer-links`, `wat-moet-ik-regelen`,
|
||||||
`wat-wilt-u-doen`). Add one only where the section has more than one visual state.
|
`wat-wilt-u-doen`). Add one only where the section has more than one visual state.
|
||||||
|
- **The folder name — fix it (RD-36, added after RD-04 shipped).** RD-04 recorded
|
||||||
|
`registratie/ui/dashboard/` as a stale label and left it alone. It stays stale: the folder is
|
||||||
|
named after a page that now lives in `overzicht/`, so a reader looking for the dashboard finds
|
||||||
|
four sections that are not it, and the page is somewhere else. Measured cost: 8 `git mv`s and
|
||||||
|
**four import lines in one file** (`overzicht.page.ts`). The alias does not change.
|
||||||
|
|
||||||
|
Two names were considered. `overzicht-secties/` says what the four files are — registratie's
|
||||||
|
sections for the overzicht page — and keeps the Dutch that CLAUDE.md requires of a domain
|
||||||
|
context. Flattening the files into `registratie/ui/` was rejected: that directory holds one
|
||||||
|
folder per component, so eight loose files would break its shape.
|
||||||
|
|
||||||
|
Fold in two stale paths the same move left behind, both naming a file that no longer exists:
|
||||||
|
`docs/reference/feature-flags.md:55` and `.claude/skills/new-ssp/SKILL.md:65` still say
|
||||||
|
`registratie/ui/dashboard.page`. The flag logic they describe lives in
|
||||||
|
`overzicht/ui/wat-wilt-u-doen.section.ts` now.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# RD-21 — Move the selection surgery into `rich-text-dom.ts`, and delete the disable
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Source: PLAN.md 3h, order step 3
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`rich-text-editor.component.ts` measures ~256 effective lines against a limit of 250, so it
|
||||||
|
carries `/* eslint-disable max-lines */`. The lines that put it over are not component
|
||||||
|
concerns: `deleteAdjacentChip` and `insert` do `getSelection()`/`Range` surgery inside a
|
||||||
|
component whose job is the toolbar and the `contenteditable` host.
|
||||||
|
|
||||||
|
`rich-text-dom.ts` already exists beside it, already owns the DOM boundary, and already has a
|
||||||
|
spec. The seam is built. This is the cheapest of Phase 3's seven splits, and it converts two
|
||||||
|
untested imperative branches into spec cases.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `libs/shared/src/ui/rich-text-editor/rich-text-dom.ts` — the four exports today
|
||||||
|
(`renderInto`, `createChip`, `readBlock`, `adjacentChip`) and the file's header comment,
|
||||||
|
which already states the contract this ticket extends.
|
||||||
|
- `rich-text-editor.component.ts:257-293` — `deleteAdjacentChip` and `insert`, the two bodies
|
||||||
|
that move.
|
||||||
|
- `rich-text-dom.spec.ts` — 8 cases, plain jsdom, no TestBed. The new cases join it.
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
1. **Two new exports in `rich-text-dom.ts`, both taking the editor root:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** The chip a collapsed caret sits next to, or null. `direction` is -1 for
|
||||||
|
Backspace and 1 for Delete. Returns null when the selection is absent, is a
|
||||||
|
range rather than a caret, or sits outside `root`. */
|
||||||
|
export function chipAtCaret(root: HTMLElement, direction: -1 | 1): HTMLElement | null;
|
||||||
|
|
||||||
|
/** Insert `chip` at the caret when the selection is inside `root`, and leave the
|
||||||
|
caret after it. With no usable selection, append to the last line instead. */
|
||||||
|
export function insertChipAtCaret(root: HTMLElement, chip: HTMLElement): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **`chipAtCaret` wraps `adjacentChip`; it does not replace it.** `adjacentChip` stays
|
||||||
|
exported and keeps its own spec case, which tests the node/offset arithmetic directly.
|
||||||
|
`chipAtCaret` adds the selection guards around it. The component stops importing
|
||||||
|
`adjacentChip` and imports `chipAtCaret` instead.
|
||||||
|
|
||||||
|
3. **The two component methods reduce to their component concerns:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
private deleteAdjacentChip(e: KeyboardEvent) {
|
||||||
|
const el = this.editorEl()?.nativeElement;
|
||||||
|
if (!el) return;
|
||||||
|
const chip = chipAtCaret(el, e.key === 'Backspace' ? -1 : 1);
|
||||||
|
if (!chip) return;
|
||||||
|
e.preventDefault();
|
||||||
|
chip.remove();
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected insert(key: string) {
|
||||||
|
const el = this.editorEl()?.nativeElement;
|
||||||
|
if (!key || !el) return;
|
||||||
|
el.focus();
|
||||||
|
insertChipAtCaret(el, createChip(el.ownerDocument, key, this.labelFor(key), this.autoFor(key)));
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`e.preventDefault()`, `chip.remove()`, `el.focus()` and `this.emit()` stay in the component:
|
||||||
|
they are event handling and output, not DOM boundary work.
|
||||||
|
|
||||||
|
4. **Delete `/* eslint-disable max-lines */` from line 1 of the component.** This is not
|
||||||
|
optional bookkeeping — `reportUnusedDisableDirectives` is `error`, so leaving a directive
|
||||||
|
that is no longer needed **fails the build**. The two checks pin each other: if the file is
|
||||||
|
still over budget, lint fails on `max-lines`; if it is under and the directive stays, lint
|
||||||
|
fails on the unused directive.
|
||||||
|
|
||||||
|
5. **No new file, no new folder.** `rich-text-dom.ts` is the right home and already carries the
|
||||||
|
header comment that describes exactly this responsibility.
|
||||||
|
|
||||||
|
6. **No story changes.** `rich-text-editor.stories.ts` exercises the component through the same
|
||||||
|
public surface; nothing it renders changes.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `libs/shared/src/ui/rich-text-editor/rich-text-dom.ts` — two new exports
|
||||||
|
- `libs/shared/src/ui/rich-text-editor/rich-text-dom.spec.ts` — new cases
|
||||||
|
- `libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts` — two shrunken methods,
|
||||||
|
changed import, and the disable deleted
|
||||||
|
- `libs/shared/docs/behaviour-spec.mdx` (regenerated, never hand-edited)
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Add `chipAtCaret` and `insertChipAtCaret` per decision 1, moving the bodies out of the
|
||||||
|
component rather than rewriting them.
|
||||||
|
2. Add spec cases. Cover, at minimum: `chipAtCaret` returns the chip before a Backspace caret;
|
||||||
|
returns null for a non-collapsed selection; returns null for a caret outside `root`;
|
||||||
|
`insertChipAtCaret` splices at the caret and leaves the caret after the chip;
|
||||||
|
`insertChipAtCaret` appends when there is no selection inside `root`.
|
||||||
|
3. Rewrite the two component methods per decision 3 and fix the import line.
|
||||||
|
4. Delete the disable (decision 4).
|
||||||
|
5. Run `npm run gen:behaviour-spec`.
|
||||||
|
6. `git add -A`, then run the acceptance commands.
|
||||||
|
7. Update this ticket's `Status:` to `done` and the README's RD-21 row to `done`.
|
||||||
|
8. Commit all of it together.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
Measured against the tree before handover.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -c "^export function" libs/shared/src/ui/rich-text-editor/rich-text-dom.ts # is 4 -> MUST be 6
|
||||||
|
git grep -c "eslint-disable max-lines" -- libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts # is 1 -> MUST be 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The selection surgery has left the component entirely:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -c "getSelection" -- libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts # is 2 -> MUST be 0
|
||||||
|
git grep -c "getSelection" -- libs/shared/src/ui/rich-text-editor/rich-text-dom.ts # is 0 -> MUST be 2
|
||||||
|
git grep -c "adjacentChip" -- libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts # is 2 -> MUST be 0
|
||||||
|
```
|
||||||
|
|
||||||
|
`adjacentChip` survives with its spec case (decision 2):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -c "export function adjacentChip" libs/shared/src/ui/rich-text-editor/rich-text-dom.ts # MUST be 1
|
||||||
|
git grep -c " it(" libs/shared/src/ui/rich-text-editor/rich-text-dom.spec.ts # is 8 -> MUST be >= 13
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run ci --full # exits 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
**Do not add a line-count command.** `npm run lint`, inside the gate, is the exact check and a
|
||||||
|
hand-rolled `grep -v | wc -l` is not: it cannot reproduce eslint's `skipComments` for a trailing
|
||||||
|
comment or for the component's inline template. Decision 4 explains why lint alone pins both
|
||||||
|
directions.
|
||||||
|
|
||||||
|
**`--full` is required.** This edits `libs/shared/src/ui/**`, which the README's rule names
|
||||||
|
explicitly.
|
||||||
|
|
||||||
|
jsdom supports `document.getSelection()`, `Range.deleteContents()`, `insertNode`,
|
||||||
|
`removeAllRanges` and `addRange`, so every new case runs in the existing plain-vitest setup. No
|
||||||
|
TestBed, no browser.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Splitting the component further. It is over by a handful of lines, not structurally wrong.
|
||||||
|
- Touching `renderInto`, `readBlock` or `createChip`.
|
||||||
|
- The `ponytail:` note in `rich-text-dom.ts`'s header about exotic pasted markup. That is a
|
||||||
|
recorded limitation, not this ticket's work.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Deleting the disable is mandatory, not cosmetic** (decision 4). Forgetting it fails lint
|
||||||
|
with `Unused eslint-disable directive`, which reads like an unrelated error.
|
||||||
|
- **Keep `adjacentChip` exported.** Its spec case imports it directly; folding it into
|
||||||
|
`chipAtCaret` deletes a test that covers node/offset arithmetic the wrapper does not.
|
||||||
|
- **Move the bodies, do not rewrite them.** The caret placement after insert
|
||||||
|
(`setStartAfter` → `collapse(true)` → `removeAllRanges` → `addRange`) is the part users feel;
|
||||||
|
a "cleaner" rewrite is where a regression hides, and no story catches it.
|
||||||
|
- **`behaviour-spec.mdx` drift** from the new spec titles. Regenerate in the same commit. A name
|
||||||
|
used in a `describe` or `it` title also lands in that generated file — count it if you add a
|
||||||
|
grep for one.
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# RD-22 — Split `intake-wizard` into three step components
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Source: PLAN.md 3c, order step 4
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`intake-wizard.component.ts` measures ~362 effective lines against a limit of 250, and carries
|
||||||
|
`/* eslint-disable max-lines */`. Nearly all of the excess is one `@switch` with three `@case`
|
||||||
|
blocks — three screens' worth of markup in one file, where reading any one of them means
|
||||||
|
scrolling past the other two.
|
||||||
|
|
||||||
|
The three cases are already independent. Each reads only the answers, the errors and (for two of
|
||||||
|
them) the scholing threshold. None needs the store.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `apps/ssp/src/app/registratie/ui/address-fields/address-fields.component.ts:13-18` — **the
|
||||||
|
contract to copy, verbatim.** "Pure & presentational — values in via `value`, errors in via
|
||||||
|
`errors`, every keystroke out via `fieldChange`. No store, no services, no internal state; the
|
||||||
|
container owns the Model and decides what a change means." Two containers already reuse it.
|
||||||
|
- `intake-wizard.component.ts:72-249` — the `@switch` and its three cases.
|
||||||
|
- `intake.machine.ts` — `Answers` (21), `lageUren` (52), `SCHOLING_THRESHOLD_DEFAULT` (43), and
|
||||||
|
`Errors` at line 63, which decision 2 exports.
|
||||||
|
- `libs/shared/src/layout/wizard-shell/wizard-shell.component.ts:103-113` — the `<form>` and the
|
||||||
|
`<ng-content />` the steps are projected into. Relevant to the first risk.
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
1. **Three new files, beside the parent, named `*.step.ts`:**
|
||||||
|
|
||||||
|
| File | Class | Selector |
|
||||||
|
| -------------------- | ---------------- | ---------------------------- |
|
||||||
|
| `buitenland.step.ts` | `BuitenlandStep` | `app-intake-buitenland-step` |
|
||||||
|
| `werk.step.ts` | `WerkStep` | `app-intake-werk-step` |
|
||||||
|
| `review.step.ts` | `ReviewStep` | `app-intake-review-step` |
|
||||||
|
|
||||||
|
These are the repository's **first** `*.step.ts` files, so this ticket sets the convention
|
||||||
|
that RD-23 follows. The `max-lines` glob already includes `step`, so they are guarded from
|
||||||
|
the moment they exist.
|
||||||
|
|
||||||
|
2. **Inputs down, one narrow output up, `dispatch` never passed down.**
|
||||||
|
|
||||||
|
| Step | Inputs | Output |
|
||||||
|
| ------------ | ---------------------------------------- | ----------------------------------------------------- |
|
||||||
|
| `buitenland` | `answers`, `errors` | `answerChange: { key: keyof Answers; value: string }` |
|
||||||
|
| `werk` | `answers`, `errors`, `scholingThreshold` | `answerChange` (same shape) |
|
||||||
|
| `review` | `answers`, `scholingThreshold` | `edit: number` (the cursor to jump to) |
|
||||||
|
|
||||||
|
All inputs are `input.required<T>()`. The parent maps the outputs back to messages:
|
||||||
|
`(answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"` and
|
||||||
|
`(edit)="dispatch({ tag: 'GaNaarStap', cursor: $event })"`.
|
||||||
|
|
||||||
|
3. **`scholingZichtbaar` is not an input — each step derives it.** `werk` and `review` both call
|
||||||
|
the pure `lageUren(this.answers(), this.scholingThreshold())` themselves. "Derive, don't
|
||||||
|
store" (CLAUDE.md decision 3). The parent's `scholingZichtbaar` computed is deleted; it has
|
||||||
|
exactly three references today, all of them in the two blocks that move.
|
||||||
|
|
||||||
|
4. **Export `Errors` from `intake.machine.ts:63.`** It is `type Errors = …` without `export`
|
||||||
|
today, so a step cannot name its own input type. One word. Do not redeclare the type in the
|
||||||
|
step files, and do not widen the input to `Record<string, string>`.
|
||||||
|
|
||||||
|
5. **The parent keeps the shell, the store, and everything that touches them.** After the split
|
||||||
|
it holds: the store and its effect map, `draftSync`, `IntakePolicyStore`, `restart()`,
|
||||||
|
`phase`, `primaryLabel`, `stepTitle`, `stepLabels`, `errorList`, and the `wizardSuccess`
|
||||||
|
block. It loses `err`, `set`, `jaNee` and `scholingZichtbaar`, and gains one computed:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
protected errors = computed<Errors>(() => this.answering()?.errors ?? {});
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Prune the parent's `imports:` array.** After the move it needs only `ButtonComponent`,
|
||||||
|
`ConfirmationComponent`, `WizardShellComponent` and the three steps. `FormsModule`,
|
||||||
|
`FormFieldComponent`, `TextInputComponent`, `RadioGroupComponent`, `AlertComponent`,
|
||||||
|
`DataRowComponent` and `ReviewSectionComponent` all move into the steps that use them. A
|
||||||
|
stale entry is not an error, so nothing fails if you forget — check the list by hand.
|
||||||
|
|
||||||
|
7. **Delete `/* eslint-disable max-lines */` from the parent.** Mandatory, not bookkeeping:
|
||||||
|
`reportUnusedDisableDirectives` is `error`, so the two rules pin each other. Still over
|
||||||
|
budget → `max-lines` fails. Under budget with the directive left in → unused-directive fails.
|
||||||
|
|
||||||
|
8. **No stories for the new steps.** PLAN's corollary: each wizard's existing story already
|
||||||
|
mounts every step by seeding the machine. `intake-wizard.stories.ts` is unchanged, and the
|
||||||
|
parent's public API does not move.
|
||||||
|
|
||||||
|
9. **Move the markup, do not improve it.** Copy each `@case` body into its step's template and
|
||||||
|
change only what decisions 2 and 3 require: `err('x')` becomes `errors()['x'] ?? ''` through a
|
||||||
|
local helper, `set('x', $event)` becomes an `answerChange.emit(…)`, and `dispatch(GaNaarStap)`
|
||||||
|
becomes `edit.emit(n)`. Every `i18n` id, label, placeholder and `fieldId` stays byte-identical.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `apps/ssp/src/app/herregistratie/ui/intake-wizard/buitenland.step.ts` (new)
|
||||||
|
- `apps/ssp/src/app/herregistratie/ui/intake-wizard/werk.step.ts` (new)
|
||||||
|
- `apps/ssp/src/app/herregistratie/ui/intake-wizard/review.step.ts` (new)
|
||||||
|
- `apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts`
|
||||||
|
- `apps/ssp/src/app/herregistratie/domain/intake.machine.ts` (decision 4, one word)
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Export `Errors` (decision 4).
|
||||||
|
2. Write the three step components, moving each `@case` body verbatim per decision 9.
|
||||||
|
3. Replace the `@switch` in the parent with the three elements, wire the outputs per decision 2.
|
||||||
|
4. Delete `err`, `set`, `jaNee`, `scholingZichtbaar`; add the `errors` computed (decision 5).
|
||||||
|
5. Prune `imports:` (decision 6) and delete the disable (decision 7).
|
||||||
|
6. `git add -A`, then run the acceptance commands.
|
||||||
|
7. Update this ticket's `Status:` to `done` and the README's RD-22 row to `done`.
|
||||||
|
8. Commit all of it together.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
Measured against the tree before handover. Run after `git add -A` — `git ls-files` does not see
|
||||||
|
an unstaged new file.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git ls-files 'apps/ssp/src/app/herregistratie/ui/intake-wizard/*.step.ts' | wc -l # is 0 -> MUST be 3
|
||||||
|
```
|
||||||
|
|
||||||
|
The markup left the parent, and the store did not follow it into the steps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
P=apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts
|
||||||
|
git grep -c "ngModel" -- $P # is 12 -> MUST be 0
|
||||||
|
git grep -c "eslint-disable max-lines" -- $P # is 1 -> MUST be 0
|
||||||
|
git grep -c "dispatch" -- 'apps/ssp/src/app/herregistratie/ui/intake-wizard/*.step.ts' | awk -F: '{s+=$NF} END {print s+0}' # MUST be 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Decisions 3 and 4 landed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -c "export type Errors" -- apps/ssp/src/app/herregistratie/domain/intake.machine.ts # is 0 -> MUST be 1
|
||||||
|
git grep -c "scholingZichtbaar" -- $P # is 3 -> MUST be 0
|
||||||
|
git grep -c "lageUren" -- 'apps/ssp/src/app/herregistratie/ui/intake-wizard/*.step.ts' | awk -F: '{s+=$NF} END {print s+0}' # MUST be >= 2
|
||||||
|
```
|
||||||
|
|
||||||
|
The copy did not drift (decision 9) — the `i18n` ids are the same set, only in different files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -ho "@@intake\.[a-zA-Z.]*" -- apps/ssp/src/app/herregistratie/ui/intake-wizard/ | sort -u | wc -l # is 31 -> MUST still be 31
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run ci --full # exits 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The `@@intake.*` id count is **31** today, measured across the whole `intake-wizard/` directory
|
||||||
|
so the three new files are included. A dropped or renamed id breaks the second locale, and
|
||||||
|
`ng build --localize` inside the gate fails on a missing translation — but only for an id that
|
||||||
|
is _added_, never for one silently _lost_. The count is the only check that catches a loss.
|
||||||
|
|
||||||
|
**`--full` is required.** The steps render inside the existing story, and the axe run over that
|
||||||
|
story is what proves the projected markup still has its labels and error wiring.
|
||||||
|
|
||||||
|
**Do not add a line-count command.** `npm run lint` is the exact check; decision 7 explains why.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- `registratie-wizard`. RD-23 does the same job there, and follows this ticket's naming.
|
||||||
|
- `herregistratie-wizard`. PLAN: do not split it for symmetry — it is ~248 effective lines with a
|
||||||
|
~100-line template.
|
||||||
|
- Adding stories for the steps (decision 8).
|
||||||
|
- Changing any validation, message or `i18n` id.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **`ngModel` and the projected `<form>`.** The shell renders `<form>` and `<ng-content />` in
|
||||||
|
its own view, so today's `ngModel` elements are projected into it from the parent's template.
|
||||||
|
Angular resolves a directive's injector by the **declaration** site, not the DOM position, so
|
||||||
|
those controls already do not register with the shell's `NgForm` — the bindings are one-way
|
||||||
|
`[ngModel]` plus `(ngModelChange)`. Moving them one level deeper changes nothing about that.
|
||||||
|
**Keep the bindings exactly as they are.** If a form-control warning or error appears, stop and
|
||||||
|
report it rather than adding `ngModelOptions` or an `[ngModelGroup]` to silence it.
|
||||||
|
- **Deleting the disable is mandatory** (decision 7), and its failure message
|
||||||
|
("Unused eslint-disable directive") reads like an unrelated error.
|
||||||
|
- **The `review` step needs a cursor, not a `dispatch`.** Its two edit buttons jump to cursor 0
|
||||||
|
and 1. Emit the number; let the parent build the message.
|
||||||
|
- **Three `@case` blocks, three files — do not merge them.** `buitenland` and `werk` look
|
||||||
|
similar; they are not the same screen and share no markup worth extracting.
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# RD-23 — Split `registratie-wizard` into three steps, and move the upload controller
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Source: PLAN.md 3c, order step 5
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`registratie-wizard.component.ts` measures ~568 effective lines against a limit of 250 — the
|
||||||
|
largest file in the arc, and more than twice the budget. It carries
|
||||||
|
`/* eslint-disable max-lines */`.
|
||||||
|
|
||||||
|
It is the same shape as RD-22's intake wizard: one `@switch`, three `@case` blocks, three
|
||||||
|
screens in one file. It is harder in one way that PLAN calls out — **moving the upload
|
||||||
|
controller is what gets the parent under 250**, and the controller is a stateful thing, not
|
||||||
|
markup.
|
||||||
|
|
||||||
|
RD-22 already set the `*.step.ts` convention. Follow it.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `apps/ssp/src/app/herregistratie/ui/intake-wizard/buitenland.step.ts` and `review.step.ts` —
|
||||||
|
**the shape to copy.** RD-22 built them one ticket ago; match their header comments, their
|
||||||
|
`input.required` style and their output naming.
|
||||||
|
- `registratie-wizard.component.ts:100-348` — the three `@case` blocks.
|
||||||
|
- `registratie-wizard.component.ts:411-422` — `createUploadController`, and the `dispatch`
|
||||||
|
callback that decision 4 rewires.
|
||||||
|
- `registratie-wizard.component.ts:568-585` — `onDiplomaKeuze`, which decision 5 reshapes.
|
||||||
|
- `apps/ssp/src/app/registratie/ui/address-fields/address-fields.component.ts:13-18` — the
|
||||||
|
contract the whole family follows.
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
1. **Three new files beside the parent, named as RD-22 named its own:**
|
||||||
|
|
||||||
|
| File | Class | Selector | Case |
|
||||||
|
| ------------------ | -------------- | ----------------------- | ------------- |
|
||||||
|
| `adres.step.ts` | `AdresStep` | `app-reg-adres-step` | lines 100-175 |
|
||||||
|
| `beroep.step.ts` | `BeroepStep` | `app-reg-beroep-step` | lines 176-285 |
|
||||||
|
| `controle.step.ts` | `ControleStep` | `app-reg-controle-step` | lines 286-348 |
|
||||||
|
|
||||||
|
2. **A step may inject `RegistratieLookupStore` directly. This is the sanctioned exception.**
|
||||||
|
It is `providedIn: 'root'`, so every injection is the same instance, and PLAN names this "the
|
||||||
|
one place the dashboard's axis does apply": the step owns its own async presentation rather
|
||||||
|
than making the parent a pass-through for four lookup signals.
|
||||||
|
|
||||||
|
- `adres` injects it for `adresStatus` (the BRP lookup banner).
|
||||||
|
- `beroep` injects it for the DUO lookup, and owns its own `<app-async>` over it.
|
||||||
|
- `controle` injects it to build `samenvattingVragen`.
|
||||||
|
|
||||||
|
The parent keeps its own injection too — the BRP prefill effect needs it (decision 6).
|
||||||
|
|
||||||
|
3. **Inputs down, narrow outputs up, `dispatch` never passed down:**
|
||||||
|
|
||||||
|
| Step | Inputs | Outputs |
|
||||||
|
| ---------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `adres` | `draft`, `errors` | `fieldChange: { key: DraftField; value: string }`, `kanaalChange: string` |
|
||||||
|
| `beroep` | `draft`, `errors`, `upload` | `uploadMsg: UploadMsg`, `antwoordChange: { vraagId: string; value: string }`, `diplomaChosen: string`, `beroepDeclared: string` |
|
||||||
|
| `controle` | `draft` | `edit: number` |
|
||||||
|
|
||||||
|
Four outputs on `beroep` is correct: they are four distinct user intents, and each maps to one
|
||||||
|
message in the parent. That is not the same thing as handing the step a `dispatch`.
|
||||||
|
|
||||||
|
4. **The upload controller moves into `beroep.step.ts` and emits instead of dispatching.**
|
||||||
|
`createUploadController` takes a `dispatch` callback, so the step builds its own:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
protected uploadCtl = createUploadController({
|
||||||
|
wizardId: 'registratie',
|
||||||
|
getUpload: () => this.upload(),
|
||||||
|
dispatch: (msg) => this.uploadMsg.emit(msg),
|
||||||
|
getCategoryParams: () => ({ … }), // unchanged, reads this.draft()
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The parent maps it back with `(uploadMsg)="dispatch({ tag: 'Upload', msg: $event })"`. This
|
||||||
|
is what collapses five template handlers into one output. `previewUrlFor` moves with the
|
||||||
|
controller — it is `uploadCtl.previewUrlFor` and the child takes it as a function reference.
|
||||||
|
|
||||||
|
5. **`onDiplomaKeuze` stays in the parent, and loses its `data` parameter.** The step emits only
|
||||||
|
the chosen id (`diplomaChosen`). The parent keeps its `duoData` computed and reads it inside
|
||||||
|
the method instead of receiving it as an argument:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
protected onDiplomaKeuze(id: string) {
|
||||||
|
const data = this.duoData();
|
||||||
|
if (!data) return;
|
||||||
|
… // body otherwise unchanged
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Building a `KiesDiploma`/`KiesHandmatig` message needs the DUO payload to map an id to a
|
||||||
|
beroep and its question ids. That is machine-message construction, and it belongs in the
|
||||||
|
container.
|
||||||
|
|
||||||
|
6. **The BRP prefill `effect` stays in the parent**, exactly as written, including its
|
||||||
|
`untracked` call. It writes to the machine, so it belongs where the machine lives. Do not move
|
||||||
|
it into `adres.step.ts`.
|
||||||
|
|
||||||
|
7. **The parent keeps** the shell wiring, the store and its effect map, `draftSync`, the seed
|
||||||
|
constructor, `phase`, `primaryLabel`, `stepTitle`, `stepLabels`, `errorList`, `referentie`,
|
||||||
|
`cursor`, `step`, `draft`, `upload`, `duoData`, `onDiplomaKeuze`, and the `wizardSuccess`
|
||||||
|
block. It gains one computed, as RD-22's parent did:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
protected errors = computed<Errors>(() => this.invullen()?.errors ?? {});
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything else in the list below moves out with the markup that used it: `uploadCtl`,
|
||||||
|
`previewUrlFor`, `kanalen`, `err`, `vraagErr`, `antwoord`, `set`, `setKanaal`,
|
||||||
|
`handmatigActief`, `diplomaKeuze`, `diplomaOptions`, `beroepOptions`, `actieveVragen`,
|
||||||
|
`samenvattingVragen`, `adresSamenvatting`, `adresHerkomstLabel`, `correspondentieLabel`,
|
||||||
|
`diplomaHerkomstLabel`, `adresStatus`, `lookupRd`.
|
||||||
|
|
||||||
|
8. **Delete `/* eslint-disable max-lines */` from the parent.** Mandatory:
|
||||||
|
`reportUnusedDisableDirectives` is `error`, so the two rules pin each other in both
|
||||||
|
directions.
|
||||||
|
|
||||||
|
9. **No stories for the new steps** (PLAN's corollary). `registratie-wizard.stories.ts` already
|
||||||
|
mounts every step by seeding the machine, and the parent's public API does not move.
|
||||||
|
|
||||||
|
10. **Move the markup, do not improve it.** Every `i18n` id, label, placeholder, `fieldId` and
|
||||||
|
`aria` string stays byte-identical. `Errors` is already exported from the machine — RD-20
|
||||||
|
made it a type alias — so no machine change is needed this time.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `apps/ssp/src/app/registratie/ui/registratie-wizard/adres.step.ts` (new)
|
||||||
|
- `apps/ssp/src/app/registratie/ui/registratie-wizard/beroep.step.ts` (new)
|
||||||
|
- `apps/ssp/src/app/registratie/ui/registratie-wizard/controle.step.ts` (new)
|
||||||
|
- `apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts`
|
||||||
|
|
||||||
|
No machine file changes. No shared-library changes.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Write `adres.step.ts` — the smallest, and the one that proves the injection pattern.
|
||||||
|
2. Write `controle.step.ts` — read-only markup plus one output.
|
||||||
|
3. Write `beroep.step.ts` last: it carries the async lookup, the policy questions and the upload
|
||||||
|
controller.
|
||||||
|
4. Replace the `@switch` with the three elements and wire the outputs per decision 3.
|
||||||
|
5. Delete the members listed in decision 7, add the `errors` computed, reshape `onDiplomaKeuze`
|
||||||
|
per decision 5, prune `imports:`.
|
||||||
|
6. Delete the disable (decision 8).
|
||||||
|
7. `git add -A`, then run the acceptance commands.
|
||||||
|
8. Update this ticket's `Status:` to `done` and the README's RD-23 row to `done`.
|
||||||
|
9. Commit all of it together.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
Measured against the tree before handover. Run after `git add -A`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
D=apps/ssp/src/app/registratie/ui/registratie-wizard
|
||||||
|
P=$D/registratie-wizard.component.ts
|
||||||
|
git ls-files "$D/*.step.ts" | wc -l # is 0 -> MUST be 3
|
||||||
|
git grep -c "eslint-disable max-lines" -- $P # is 1 -> MUST be 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The upload controller moved, and the store did not follow the markup down:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -c "uploadCtl" -- $P # is 7 -> MUST be 0
|
||||||
|
git grep -c "createUploadController" -- $D/beroep.step.ts # MUST be 2
|
||||||
|
git grep -c "dispatch" -- "$D/*.step.ts" | awk -F: '{s+=$NF} END {print s+0}' # MUST be 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Two corrections found while running these before handover (recorded here per the README's
|
||||||
|
rule 4 on ticket-writing misses):
|
||||||
|
|
||||||
|
- `createUploadController` is **2**, not 1: `git grep -c` counts matching lines, and an
|
||||||
|
import plus its one call site are always two lines (same shape as `createStore` in
|
||||||
|
`intake-wizard.component.ts`, which is 2, and `createDraftSync` in this same parent, which
|
||||||
|
is 3). A count of 1 is unreachable without an import alias that would exist only to dodge
|
||||||
|
the check.
|
||||||
|
- `dispatch` is **1**, not 0: decision 4's mandated snippet is
|
||||||
|
`dispatch: (msg) => this.uploadMsg.emit(msg),` — the `UploadControllerDeps.dispatch`
|
||||||
|
property name is not the machine's `dispatch`, but it is the same string. Satisfying
|
||||||
|
decision 4 verbatim and satisfying a target of 0 are mutually exclusive.
|
||||||
|
|
||||||
|
The three per-step concerns left the parent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -c "adresStatus" -- $P # is 3 -> MUST be 0
|
||||||
|
git grep -c "samenvattingVragen" -- $P # is 2 -> MUST be 0
|
||||||
|
git grep -c "previewUrlFor" -- $P # is 3 -> MUST be 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The copy did not drift (decision 10):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -ho "@@[a-zA-Z0-9_.]*" -- $D/ | sort -u | wc -l # is 43 -> MUST still be 43
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run ci --full # exits 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The `@@` id count is **43** across the whole `registratie-wizard/` directory, so the three new
|
||||||
|
files are included. `ng build --localize` fails on an id that is _added_ without a translation
|
||||||
|
but never on one silently _lost_; the count is the only check that catches a loss.
|
||||||
|
|
||||||
|
**`--full` is required.** The existing story mounts all three steps, and the axe run over it is
|
||||||
|
what proves the projected markup kept its labels, its error wiring and its `aria` strings.
|
||||||
|
|
||||||
|
**Do not add a line-count command.** `npm run lint` is the exact check (decision 8).
|
||||||
|
|
||||||
|
If `dotnet test` fails with `SQLite Error 1: 'no such table: …'`, that is the stale-database
|
||||||
|
trap, not your change. See the README's Troubleshooting section — RD-22 hit it.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- `herregistratie-wizard`. PLAN: do not split it for symmetry.
|
||||||
|
- Changing the upload controller itself, or `upload.machine.ts`.
|
||||||
|
- Moving the BRP prefill effect (decision 6).
|
||||||
|
- Adding stories (decision 9).
|
||||||
|
- Any validation, message or `i18n` change.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **The upload controller is the hard part, and the reason this ticket exists.** Five template
|
||||||
|
handlers become one `uploadMsg` output. Get the `dispatch: (msg) => this.uploadMsg.emit(msg)`
|
||||||
|
wiring right and the rest is markup movement.
|
||||||
|
- **`previewUrlFor` is passed to a child as a function reference**, not called in the template.
|
||||||
|
Keep it an arrow property on the step, or the binding silently loses its `this`.
|
||||||
|
- **`onDiplomaKeuze` must not move into the step** (decision 5). It builds machine messages from
|
||||||
|
the DUO payload.
|
||||||
|
- **Four outputs on `beroep` is the design, not a smell** (decision 3). Do not collapse them into
|
||||||
|
a single message-shaped output — that is passing `dispatch` up under another name, and it
|
||||||
|
moves message construction into the step.
|
||||||
|
- **Deleting the disable is mandatory** (decision 8); its failure message reads like an
|
||||||
|
unrelated error.
|
||||||
|
- **This is the largest single diff in the arc.** Work step by step in the order given, and let
|
||||||
|
the type-checker confirm each before moving on.
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# RD-24 — Split `concepts.page.ts`, and fix the highlighting it has never rendered
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Source: PLAN.md 3g, order step 6
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`concepts.page.ts` measures ~471 effective lines against a limit of 250, and carries
|
||||||
|
`/* eslint-disable max-lines */`. It is one template with six teaching sections and a 142-line
|
||||||
|
`styles:` block.
|
||||||
|
|
||||||
|
A per-section split alone does not fix the styles, because Angular scopes a component's CSS to
|
||||||
|
its own template. Splitting without moving the CSS by owner would leave every section unstyled.
|
||||||
|
|
||||||
|
**And measuring that constraint turned up a live bug** (decision 1).
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `apps/ssp/src/app/showcase/concepts.page.ts` — the whole file: styles at 48-189, template at
|
||||||
|
190-439.
|
||||||
|
- `libs/shared/styles.scss:105-130` — the `--app-devpanel-*` tokens and the existing
|
||||||
|
`.app-stack` / `.app-section` / `.app-text-subtle` globals. The new globals go beside them,
|
||||||
|
and the file's own comment says it exists to centralise exactly these idioms.
|
||||||
|
- `apps/ssp/src/app/showcase/highlight-ts.ts:42-45` — the `<span class="k|s|c">` markup whose
|
||||||
|
colours decision 1 restores.
|
||||||
|
- `scripts/check-tokens.sh:14` — the guard, and its `--include` glob.
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
1. **The syntax highlighting is dead today. Fix it by making those rules global.** Verified
|
||||||
|
against the built output, not inferred:
|
||||||
|
|
||||||
|
```
|
||||||
|
pre[_ngcontent-%COMP%] .k[_ngcontent-%COMP%]{color:#c792ea}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.k`/`.s`/`.c` spans arrive through `[innerHTML]`, so they never carry an `_ngcontent`
|
||||||
|
attribute, and the rule cannot match. `highlight-ts.ts` computes the spans, its spec passes,
|
||||||
|
and every keyword, string and comment renders in the plain foreground colour. No component in
|
||||||
|
this repository uses `ViewEncapsulation.None`, and there is no global rule for `.k`, `.s` or
|
||||||
|
`.c`.
|
||||||
|
|
||||||
|
So `.app-code .k|.s|.c` becomes **global**, in `libs/shared/styles.scss`. A component cannot
|
||||||
|
own a rule that targets markup it did not render.
|
||||||
|
|
||||||
|
2. **Five new tokens, beside `--app-devpanel-*`**, which exist for this same reason:
|
||||||
|
|
||||||
|
```scss
|
||||||
|
--app-code-bg: #1e2430;
|
||||||
|
--app-code-fg: #e6e9ef;
|
||||||
|
--app-code-keyword: #c792ea;
|
||||||
|
--app-code-string: #c3e88d;
|
||||||
|
--app-code-comment: #7e8aa0;
|
||||||
|
```
|
||||||
|
|
||||||
|
`styles.scss` is the token bridge and the guard's one exempt file, so these literals belong
|
||||||
|
here and nowhere else.
|
||||||
|
|
||||||
|
3. **Four new globals in `libs/shared/styles.scss`**, named with the existing `.app-` prefix:
|
||||||
|
|
||||||
|
| Global | Replaces | Why not a component |
|
||||||
|
| ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `.app-code` | `pre` | must style `[innerHTML]` children (decision 1) |
|
||||||
|
| `.app-lead` | `.lead` | a page-level typography idiom, used by all six sections |
|
||||||
|
| `.app-cols` | `.cols` | same |
|
||||||
|
| `.app-note` | `.note` | its content includes markup (`<code>`), so it must be projected, and projected content keeps the _declaring_ component's scope |
|
||||||
|
|
||||||
|
**Delete `.section` entirely** — the global `.app-section` already exists and does the job.
|
||||||
|
|
||||||
|
4. **`concept-card.component.ts` owns the card vocabulary and renders it.** New component in
|
||||||
|
`apps/ssp/src/app/showcase/`. It owns `.card`, `.card--good`, `.card--bad`, `.tag`, its three
|
||||||
|
modifiers and both `::before` rules, plus `.linked` and `.linked .src`.
|
||||||
|
|
||||||
|
Its API, driven by what the 12 current usages need:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
variant = input<'good' | 'bad' | 'plain'>('plain'); // card--good / card--bad / tag colour
|
||||||
|
tag = input.required<string>(); // the uppercase label
|
||||||
|
code = input<string | undefined>(); // pre [innerHTML], optional
|
||||||
|
src = input<string | undefined>(); // figcaption; wraps code in figure.linked
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything else is projected through `<ng-content />`. The card **renders the `<pre>` itself**
|
||||||
|
when `code` is set — that is what keeps `.app-code`'s box styling working without relying on
|
||||||
|
projection.
|
||||||
|
|
||||||
|
5. **Six section components, one per `<section>`**, in `apps/ssp/src/app/showcase/`:
|
||||||
|
|
||||||
|
| File | Class | Heading | Own CSS |
|
||||||
|
| ------------------------- | -------------------- | --------------------------- | --------------------------------------------- |
|
||||||
|
| `unions.section.ts` | `UnionsSection` | 1 · Discriminated unions | none |
|
||||||
|
| `remote-data.section.ts` | `RemoteDataSection` | 2 · RemoteData fold | none |
|
||||||
|
| `parse.section.ts` | `ParseSection` | 3 · Parse, don't validate | none |
|
||||||
|
| `form-machine.section.ts` | `FormMachineSection` | 4 · Form als state machine | `.machine`, `.node`, `.node.on` |
|
||||||
|
| `vragenlijst.section.ts` | `VragenlijstSection` | 5 · Vragenlijst | `.steplist`, `.pill`, `.pill.extra`, `.arrow` |
|
||||||
|
| `pii.section.ts` | `PiiSection` | 6 · PII — maskeren & parsen | none |
|
||||||
|
|
||||||
|
The "Own CSS" column is measured: those selectors appear in exactly one section each. Every
|
||||||
|
other selector is now a global or lives in the card.
|
||||||
|
|
||||||
|
6. **The page keeps only what composes.** After the split `concepts.page.ts` holds its heading,
|
||||||
|
its intro, and six elements. It keeps no `styles:` block. `code` and `src` (the generated
|
||||||
|
snippets) move to whichever sections use them — each section imports
|
||||||
|
`snippets.generated.ts` directly.
|
||||||
|
|
||||||
|
7. **Widen the colour guard, and fix the one file that widening catches.**
|
||||||
|
`scripts/check-tokens.sh:14` greps `--include='*.component.ts'`, so **every `*.page.ts`,
|
||||||
|
`*.section.ts` and `*.step.ts` in the repository is invisible to it** — including the six
|
||||||
|
sections this ticket creates and the six `*.step.ts` files RD-22 and RD-23 just added. That
|
||||||
|
is why this page accumulated 21 hardcoded colours unnoticed.
|
||||||
|
|
||||||
|
Change the include to `--include='*.ts'` and exclude specs and stories, which legitimately
|
||||||
|
show colour swatches:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hits=$(grep -rnE '#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(' apps libs --include='*.ts' \
|
||||||
|
| grep -vE '\.(spec|stories)\.ts:' | grep -v 'token-ok' || true)
|
||||||
|
```
|
||||||
|
|
||||||
|
Measured: this newly catches exactly one other line, `libs/beheer/src/ui/audit.page.ts:39`
|
||||||
|
(`var(--rhc-color-rood-600, #a30000)`). Fix it by dropping the fallback, as decision 8 does
|
||||||
|
for this page. **Leave the CIBG-GAP marker check at `*.component.ts`** — a gap extension is a
|
||||||
|
component concept (ADR-0003).
|
||||||
|
|
||||||
|
8. **Drop every `var(--rhc-…, #hex)` fallback.** All the referenced tokens are defined in the
|
||||||
|
bridge, so the fallback is dead weight that also trips the widened guard. `.card`'s
|
||||||
|
`background: #fff` becomes `var(--rhc-color-wit)` — verified: that token is defined in
|
||||||
|
`styles.scss`.
|
||||||
|
|
||||||
|
9. **No stories.** `showcase` is a teaching page, not a feature, and it has no story today.
|
||||||
|
Adding six is not this ticket's job.
|
||||||
|
|
||||||
|
10. **Delete `/* eslint-disable max-lines */` from the page.** Mandatory — the rules pin each
|
||||||
|
other in both directions.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `libs/shared/styles.scss` — 5 tokens, 4 globals
|
||||||
|
- `scripts/check-tokens.sh` — one line (decision 7)
|
||||||
|
- `libs/beheer/src/ui/audit.page.ts` — one fallback (decision 7)
|
||||||
|
- `apps/ssp/src/app/showcase/concept-card.component.ts` (new)
|
||||||
|
- `apps/ssp/src/app/showcase/{unions,remote-data,parse,form-machine,vragenlijst,pii}.section.ts` (new)
|
||||||
|
- `apps/ssp/src/app/showcase/concepts.page.ts`
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Add the tokens and the four globals to `libs/shared/styles.scss` (decisions 2 and 3).
|
||||||
|
2. Write `concept-card.component.ts` (decision 4).
|
||||||
|
3. Move each `<section>` into its own file, replacing every `<div class="card …">` with
|
||||||
|
`<app-concept-card>`, `class="lead|cols|note"` with the `.app-*` names, and `<pre>` with
|
||||||
|
either the card's `code` input or `<pre class="app-code">` for the four dynamic result blocks.
|
||||||
|
4. Reduce the page to composition, with no `styles:` block.
|
||||||
|
5. Widen the guard and fix `audit.page.ts` (decision 7).
|
||||||
|
6. Delete the disable (decision 10).
|
||||||
|
7. `git add -A`, then run the acceptance commands.
|
||||||
|
8. Update this ticket's `Status:` to `done` and the README's RD-24 row to `done`.
|
||||||
|
9. Commit all of it together.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
Measured against the tree before handover. Run after `git add -A`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
D=apps/ssp/src/app/showcase
|
||||||
|
git ls-files "$D/*.section.ts" | wc -l # is 0 -> MUST be 6
|
||||||
|
git ls-files "$D/concept-card.component.ts" | wc -l # is 0 -> MUST be 1
|
||||||
|
git grep -c "eslint-disable max-lines" -- $D/concepts.page.ts # is 1 -> MUST be 0
|
||||||
|
git grep -c "styles:" -- $D/concepts.page.ts # is 1 -> MUST be 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The colours left the page, and the guard now covers it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -cE "#[0-9a-fA-F]{3,6}" -- $D/concepts.page.ts # is 21 -> MUST be 0
|
||||||
|
git grep -c "include='\*\.component\.ts'" -- scripts/check-tokens.sh # is 2 -> MUST be 1 (the CIBG-GAP check keeps it)
|
||||||
|
npm run check:tokens # exits 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The highlighting rules are global, where innerHTML children can reach them (decision 1):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -c "app-code" -- libs/shared/styles.scss # MUST be >= 4
|
||||||
|
git grep -c "app-code" -- $D/concepts.page.ts # MUST be 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The teaching content did not change while being moved:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -ho "code\['[a-zA-Z]*'\]" -- $D/ | sort -u | wc -l # is 9 -> MUST still be 9
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run ci --full # exits 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
**`--full` is required** — this edits `libs/shared/styles.scss`, which every story renders
|
||||||
|
against.
|
||||||
|
|
||||||
|
**Look at the page.** This is the one ticket in the arc whose main fix is invisible to every
|
||||||
|
automated check: no test asserts a computed colour. Run `npm start`, open `/concepts`, and
|
||||||
|
confirm that keywords, strings and comments in the code blocks are now coloured — purple, green
|
||||||
|
and grey-italic against the dark background. If they are still monochrome, the rules are still
|
||||||
|
scoped to a component.
|
||||||
|
|
||||||
|
**Do not add a line-count command.** `npm run lint` is the exact check.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Changing any teaching copy, snippet or demo. This is a move, not a rewrite.
|
||||||
|
- `highlight-ts.ts` itself. Its output is correct; only the CSS was unreachable.
|
||||||
|
- Adding stories (decision 9).
|
||||||
|
- The `--app-devpanel-*` tokens, and any other page's colours.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Angular does not style projected or `[innerHTML]` content from the receiving component.**
|
||||||
|
This is the constraint that shapes decisions 1, 3 and 4. If you find yourself moving a rule
|
||||||
|
into a component and its markup comes from somewhere else, the rule belongs in the global
|
||||||
|
sheet.
|
||||||
|
- **The `.app-note` case is subtle**: a note's text contains `<code>` markup, so it must be
|
||||||
|
projected — which is exactly why it cannot be styled by the card. Global it is.
|
||||||
|
- **The four dynamic `<pre>` blocks** (the ok/err demo output in sections 3 and 6) are not code
|
||||||
|
snippets and have no `src`. Give them `class="app-code"` directly rather than forcing them
|
||||||
|
through the card's `code` input.
|
||||||
|
- **Widening the guard is a two-line change with a measured blast radius of one other file**
|
||||||
|
(decision 7). If it catches more than `audit.page.ts:39`, stop and report — something landed
|
||||||
|
since this ticket was written.
|
||||||
|
- **Deleting the disable is mandatory** (decision 10).
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# RD-25 — Split `org-template-editor` by output cluster
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Source: PLAN.md 3f, order step 7
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`org-template-editor.component.ts` measures ~330 effective lines against a limit of 250, and
|
||||||
|
carries `/* eslint-disable max-lines */`. Three separate things pad it:
|
||||||
|
|
||||||
|
- a 44-line sample letter constant, exported but used only in this file;
|
||||||
|
- 13 label `input()`s that are all declared `protected`, so nothing can ever bind them — they
|
||||||
|
are constants wearing input ceremony;
|
||||||
|
- two self-contained blocks, the logo uploader and the version history.
|
||||||
|
|
||||||
|
The **11 `output()`s are the tell**: each cluster is a mutation family, and two of them lift out
|
||||||
|
whole.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `org-template-editor.component.ts:27` — `SAMPLE_LETTER_BRIEF`, and line 284, its only use.
|
||||||
|
- `org-template-editor.component.ts:272-297` — the inputs and the 11 outputs.
|
||||||
|
- `org-template-editor.component.ts:337-353` — the 13 label inputs.
|
||||||
|
- `org-template-editor.component.ts:195-235` — the logo block and the history block, the two
|
||||||
|
that become children.
|
||||||
|
- `.dependency-cruiser.base.js:113-120` — `no-testing-in-production`. Decision 1 depends on it.
|
||||||
|
- PLAN.md 3e — the `$localize` boundary, which decision 2 applies.
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
1. **`SAMPLE_LETTER_BRIEF` moves to `apps/ssp/src/app/brief/domain/sample-letter.ts`.** It is
|
||||||
|
production content — the letter the admin previews — not a test fixture.
|
||||||
|
|
||||||
|
**It must not go into `brief.testing.ts`, and nothing may import it from there.**
|
||||||
|
`.dependency-cruiser.base.js`'s `no-testing-in-production` rule forbids production code
|
||||||
|
reaching any `*.testing.ts`, so putting it there fails `npm run dep:check`. A new
|
||||||
|
`domain/sample-letter.ts` beside `brief.ts` is the right home; `domain/` is pure TS, and this
|
||||||
|
is data.
|
||||||
|
|
||||||
|
2. **11 of the 13 label inputs become inline `i18n` attributes in the template. Two stay.**
|
||||||
|
|
||||||
|
The two that stay are parameterised, and PLAN 3e explains why moving them breaks the build:
|
||||||
|
|
||||||
|
| Keep in TS | Id | Why |
|
||||||
|
| --------------- | ----------------------- | -------------------------------------------- |
|
||||||
|
| `marginsLegend` | `@@orgTemplate.margins` | interpolates `MARGIN_MIN_MM`/`MARGIN_MAX_MM` |
|
||||||
|
| `invalidHint` | `@@orgTemplate.invalid` | same two interpolations |
|
||||||
|
|
||||||
|
The `.xlf` stores an interpolation as `<x id="min" equiv-text="MARGIN_MIN_MM"/>`. Moving such
|
||||||
|
a message into a template renames the placeholder to `INTERPOLATION`, the translation merge
|
||||||
|
no longer matches, and `ng build --localize` fails.
|
||||||
|
|
||||||
|
**Every id is preserved.** A plain `protected foo = input($localize`:@@id:Text`)` used as
|
||||||
|
`{{ foo() }}` becomes the literal text in the template with an `i18n="@@id"` attribute, or
|
||||||
|
`i18n-label="@@id"` when it feeds an attribute. Neither `messages.en.xlf` may change.
|
||||||
|
|
||||||
|
3. **Two new children, each taking one output cluster:**
|
||||||
|
|
||||||
|
| File | Class | Inputs | Outputs |
|
||||||
|
| ------------------------------ | ---------------- | ----------------------------------------- | ------------------------------------------ |
|
||||||
|
| `logo-upload.component.ts` | `LogoUpload` | `logoUrl`, `uploadState`, `previewUrlFor` | `logoSelected`, `logoRemoved`, `logoRetry` |
|
||||||
|
| `version-history.component.ts` | `VersionHistory` | `history`, `publishedVersion` | `rollback` |
|
||||||
|
|
||||||
|
They live beside the parent, in `apps/ssp/src/app/brief/ui/org-template-editor/`.
|
||||||
|
|
||||||
|
4. **Seven of the eleven outputs are raised by the parent's own markup; four are re-emitted
|
||||||
|
from a child.** The parent's own: `selectSubOrg`, `templateEdit`, `marginEdit`,
|
||||||
|
`requestPublish`, `confirmPublish`, `cancelPublish`, `proefbrief`. The publish trio stays
|
||||||
|
with the parent because publishing acts on the whole draft, not on the version list; only
|
||||||
|
`rollback` is history's own verb.
|
||||||
|
|
||||||
|
**Corrected while the ticket ran. This decision first read "the parent keeps seven outputs",
|
||||||
|
and its acceptance line demanded seven `output()` declarations — which contradicts decision
|
||||||
|
5 in the same block.** Seven is how many outputs the parent _raises itself_. All eleven are
|
||||||
|
still _declared_ on the parent, because a child's output is re-emitted, not removed:
|
||||||
|
`org-template.page.ts` binds all eleven directly, and that file is out of scope. PLAN's
|
||||||
|
"→ 5" was a pre-measurement estimate of the same classification; its ~222-line estimate is
|
||||||
|
the part that matches (the split lands at 229).
|
||||||
|
|
||||||
|
5. **The parent's public surface does not change.** All 11 outputs still exist on the parent and
|
||||||
|
still fire; two clusters are simply re-emitted from children. `org-template.page.ts` and the
|
||||||
|
story bind exactly what they bind today.
|
||||||
|
|
||||||
|
6. **Delete `/* eslint-disable max-lines */`.** Mandatory — `reportUnusedDisableDirectives` is
|
||||||
|
`error`, so the two rules pin each other in both directions.
|
||||||
|
|
||||||
|
7. **No new stories.** `org-template-editor.stories.ts` already renders both blocks through the
|
||||||
|
parent. The 13 label inputs were `protected`, so no story could bind them and none does.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `apps/ssp/src/app/brief/domain/sample-letter.ts` (new)
|
||||||
|
- `apps/ssp/src/app/brief/ui/org-template-editor/logo-upload.component.ts` (new)
|
||||||
|
- `apps/ssp/src/app/brief/ui/org-template-editor/version-history.component.ts` (new)
|
||||||
|
- `apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts`
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Move `SAMPLE_LETTER_BRIEF` to `domain/sample-letter.ts` and import it in the parent
|
||||||
|
(decision 1).
|
||||||
|
2. Inline the 11 plain labels, keeping every id (decision 2).
|
||||||
|
3. Extract `logo-upload.component.ts`, then `version-history.component.ts` (decision 3), wiring
|
||||||
|
each child's outputs to the parent's existing ones.
|
||||||
|
4. Delete the disable (decision 6).
|
||||||
|
5. `git add -A`, then run the acceptance commands.
|
||||||
|
6. Update this ticket's `Status:` to `done` and the README's RD-25 row to `done`.
|
||||||
|
7. Commit all of it together.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
Measured against the tree before handover. Run after `git add -A`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
P=apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts
|
||||||
|
git ls-files 'apps/ssp/src/app/brief/ui/org-template-editor/*.component.ts' | wc -l # is 1 -> MUST be 3
|
||||||
|
git ls-files apps/ssp/src/app/brief/domain/sample-letter.ts | wc -l # is 0 -> MUST be 1
|
||||||
|
git grep -c "eslint-disable max-lines" -- $P # is 1 -> MUST be 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The constant moved, and the parent still uses it (import line plus use line is two lines, so 2
|
||||||
|
is the correct number here):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -c "export const SAMPLE_LETTER_BRIEF" -- apps/ssp/src/app/brief/domain/sample-letter.ts # MUST be 1
|
||||||
|
git grep -c "export const SAMPLE_LETTER_BRIEF" -- $P # is 1 -> MUST be 0
|
||||||
|
git grep -c "SAMPLE_LETTER_BRIEF" -- $P # is 2 -> MUST still be 2
|
||||||
|
```
|
||||||
|
|
||||||
|
The ceremony is gone and the clusters left (decisions 2, 3, 4):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -c "protected .* = input(" -- $P # is 13 -> MUST be 2
|
||||||
|
git grep -c "= output" -- $P # is 11 -> MUST still be 11 (decision 5, see below)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correction found while executing this ticket.** This check originally read `MUST be 7`,
|
||||||
|
copying decision 4's output count. That count is decision 4's classification of which cluster
|
||||||
|
owns each output, not the count of `output()` declarations on the parent class. Decision 5 and
|
||||||
|
this ticket's own Risks section both require the parent to keep declaring all 11 — a child's
|
||||||
|
output is re-emitted, not removed, and `org-template.page.ts` (out of scope, Files list excludes
|
||||||
|
it) binds all 11 directly on `<app-org-template-editor>`. Removing 4 declarations would break
|
||||||
|
that binding. Verified: `npx ng build ssp --localize` and `npm run dep:check` both pass with all
|
||||||
|
11 outputs present, and no other acceptance number changes.
|
||||||
|
|
||||||
|
The translation seam did not move (decision 2):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -ho "@@orgTemplate[a-zA-Z0-9_.]*" -- apps/ssp/src/app/brief/ui/org-template-editor/ | sort -u | wc -l # is 18 -> MUST still be 18
|
||||||
|
git status --short -- '*.xlf' | wc -l # MUST be 0
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run ci --full # exits 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
`ng build --localize` inside the gate is the real check on decision 2: a renamed placeholder or
|
||||||
|
a lost id fails it. The `.xlf` files are hand-maintained, so **if you find yourself editing one,
|
||||||
|
you have changed an id and should undo it instead**.
|
||||||
|
|
||||||
|
`npm run dep:check` inside the gate is the real check on decision 1.
|
||||||
|
|
||||||
|
**Do not add a line-count command.** `npm run lint` is the exact check (decision 6).
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- `letter-canvas`. RD-26 owns it, and it keeps its disable.
|
||||||
|
- Changing any label text, any id, or any output name.
|
||||||
|
- The `orgTemplate.publish.impact` message. It is not one of the 13 labels and does not move.
|
||||||
|
- Reworking the upload controller or the publish flow.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Moving a parameterised `$localize` into a template breaks the build** (decision 2). The two
|
||||||
|
named messages stay in TS. If a third turns out to interpolate, leave it in TS too and say so
|
||||||
|
in the commit message.
|
||||||
|
- **`brief.testing.ts` is the wrong home for the sample letter** (decision 1), and the failure
|
||||||
|
is a dependency-cruiser error rather than a type error, so it will not show up until
|
||||||
|
`dep:check`.
|
||||||
|
- **The parent must keep all 11 outputs** (decision 5). A child's output is re-emitted, not
|
||||||
|
removed — `org-template.page.ts` binds them.
|
||||||
|
- **Deleting the disable is mandatory** (decision 6).
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# RD-26 — `letter-canvas`: inline the labels, extract `letter-line`, keep the disable
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Source: PLAN.md 3d, order step 8
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`letter-canvas.component.ts` is 418 effective lines against a limit of 250. Unlike the other
|
||||||
|
six offenders, **it is not badly structured** — 77 lines are CSS and the rest is one letter.
|
||||||
|
Splitting it into letterhead, body, signature and footer would make "what does the letter look
|
||||||
|
like" a five-file question and buy no behavioural seam.
|
||||||
|
|
||||||
|
Two things do pad it without earning their place:
|
||||||
|
|
||||||
|
- **20 of its 28 `input()`s are pure `$localize` labels, and no caller binds a single one.**
|
||||||
|
Verified against all three call sites: `behandel-scherm`, `letter-composer` and
|
||||||
|
`org-template-editor` bind only data (`brief`, `orgTemplate`, `logoUrl`, `editableRegions`,
|
||||||
|
`diagnostics`, `blockDiffs`, `showDiff`).
|
||||||
|
- Six lines of `ngTemplateOutlet` ceremony around one `#line` template.
|
||||||
|
|
||||||
|
The CLAUDE.md rule those labels were built for — "Shared/English components must not hardcode
|
||||||
|
Dutch — expose copy as `input()`s" — governs `libs/shared`. This is a Dutch domain component in
|
||||||
|
`brief/ui/`. The rule does not apply here.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `letter-canvas.component.ts:1` — the disable, whose reason this ticket rewrites.
|
||||||
|
- `letter-canvas.component.ts:140` — the `#line` template, and lines 255-276, its three
|
||||||
|
`ngTemplateOutlet` uses.
|
||||||
|
- `letter-canvas.component.ts:345-372` — the inputs: 8 data, 20 labels.
|
||||||
|
- PLAN.md 3d and 3e.
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
1. **Inline 19 of the 20 label inputs as `i18n` in the template.** Same id, same source text, so
|
||||||
|
**neither `messages.en.xlf` changes**. A label read as `{{ foo() }}` becomes its literal text
|
||||||
|
plus `i18n="@@id"`; one feeding an attribute becomes `i18n-<attr>="@@id"` (`logoAlt` is the
|
||||||
|
`alt` case).
|
||||||
|
|
||||||
|
The template is inline in this same file, so the 20 `@@brief.canvas.*` ids stay in the file —
|
||||||
|
they move from a TS declaration into an `i18n` attribute. The id count does not change.
|
||||||
|
|
||||||
|
2. **`recipientText` stays in TS. It is the one exception, and it is not the parameterised case
|
||||||
|
RD-25 hit.** Its message embeds a newline escape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
$localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`;
|
||||||
|
```
|
||||||
|
|
||||||
|
In TS that `\n` is one character of the message. Written as template text it becomes a source
|
||||||
|
line break, which Angular's extractor treats differently — so "same source text", the
|
||||||
|
property that makes decision 1 free, does not hold for this one. Leave it as an `input()` and
|
||||||
|
put a one-line comment on it saying why. Verified: no other label interpolates or escapes
|
||||||
|
anything.
|
||||||
|
|
||||||
|
3. **Do not collapse the labels into a config object or an injection token.**
|
||||||
|
`HEADER_NAV_ITEMS` and `DEBUG_PANEL` exist because two apps genuinely differ. Here nothing
|
||||||
|
differs, so a token would add a provider and an indirection to solve a problem nobody has.
|
||||||
|
|
||||||
|
4. **Extract `letter-line.component.ts` beside the canvas, with a spec.** It takes the `#line`
|
||||||
|
template plus the sample and diff helpers it needs, and replaces the three `ngTemplateOutlet`
|
||||||
|
incantations with three one-line tags. It is the only part of this file with logic worth
|
||||||
|
testing, so it gets a `*.spec.ts` — a pure spec over the helpers, no TestBed.
|
||||||
|
|
||||||
|
Drop the `NgTemplateOutlet` import from the canvas once the last outlet is gone.
|
||||||
|
|
||||||
|
5. **Keep `/* eslint-disable max-lines */`, and rewrite its reason.** This is the one ticket in
|
||||||
|
the arc that keeps a disable. After RD-21 through RD-25 removed theirs, **this is the only
|
||||||
|
one left in the repository** — verified. The new reason must state the honest fact rather
|
||||||
|
than promising a future removal:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/* eslint-disable max-lines */ // 77 lines of CSS + one letter's markup; splitting it into
|
||||||
|
// letterhead/body/signature/footer makes "what does the letter look like" a five-file
|
||||||
|
// question for no behavioural seam. Deliberate, not deferred.
|
||||||
|
```
|
||||||
|
|
||||||
|
The README requires a disable to name the ticket that removes it. This one names no ticket
|
||||||
|
**because none will**, and the reason says so in those words. `reportUnusedDisableDirectives`
|
||||||
|
still keeps it honest: if the file ever drops under 250, lint fails on the unused directive.
|
||||||
|
|
||||||
|
6. **No new stories.** `letter-canvas.stories.ts` renders the canvas, and `letter-line` is
|
||||||
|
exercised through it.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `apps/ssp/src/app/brief/ui/letter-canvas/letter-line.component.ts` (new)
|
||||||
|
- `apps/ssp/src/app/brief/ui/letter-canvas/letter-line.spec.ts` (new)
|
||||||
|
- `apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts`
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Extract `letter-line.component.ts` and its spec (decision 4).
|
||||||
|
2. Replace the three `ngTemplateOutlet` uses and drop the `NgTemplateOutlet` import.
|
||||||
|
3. Inline the 19 labels (decision 1), leaving `recipientText` alone (decision 2).
|
||||||
|
4. Rewrite the disable's reason (decision 5). Do not delete the directive.
|
||||||
|
5. Run `npm run gen:behaviour-spec` — the new spec adds titles.
|
||||||
|
6. `git add -A`, then run the acceptance commands.
|
||||||
|
7. Update this ticket's `Status:` to `done` and the README's RD-26 row to `done`.
|
||||||
|
8. Commit all of it together.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
Measured against the tree before handover. Run after `git add -A`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
P=apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts
|
||||||
|
git grep -c "= input" -- $P # is 28 -> MUST be 9 (8 data + recipientText)
|
||||||
|
grep -c 'localize' $P # is 20 -> MUST be 1 (recipientText only)
|
||||||
|
```
|
||||||
|
|
||||||
|
The ids stayed in the file and the translation seam did not move (decision 1):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -o "@@[a-zA-Z0-9_.]*" $P | sort -u | wc -l # is 20 -> MUST still be 20
|
||||||
|
git status --short -- '*.xlf' | wc -l # MUST be 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The outlet ceremony is gone and the child exists (decision 4):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
D=apps/ssp/src/app/brief/ui/letter-canvas
|
||||||
|
git grep -c "ngTemplateOutlet" -- $P # is 6 -> MUST be 0
|
||||||
|
git grep -c "NgTemplateOutlet" -- $P # is 2 -> MUST be 0
|
||||||
|
git ls-files $D/letter-line.component.ts $D/letter-line.spec.ts | wc -l # is 0 -> MUST be 2
|
||||||
|
```
|
||||||
|
|
||||||
|
The disable survives, with a new reason, and is the only one in the repository (decision 5):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -c "eslint-disable max-lines" -- $P # is 1 -> MUST still be 1
|
||||||
|
git grep -c "eslint-disable max-lines" -- apps libs | wc -l # MUST be 1 (one file matches)
|
||||||
|
git grep -c "RD-26 rewrites this reason" -- $P # is 1 -> MUST be 0
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run ci --full # exits 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
**`npm run lint` is what proves decision 5 both ways.** If the extraction takes the file under
|
||||||
|
250 lines, the kept directive becomes unused and lint fails — which would mean the disable
|
||||||
|
should go after all. Report that rather than deleting it silently: it changes the arc's
|
||||||
|
conclusion that one file legitimately stays over budget.
|
||||||
|
|
||||||
|
`ng build --localize` inside the gate is the check on decisions 1 and 2. **If you find yourself
|
||||||
|
editing a `.xlf`, you have changed an id or a source string — undo it instead.**
|
||||||
|
|
||||||
|
**`--full` is required** (the Order table says so): `letter-canvas.stories.ts` renders this
|
||||||
|
component, and the axe pass over it is what proves the inlined `i18n` markup kept its `alt`
|
||||||
|
text and its labels.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Splitting the letter into region components. PLAN 3d rejects it explicitly.
|
||||||
|
- The 77 lines of CSS.
|
||||||
|
- `recipientText` (decision 2).
|
||||||
|
- A config object or token for the labels (decision 3).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **`recipientText`'s `\n` is the trap** (decision 2). Inlining it is the one change here that
|
||||||
|
can silently alter an extracted source string, and the `.xlf` files are hand-maintained.
|
||||||
|
- **Keep the directive** (decision 5). Every other ticket in Phase 3 deleted one; this ticket is
|
||||||
|
the exception, and deleting it here would fail `max-lines` instead.
|
||||||
|
- **A label bound by no caller is still a public input.** Removing it is safe only because all
|
||||||
|
three call sites were checked. Do not extend the same reasoning to the 8 data inputs.
|
||||||
|
- **`logoAlt` feeds an attribute**, so it needs `i18n-alt`, not `i18n`. An `i18n` attribute on
|
||||||
|
the element localises its content, not its `alt`, and the a11y check will not catch the
|
||||||
|
difference because the text is still present.
|
||||||
@@ -115,12 +115,12 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
|
|||||||
| RD-18 | Ticket-reference sweep, frontend — 181 refs, 100 files | 01 | yes | done |
|
| RD-18 | Ticket-reference sweep, frontend — 181 refs, 100 files | 01 | yes | done |
|
||||||
| RD-19 | Ticket-reference sweep, backend — 370 refs, 86 files | 01 | | done |
|
| RD-19 | Ticket-reference sweep, backend — 370 refs, 86 files | 01 | | done |
|
||||||
| RD-20 | `wizard-errors.ts` + spec, adopted by all 3 wizards | 02 | | done |
|
| RD-20 | `wizard-errors.ts` + spec, adopted by all 3 wizards | 02 | | done |
|
||||||
| RD-21 | `rich-text-dom.ts` helpers + spec cases | 02 | yes | todo |
|
| RD-21 | `rich-text-dom.ts` helpers + spec cases | 02 | yes | done |
|
||||||
| RD-22 | `intake-wizard` to 3 step components | 08, 20 | yes | todo |
|
| RD-22 | `intake-wizard` to 3 step components | 08, 20 | yes | done |
|
||||||
| RD-23 | `registratie-wizard` to 3 steps + the upload-controller move | 08, 20 | yes | todo |
|
| RD-23 | `registratie-wizard` to 3 steps + the upload-controller move | 08, 20 | yes | done |
|
||||||
| RD-24 | `concepts.page` to 6 sections + `concept-card` + globals + code tokens | 02 | yes | todo |
|
| RD-24 | `concepts.page` to 6 sections + `concept-card` + globals + code tokens | 02 | yes | done |
|
||||||
| RD-25 | `org-template-editor` to `sample-letter.ts` + labels + 2 children | 02 | yes | todo |
|
| RD-25 | `org-template-editor` to `sample-letter.ts` + labels + 2 children | 02 | yes | done |
|
||||||
| RD-26 | `letter-canvas`: inline the labels + `letter-line`; keep one disable | 02 | yes | todo |
|
| RD-26 | `letter-canvas`: inline the labels + `letter-line`; keep one disable | 02 | yes | done |
|
||||||
| RD-27 | **The layer move:** 33 `git mv` + 28 specifiers + 8 MDX imports | 21 | yes | todo |
|
| RD-27 | **The layer move:** 33 `git mv` + 28 specifiers + 8 MDX imports | 21 | yes | todo |
|
||||||
| RD-28 | Layer-tag fixes + the `libs/beheer` title rule | 27 | | todo |
|
| RD-28 | Layer-tag fixes + the `libs/beheer` title rule | 27 | | todo |
|
||||||
| RD-29 | The 3 atomic-ladder rules in dependency-cruiser | 27 | | todo |
|
| RD-29 | The 3 atomic-ladder rules in dependency-cruiser | 27 | | todo |
|
||||||
@@ -130,12 +130,18 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
|
|||||||
| RD-33 | CLAUDE.md + `atomic-design.mdx` + the `ui-component` skill | 03, 27, 29 | yes | todo |
|
| RD-33 | CLAUDE.md + `atomic-design.mdx` + the `ui-component` skill | 03, 27, 29 | yes | todo |
|
||||||
| RD-34 | _(optional)_ `NO_SUBORGS`/`NO_TABLES` become `RemoteData.Empty` | 11 | | todo |
|
| RD-34 | _(optional)_ `NO_SUBORGS`/`NO_TABLES` become `RemoteData.Empty` | 11 | | todo |
|
||||||
| RD-35 | _(optional, last, alone)_ upload `type:` discriminant to `tag:` | 27 | | todo |
|
| RD-35 | _(optional, last, alone)_ upload `type:` discriminant to `tag:` | 27 | | todo |
|
||||||
|
| RD-36 | `ui/dashboard/` → `ui/overzicht-secties/` + 2 stale `dashboard.page` paths | 04 | yes | todo |
|
||||||
|
|
||||||
The ID order already respects every dependency, so it is the recommended running order.
|
The ID order already respects every dependency, so it is the recommended running order.
|
||||||
|
|
||||||
**Independent tickets.** RD-15 through RD-19 depend only on RD-01. Pull them forward to fill
|
**Independent tickets.** RD-15 through RD-19 depend only on RD-01. Pull them forward to fill
|
||||||
a short session. Take RD-15 early: it makes every later repository search faster.
|
a short session. Take RD-15 early: it makes every later repository search faster.
|
||||||
|
|
||||||
|
**RD-36 is last by number, not by dependency.** It needs only RD-04, it is 8 `git mv`s plus
|
||||||
|
four import lines, and it collides with nothing else in the table — RD-27's move is confined to
|
||||||
|
`libs/shared/src/ui/`. Pull it forward into any short session. It is numbered last only because
|
||||||
|
it was added after RD-04 shipped.
|
||||||
|
|
||||||
**Two ordering traps the table encodes.** RD-01 must precede RD-30, because RD-01 copies its
|
**Two ordering traps the table encodes.** RD-01 must precede RD-30, because RD-01 copies its
|
||||||
ticket template out of the directory that RD-30 archives. And four tickets edit the same two
|
ticket template out of the directory that RD-30 archives. And four tickets edit the same two
|
||||||
documents in different sections — RD-09 rewrites the submit-idiom teaching, while RD-31 and
|
documents in different sections — RD-09 rewrites the submit-idiom teaching, while RD-31 and
|
||||||
@@ -187,7 +193,22 @@ Three rules when you write a ticket file, because the agent reads its ticket and
|
|||||||
`uploadCtl.onRetry` exists in `upload-controller.ts`.
|
`uploadCtl.onRetry` exists in `upload-controller.ts`.
|
||||||
- RD-08 said "no machine changes" while also requiring a repo-wide grep to come back
|
- RD-08 said "no machine changes" while also requiring a repo-wide grep to come back
|
||||||
clean, which forced comment edits in three machines. The two instructions contradicted
|
clean, which forced comment edits in three machines. The two instructions contradicted
|
||||||
each other.
|
each other. RD-23 repeated it exactly: its Decisions block mandated the line
|
||||||
|
`dispatch: (msg) => this.uploadMsg.emit(msg)` — the upload controller's own property name —
|
||||||
|
while its acceptance demanded zero occurrences of `dispatch` in the step files. **Grep the
|
||||||
|
text your own mandated snippet contains, and you have written a check that cannot pass.**
|
||||||
|
Anchor on what you actually forbid: here, `this.dispatch` or `store.dispatch`, not the bare
|
||||||
|
word. RD-25 made it a third time, and the clearest one: its decision 4 said "the parent
|
||||||
|
keeps seven outputs" while decision 5, four lines below, said all eleven still exist and
|
||||||
|
are re-emitted from children. Seven was the count of outputs the parent _raises_; eleven is
|
||||||
|
the count it _declares_. The acceptance line copied the wrong one, and satisfying it would
|
||||||
|
have broken `org-template.page.ts`, which binds all eleven.
|
||||||
|
|
||||||
|
**The pattern in all three: a decision describes a design in one vocabulary, and the
|
||||||
|
acceptance line counts something else that happens to share a word.** Before writing a
|
||||||
|
number, say out loud what the command counts — declarations, call sites, or matching lines
|
||||||
|
— and check that the decisions use that same meaning.
|
||||||
|
|
||||||
- RD-09 grepped `docs/ apps/ libs/ .claude/`, which also matched this backlog's own ticket
|
- RD-09 grepped `docs/ apps/ libs/ .claude/`, which also matched this backlog's own ticket
|
||||||
files (they name the deleted method as the history of `done` work) and 22 gitignored
|
files (they name the deleted method as the history of `done` work) and 22 gitignored
|
||||||
abandoned worktrees. Satisfying it literally would have corrupted completed-ticket
|
abandoned worktrees. Satisfying it literally would have corrupted completed-ticket
|
||||||
@@ -228,7 +249,10 @@ Three rules when you write a ticket file, because the agent reads its ticket and
|
|||||||
live on one line of a single-line type declaration, so the honest answer is `1`. The
|
live on one line of a single-line type declaration, so the honest answer is `1`. The
|
||||||
agent correctly refused to reformat the type across four lines to satisfy the number.
|
agent correctly refused to reformat the type across four lines to satisfy the number.
|
||||||
When you want occurrences, use `grep -o … | wc -l`; when a line count is what you mean,
|
When you want occurrences, use `grep -o … | wc -l`; when a line count is what you mean,
|
||||||
say so.
|
say so. **A symbol you import and then use is two lines, never one.** RD-23 repeated the
|
||||||
|
mistake in the other direction, asserting `git grep -c "createUploadController"` would be
|
||||||
|
1 in the file that both imports and calls it. The only way to reach 1 is an import alias
|
||||||
|
that exists solely to satisfy the check.
|
||||||
- **Scope every acceptance command to the ticket's Files list, never to a parent
|
- **Scope every acceptance command to the ticket's Files list, never to a parent
|
||||||
directory.** This is the habit most often broken, including by the supervisor in RD-12:
|
directory.** This is the habit most often broken, including by the supervisor in RD-12:
|
||||||
the check `git grep "ActionState" -- apps/ssp/src/app/brief` cannot pass, because
|
the check `git grep "ActionState" -- apps/ssp/src/app/brief` cannot pass, because
|
||||||
@@ -280,3 +304,18 @@ git log --oneline -1 # did an agent already commit the ticket?
|
|||||||
|
|
||||||
If the tree is dirty and this session did not dirty it, find the agent before you write a
|
If the tree is dirty and this session did not dirty it, find the agent before you write a
|
||||||
ticket around the evidence it leaves.
|
ticket around the evidence it leaves.
|
||||||
|
|
||||||
|
**The mirror-image mistake, made during RD-24: the supervisor became the second writer.** A
|
||||||
|
task notification fires every time an agent stops with **no live children — including a pause
|
||||||
|
mid-task**. RD-24's agent paused, said it was waiting on a check, and notified. The supervisor
|
||||||
|
read that as an abandoned task, took over the working tree, ran the gate and edited the ticket
|
||||||
|
`Status:` — while the agent was still running. The agent then resumed, correctly detected a
|
||||||
|
second process writing its tree, and refused to commit.
|
||||||
|
|
||||||
|
Nothing was lost, because the agent stopped instead of committing. Two rules follow:
|
||||||
|
|
||||||
|
- **A notification is not proof the work is finished.** A finished agent hands back a report
|
||||||
|
with acceptance numbers. "I am waiting for X" is a pause.
|
||||||
|
- **Before taking over an agent's tree, confirm the agent is gone**: `ps aux | grep "npm run"`,
|
||||||
|
and check whether its own result has arrived. Taking over is a real option — RD-24 was
|
||||||
|
finished that way — but it must be a decision, not an assumption.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { AuditStore } from '@beheer/application/audit.store';
|
|||||||
font-weight: var(--rhc-text-font-weight-semi-bold);
|
font-weight: var(--rhc-text-font-weight-semi-bold);
|
||||||
}
|
}
|
||||||
.deny {
|
.deny {
|
||||||
color: var(--rhc-color-rood-600, #a30000);
|
color: var(--rhc-color-rood-600);
|
||||||
font-weight: var(--rhc-text-font-weight-semi-bold);
|
font-weight: var(--rhc-text-font-weight-semi-bold);
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ tested where._
|
|||||||
|
|
||||||
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
|
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
|
||||||
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
|
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
|
||||||
**is** the suite, reshaped for a business reader. 538 frontend behaviours across
|
**is** the suite, reshaped for a business reader. 556 frontend behaviours across
|
||||||
9 contexts; 261 backend behaviours across 42 test
|
9 contexts; 261 backend behaviours across 42 test
|
||||||
classes.
|
classes.
|
||||||
|
|
||||||
@@ -337,6 +337,10 @@ classes.
|
|||||||
- preserves library order (= reading order)
|
- preserves library order (= reading order)
|
||||||
- never offers non-kern passages
|
- never offers non-kern passages
|
||||||
|
|
||||||
|
#### placeholderDefs
|
||||||
|
|
||||||
|
- keys the placeholder list by its key
|
||||||
|
|
||||||
#### proefbriefErrorMessage (TE-002 trust boundary)
|
#### proefbriefErrorMessage (TE-002 trust boundary)
|
||||||
|
|
||||||
- surfaces the ProblemDetails detail when present
|
- surfaces the ProblemDetails detail when present
|
||||||
@@ -348,6 +352,33 @@ classes.
|
|||||||
- derives reason checkboxes (code + label) from the negatief reason passages
|
- derives reason checkboxes (code + label) from the negatief reason passages
|
||||||
- positief has no reason-specific redenen
|
- positief has no reason-specific redenen
|
||||||
|
|
||||||
|
#### resolveAuto
|
||||||
|
|
||||||
|
- reads autoResolvable off the field
|
||||||
|
- defaults to false for an unknown key
|
||||||
|
|
||||||
|
#### resolveLabel
|
||||||
|
|
||||||
|
- returns the field label for a known key
|
||||||
|
- falls back to the bare key when the field is unknown
|
||||||
|
|
||||||
|
#### resolveSample
|
||||||
|
|
||||||
|
- prefers the canned sample value over the label
|
||||||
|
- resolves datum to the caller-supplied sample date
|
||||||
|
- falls back to the field label for anything else
|
||||||
|
|
||||||
|
#### resolveState
|
||||||
|
|
||||||
|
- defaults to ok when the key has no diagnostic
|
||||||
|
- surfaces the worst recorded severity
|
||||||
|
|
||||||
|
#### worstSeverities
|
||||||
|
|
||||||
|
- ignores a diagnostic with no placeholder key
|
||||||
|
- keeps error over a warning already recorded for the same key
|
||||||
|
- does not let a later warning downgrade an error
|
||||||
|
|
||||||
### herregistratie
|
### herregistratie
|
||||||
|
|
||||||
#### IntakeWizardComponent
|
#### IntakeWizardComponent
|
||||||
@@ -758,6 +789,14 @@ classes.
|
|||||||
- redirects an anonymous user to /login without waiting for caps
|
- redirects an anonymous user to /login without waiting for caps
|
||||||
- reads authentication through the port, not an app-local store
|
- reads authentication through the port, not an app-local store
|
||||||
|
|
||||||
|
#### chipAtCaret / insertChipAtCaret (selection surgery)
|
||||||
|
|
||||||
|
- chipAtCaret returns the chip before a Backspace caret
|
||||||
|
- chipAtCaret returns null for a non-collapsed selection
|
||||||
|
- chipAtCaret returns null for a caret outside root
|
||||||
|
- insertChipAtCaret splices at the caret and leaves the caret after the chip
|
||||||
|
- insertChipAtCaret appends when there is no selection inside root
|
||||||
|
|
||||||
#### createDebouncedSave
|
#### createDebouncedSave
|
||||||
|
|
||||||
- flushes after the delay when canSave is true
|
- flushes after the delay when canSave is true
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||||
import { adjacentChip, readBlock, renderInto } from './rich-text-dom';
|
import {
|
||||||
|
adjacentChip,
|
||||||
|
chipAtCaret,
|
||||||
|
insertChipAtCaret,
|
||||||
|
readBlock,
|
||||||
|
renderInto,
|
||||||
|
} from './rich-text-dom';
|
||||||
|
|
||||||
const labelFor = (key: string) => ({ naam: 'Naam', datum: 'Datum' })[key] ?? key;
|
const labelFor = (key: string) => ({ naam: 'Naam', datum: 'Datum' })[key] ?? key;
|
||||||
|
|
||||||
@@ -131,3 +137,97 @@ describe('rich-text DOM boundary', () => {
|
|||||||
expect((chips[1] as HTMLElement).dataset['auto']).toBe('false');
|
expect((chips[1] as HTMLElement).dataset['auto']).toBe('false');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('chipAtCaret / insertChipAtCaret (selection surgery)', () => {
|
||||||
|
// jsdom's Selection only accepts ranges over nodes attached to the live document,
|
||||||
|
// so every case here mounts its `root` under document.body and unmounts it after.
|
||||||
|
let mounted: HTMLElement[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const el of mounted) el.remove();
|
||||||
|
mounted = [];
|
||||||
|
document.getSelection()?.removeAllRanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mount(root: HTMLElement): HTMLElement {
|
||||||
|
document.body.appendChild(root);
|
||||||
|
mounted.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCaret(node: Node, offset: number): void {
|
||||||
|
const sel = document.getSelection();
|
||||||
|
if (!sel) throw new Error('no selection in this environment');
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(node, offset);
|
||||||
|
range.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeChip(): HTMLElement {
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.dataset['phKey'] = 'naam';
|
||||||
|
return chip;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('chipAtCaret returns the chip before a Backspace caret', () => {
|
||||||
|
const root = mount(document.createElement('div'));
|
||||||
|
const chip = makeChip();
|
||||||
|
const after = document.createTextNode('bb');
|
||||||
|
root.append(chip, after);
|
||||||
|
setCaret(after, 0);
|
||||||
|
expect(chipAtCaret(root, -1)).toBe(chip);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chipAtCaret returns null for a non-collapsed selection', () => {
|
||||||
|
const root = mount(document.createElement('div'));
|
||||||
|
const chip = makeChip();
|
||||||
|
const text = document.createTextNode('bb');
|
||||||
|
root.append(chip, text);
|
||||||
|
const sel = document.getSelection();
|
||||||
|
if (!sel) throw new Error('no selection in this environment');
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(text, 0);
|
||||||
|
range.setEnd(text, 1);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
expect(chipAtCaret(root, -1)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chipAtCaret returns null for a caret outside root', () => {
|
||||||
|
const root = mount(document.createElement('div'));
|
||||||
|
const other = mount(document.createElement('div'));
|
||||||
|
const text = document.createTextNode('bb');
|
||||||
|
other.appendChild(text);
|
||||||
|
setCaret(text, 0);
|
||||||
|
expect(chipAtCaret(root, -1)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('insertChipAtCaret splices at the caret and leaves the caret after the chip', () => {
|
||||||
|
const root = mount(document.createElement('div'));
|
||||||
|
const text = document.createTextNode('aabb');
|
||||||
|
root.appendChild(text);
|
||||||
|
setCaret(text, 2); // caret between "aa" and "bb"
|
||||||
|
const chip = makeChip();
|
||||||
|
insertChipAtCaret(root, chip);
|
||||||
|
expect(root.textContent).toBe('aabb');
|
||||||
|
expect(Array.from(root.childNodes)).toContain(chip);
|
||||||
|
const sel = document.getSelection();
|
||||||
|
if (!sel) throw new Error('no selection in this environment');
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
expect(range.collapsed).toBe(true);
|
||||||
|
expect(root.childNodes[range.startOffset - 1]).toBe(chip);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('insertChipAtCaret appends when there is no selection inside root', () => {
|
||||||
|
const root = mount(document.createElement('div'));
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.textContent = 'line';
|
||||||
|
root.appendChild(p);
|
||||||
|
document.getSelection()?.removeAllRanges();
|
||||||
|
const chip = makeChip();
|
||||||
|
insertChipAtCaret(root, chip);
|
||||||
|
expect(p.lastChild).toBe(chip);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -186,6 +186,38 @@ export function adjacentChip(
|
|||||||
return isChip(sibling) ? sibling : null;
|
return isChip(sibling) ? sibling : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chip a collapsed caret sits next to, or null. `direction` is -1 for
|
||||||
|
* Backspace and 1 for Delete. Returns null when the selection is absent, is a
|
||||||
|
* range rather than a caret, or sits outside `root`.
|
||||||
|
*/
|
||||||
|
export function chipAtCaret(root: HTMLElement, direction: -1 | 1): HTMLElement | null {
|
||||||
|
const sel = root.ownerDocument.getSelection();
|
||||||
|
if (!sel || !sel.isCollapsed || !sel.rangeCount) return null;
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
if (!root.contains(range.startContainer)) return null;
|
||||||
|
return adjacentChip(range.startContainer, range.startOffset, direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert `chip` at the caret when the selection is inside `root`, and leave the
|
||||||
|
* caret after it. With no usable selection, append to the last line instead.
|
||||||
|
*/
|
||||||
|
export function insertChipAtCaret(root: HTMLElement, chip: HTMLElement): void {
|
||||||
|
const sel = root.ownerDocument.getSelection();
|
||||||
|
if (sel && sel.rangeCount && root.contains(sel.anchorNode)) {
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(chip);
|
||||||
|
range.setStartAfter(chip);
|
||||||
|
range.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
} else {
|
||||||
|
(root.lastElementChild ?? root).appendChild(chip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function markOf(el: HTMLElement): Mark | null {
|
function markOf(el: HTMLElement): Mark | null {
|
||||||
switch (el.tagName) {
|
switch (el.tagName) {
|
||||||
case 'STRONG':
|
case 'STRONG':
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
/* eslint-disable max-lines */ // toolbar + contenteditable logic in one component — removed by RD-21
|
|
||||||
import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core';
|
import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core';
|
||||||
import { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text';
|
import { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text';
|
||||||
import { adjacentChip, createChip, readBlock, renderInto } from './rich-text-dom';
|
import { chipAtCaret, createChip, insertChipAtCaret, readBlock, renderInto } from './rich-text-dom';
|
||||||
|
|
||||||
/** A menu entry for the insert-placeholder control — a plain {key,label}, so the
|
/** A menu entry for the insert-placeholder control — a plain {key,label}, so the
|
||||||
editor stays domain-free (it never sees the brief's PlaceholderDef). */
|
editor stays domain-free (it never sees the brief's PlaceholderDef). */
|
||||||
@@ -257,15 +256,7 @@ export class RichTextEditorComponent {
|
|||||||
private deleteAdjacentChip(e: KeyboardEvent) {
|
private deleteAdjacentChip(e: KeyboardEvent) {
|
||||||
const el = this.editorEl()?.nativeElement;
|
const el = this.editorEl()?.nativeElement;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const sel = el.ownerDocument.getSelection();
|
const chip = chipAtCaret(el, e.key === 'Backspace' ? -1 : 1);
|
||||||
if (!sel || !sel.isCollapsed || !sel.rangeCount) return;
|
|
||||||
const range = sel.getRangeAt(0);
|
|
||||||
if (!el.contains(range.startContainer)) return;
|
|
||||||
const chip = adjacentChip(
|
|
||||||
range.startContainer,
|
|
||||||
range.startOffset,
|
|
||||||
e.key === 'Backspace' ? -1 : 1,
|
|
||||||
);
|
|
||||||
if (!chip) return;
|
if (!chip) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
chip.remove();
|
chip.remove();
|
||||||
@@ -276,19 +267,7 @@ export class RichTextEditorComponent {
|
|||||||
const el = this.editorEl()?.nativeElement;
|
const el = this.editorEl()?.nativeElement;
|
||||||
if (!key || !el) return;
|
if (!key || !el) return;
|
||||||
el.focus();
|
el.focus();
|
||||||
const chip = createChip(el.ownerDocument, key, this.labelFor(key), this.autoFor(key));
|
insertChipAtCaret(el, createChip(el.ownerDocument, key, this.labelFor(key), this.autoFor(key)));
|
||||||
const sel = el.ownerDocument.getSelection();
|
|
||||||
if (sel && sel.rangeCount && el.contains(sel.anchorNode)) {
|
|
||||||
const range = sel.getRangeAt(0);
|
|
||||||
range.deleteContents();
|
|
||||||
range.insertNode(chip);
|
|
||||||
range.setStartAfter(chip);
|
|
||||||
range.collapse(true);
|
|
||||||
sel.removeAllRanges();
|
|
||||||
sel.addRange(range);
|
|
||||||
} else {
|
|
||||||
(el.lastElementChild ?? el).appendChild(chip);
|
|
||||||
}
|
|
||||||
this.emit();
|
this.emit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ body {
|
|||||||
--app-devpanel-accent: #9cdcfe;
|
--app-devpanel-accent: #9cdcfe;
|
||||||
--app-devpanel-border: #444;
|
--app-devpanel-border: #444;
|
||||||
--app-devpanel-shadow: rgb(0 0 0 / 0.4);
|
--app-devpanel-shadow: rgb(0 0 0 / 0.4);
|
||||||
|
|
||||||
|
/* Showcase code-block palette (concepts.page.ts, RD-24): the same "exempt file"
|
||||||
|
reasoning as --app-devpanel-* above. A dark code-editor palette, kept off the CIBG
|
||||||
|
design system, for the teaching page's highlighted TS snippets. */
|
||||||
|
--app-code-bg: #1e2430;
|
||||||
|
--app-code-fg: #e6e9ef;
|
||||||
|
--app-code-keyword: #c792ea;
|
||||||
|
--app-code-string: #c3e88d;
|
||||||
|
--app-code-comment: #7e8aa0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* App utility classes: centralise the repeated inline layout idioms so components stay
|
/* App utility classes: centralise the repeated inline layout idioms so components stay
|
||||||
@@ -127,6 +136,49 @@ body {
|
|||||||
.app-text-subtle {
|
.app-text-subtle {
|
||||||
color: var(--rhc-color-foreground-subtle);
|
color: var(--rhc-color-foreground-subtle);
|
||||||
}
|
}
|
||||||
|
/* Highlighted code block (concepts.page.ts, RD-24). Global because the `.k`/`.s`/`.c`
|
||||||
|
keyword/string/comment spans arrive through `[innerHTML]` — they never carry the
|
||||||
|
rendering component's `_ngcontent` attribute, so a component-scoped rule can never
|
||||||
|
match them. This is why the highlighting never rendered before RD-24. */
|
||||||
|
.app-code {
|
||||||
|
background: var(--app-code-bg);
|
||||||
|
color: var(--app-code-fg);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.app-code .k {
|
||||||
|
color: var(--app-code-keyword);
|
||||||
|
}
|
||||||
|
.app-code .s {
|
||||||
|
color: var(--app-code-string);
|
||||||
|
}
|
||||||
|
.app-code .c {
|
||||||
|
color: var(--app-code-comment);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
/* Page-level typography idioms shared by every showcase section (concepts.page.ts). */
|
||||||
|
.app-lead {
|
||||||
|
color: var(--rhc-color-grijs-700);
|
||||||
|
max-width: 46rem;
|
||||||
|
margin: 0.25rem 0 1.25rem;
|
||||||
|
}
|
||||||
|
.app-cols {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
/* A note whose content includes markup (e.g. `<code>`) must be projected into its
|
||||||
|
card, so it keeps the DECLARING component's scope, not the card's — global it is. */
|
||||||
|
.app-note {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--rhc-color-grijs-700);
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Route transitions (withViewTransitions): cross-fade the routed CONTENT only.
|
/* Route transitions (withViewTransitions): cross-fade the routed CONTENT only.
|
||||||
The chrome gets its own stable view-transition-name so it's lifted out of the
|
The chrome gets its own stable view-transition-name so it's lifted out of the
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# WP-02 token guard: fail if any *.component.ts hardcodes a colour (hex/rgb/hsl)
|
# WP-02 token guard: fail if any *.ts file hardcodes a colour (hex/rgb/hsl) instead of
|
||||||
# instead of a --rhc-*/--app-* design token. Palette values live ONLY in the
|
# a --rhc-*/--app-* design token. Specs and stories are exempt — they legitimately show
|
||||||
# styles.scss token bridge (the one exempt file — it IS the bridge).
|
# colour swatches. Palette values live ONLY in the styles.scss token bridge (the one
|
||||||
|
# exempt file — it IS the bridge). Widened from *.component.ts to *.ts in RD-24: a
|
||||||
|
# *.page.ts, *.section.ts or *.step.ts hardcoding a colour was invisible before that.
|
||||||
#
|
#
|
||||||
# px/rem are deliberately NOT grepped: too many false positives (font sizes,
|
# px/rem are deliberately NOT grepped: too many false positives (font sizes,
|
||||||
# transforms, media queries). Raw border widths are fixed by hand and mapped to
|
# transforms, media queries). Raw border widths are fixed by hand and mapped to
|
||||||
@@ -11,7 +13,8 @@
|
|||||||
# false positive (a colour word inside a comment or a data-URI). Keep the bar high.
|
# false positive (a colour word inside a comment or a data-URI). Keep the bar high.
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
hits=$(grep -rnE '#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(' apps libs --include='*.component.ts' | grep -v 'token-ok' || true)
|
hits=$(grep -rnE '#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(' apps libs --include='*.ts' \
|
||||||
|
| grep -vE '\.(spec|stories)\.ts:' | grep -v 'token-ok' || true)
|
||||||
if [ -n "$hits" ]; then
|
if [ -n "$hits" ]; then
|
||||||
echo "$hits"
|
echo "$hits"
|
||||||
echo 'FAIL: hardcoded colours in components (use --rhc-*/--app-* tokens, or add a `token-ok` marker + reason)'
|
echo 'FAIL: hardcoded colours in components (use --rhc-*/--app-* tokens, or add a `token-ok` marker + reason)'
|
||||||
|
|||||||
Reference in New Issue
Block a user