Add branching intake wizard (derived steps + radio-group atom)

A second wizard demonstrating a BRANCHING flow: the visible steps are derived
from the answers by a pure `visibleSteps` function rather than stored, so
answering "buiten Nederland gewerkt? -> ja" or reporting few hours adds steps
and the progress denominator changes live. Same Elm-style store + RemoteData
patterns as the fixed wizard; answers persist to localStorage.

- intake.machine.ts: IntakeState union + Answers + visibleSteps + pure reduce (+spec)
- intake-wizard organism, intake.page, submit-intake command
- new radio-group atom (ControlValueAccessor) in shared/ui
- /intake route + dashboard link + concepts showcase section
- tighten Aantekening.type to a 'Specialisme' | 'Aantekening' union
- README + ARCHITECTURE updated

Verified live end-to-end (branches add steps 4->5->6, review, submit) with no
console errors; build, unit tests, and Storybook all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 09:38:26 +02:00
parent 164d20a10d
commit 7463efdc2d
14 changed files with 960 additions and 85 deletions

View File

@@ -3,12 +3,12 @@ import { FormsModule } from '@angular/forms';
import type { Resource } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
import { Registration } from '@registratie/domain/registration';
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
@@ -22,92 +22,151 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
@Component({
selector: 'app-concepts-page',
imports: [
FormsModule, PageShellComponent, HeadingComponent, AlertComponent, TextInputComponent,
...ASYNC, SkeletonComponent, RegistrationSummaryComponent, HerregistratieWizardComponent,
FormsModule, PageShellComponent, HeadingComponent, TextInputComponent,
...ASYNC, SkeletonComponent, RegistrationSummaryComponent, HerregistratieWizardComponent, IntakeWizardComponent,
],
styles: [`
.cols { display:flex; flex-wrap:wrap; gap:1.5rem; margin:1rem 0 2.5rem }
.col { flex:1 1 20rem; min-width:18rem }
.tag { font-weight:700; font-size:0.8rem; text-transform:uppercase; letter-spacing:0.04em }
.bad { color:var(--rhc-color-rood-500) }
.good { color:var(--rhc-color-groen-500) }
pre { background:var(--rhc-color-grijs-100,#f3f3f3); padding:1rem; border-radius:4px; overflow:auto; font-size:0.85rem }
.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 .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 }
.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: `
<app-page-shell heading="Onmogelijke toestanden onmogelijk maken" backLink="/dashboard">
<app-alert type="info">
Vier functionele patronen die met atomic design makkelijker te tonen zijn — telkens "fout"
(de oude vorm liet het toe) naast "goed" (het type maakt het onmogelijk).
</app-alert>
<p class="lead">
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).
</p>
<!-- 1. Discriminated unions -->
<app-heading [level]="2">1 · Discriminated unions</app-heading>
<div class="cols">
<div class="col">
<p class="tag bad">Fout — vlakke interface</p>
<pre>{{ unionBad }}</pre>
<p class="rhc-paragraph">Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.</p>
<section class="section">
<app-heading [level]="2">1 · Discriminated unions</app-heading>
<p class="lead">Laat elke variant precies de gegevens dragen die kloppen — niets meer.</p>
<div class="cols">
<div class="card card--bad">
<p class="tag bad">Fout — vlakke interface</p>
<pre [innerHTML]="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>
<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>
<div class="col">
<p class="tag good">Goed — sum type</p>
<app-registration-summary [reg]="doorgehaald" />
<p class="rhc-paragraph">De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat simpelweg niet.</p>
</div>
</div>
</section>
<!-- 2. RemoteData fold -->
<app-heading [level]="2">2 · RemoteData fold</app-heading>
<div class="cols">
<div class="col">
<p class="tag good">Eén molecuul, vier elkaar uitsluitende toestanden</p>
<p class="tag">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">Empty</p>
<app-async [resource]="emptyRes" [isEmpty]="isEmpty"><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async>
<p class="tag">Failure</p>
<app-async [resource]="errorRes"><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async>
<p class="tag">Success</p>
<app-async [resource]="successRes" [isEmpty]="isEmpty"><ng-template appAsyncLoaded let-v><ul class="rhc-unordered-list">@for (i of v; track i) {<li>{{ i }}</li>}</ul></ng-template></app-async>
<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 let-v><ul class="rhc-unordered-list">@for (i of v; track i) {<li>{{ i }}</li>}</ul></ng-template></app-async>
</div>
<div class="card card--good">
<p class="tag good">De exhaustieve fold</p>
<pre [innerHTML]="foldCode"></pre>
<p class="note">Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem afhandelt.</p>
</div>
</div>
<div class="col">
<p class="tag good">De exhaustieve fold</p>
<pre>{{ foldCode }}</pre>
<p class="rhc-paragraph">Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem afhandelt.</p>
</div>
</div>
</section>
<!-- 3. Parse, don't validate -->
<app-heading [level]="2">3 · Parse, don't validate</app-heading>
<div class="cols">
<div class="col">
<p class="tag good">Smart constructor → Result</p>
<app-text-input inputId="pc" [ngModel]="raw()" (ngModelChange)="raw.set($event)" name="pc" placeholder="Typ een postcode, bijv. 1234 AB" />
<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>
<app-text-input inputId="pc" [ngModel]="raw()" (ngModelChange)="raw.set($event)" name="pc" placeholder="Typ een postcode, bijv. 1234 AB" />
</div>
<div class="card" [class.card--good]="parsed().ok" [class.card--bad]="!parsed().ok">
@if (parsed().ok) {
<p class="tag good">ok</p>
<pre>Postcode = "{{ $any(parsed()).value }}"</pre>
<p class="note">Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.</p>
} @else {
<p class="tag bad">err</p>
<pre>{{ $any(parsed()).error }}</pre>
}
</div>
</div>
<div class="col">
@if (parsed().ok) {
<p class="tag good">ok</p>
<pre>Postcode = "{{ parsed().ok ? $any(parsed()).value : '' }}"</pre>
<p class="rhc-paragraph">Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.</p>
} @else {
<p class="tag bad">err</p>
<pre>{{ $any(parsed()).error }}</pre>
}
</div>
</div>
</section>
<!-- 4. State machine / wizard -->
<app-heading [level]="2">4 · Form als state machine</app-heading>
<div class="cols">
<div class="col">
<p class="tag bad">Fout — losse booleans</p>
<pre>{{ machineBad }}</pre>
<p class="rhc-paragraph">Niets verhindert "submitting" mét validatiefouten of een successcherm met errors.</p>
<!-- 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]="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>
<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>
<div class="col">
<p class="tag good">Goed — één tagged union stuurt de UI</p>
<app-herregistratie-wizard />
</section>
<!-- 5. Branching: steps derived from answers -->
<section class="section">
<app-heading [level]="2">5 · Vertakkende vragenlijst — "afleiden, niet opslaan"</app-heading>
<p class="lead">
Welke stappen bestaan is geen opgeslagen toestand maar een <em>pure functie</em> van de antwoorden
(<code>visibleSteps(answers)</code>). Antwoord "ja" op buitenland of vul weinig uren in, en de
lijst hieronder groeit mee.
</p>
<div class="cols">
<div class="card card--good">
<p class="tag good">Live afgeleide stappen</p>
<div class="steplist">
@for (s of iw.steps(); track s; let last = $last) {
<span class="pill" [class.extra]="s === 'buitenlandDetails' || s === 'scholing'">{{ s }}</span>
@if (!last) { <span class="arrow">→</span> }
}
</div>
<p class="note">De gestippelde stappen verschijnen alleen door eerdere antwoorden. De voortgang "van N" verandert mee.</p>
</div>
<div class="card card--good">
<p class="tag good">De wizard</p>
<app-intake-wizard #iw />
</div>
</div>
</div>
</section>
</app-page-shell>
`,
})
@@ -128,20 +187,20 @@ export class ConceptsPage {
raw = signal('');
parsed = computed(() => parsePostcode(this.raw()));
unionBad = `interface Registration {
status: 'Geregistreerd' | 'Doorgehaald';
herregistratieDatum: string; // altijd aanwezig 😬
unionBad = `<span class="k">interface</span> Registration {
status: <span class="s">'Geregistreerd'</span> | <span class="s">'Doorgehaald'</span>;
herregistratieDatum: string; <span class="c">// altijd aanwezig 😬</span>
}`;
foldCode = `foldRemote(rd, {
foldCode = `<span class="k">foldRemote</span>(rd, {
loading: () => spinner,
empty: () => 'geen data',
empty: () => <span class="s">'geen data'</span>,
failure: (e) => alert(e),
success: (v) => render(v),
}); // mist er één → compile-fout`;
}); <span class="c">// mist er één → compile-fout</span>`;
machineBad = `submitting = signal(false);
submitted = signal(false);
errors = signal<...>({});
// submitting === true && errors.size > 0 ? 🤷`;
machineBad = `submitting = <span class="k">signal</span>(false);
submitted = <span class="k">signal</span>(false);
errors = <span class="k">signal</span>&lt;...&gt;({});
<span class="c">// submitting === true && errors.size > 0 ? 🤷</span>`;
}