diff --git a/src/app/organisms/herregistratie-wizard/herregistratie-wizard.component.ts b/src/app/organisms/herregistratie-wizard/herregistratie-wizard.component.ts
new file mode 100644
index 0000000..37e6991
--- /dev/null
+++ b/src/app/organisms/herregistratie-wizard/herregistratie-wizard.component.ts
@@ -0,0 +1,98 @@
+import { Component, computed, input, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { FormFieldComponent } from '../../molecules/form-field/form-field.component';
+import { TextInputComponent } from '../../atoms/text-input/text-input.component';
+import { ButtonComponent } from '../../atoms/button/button.component';
+import { AlertComponent } from '../../atoms/alert/alert.component';
+import { SpinnerComponent } from '../../atoms/spinner/spinner.component';
+import { ok } from '../../core/fp';
+import { WizardState, Draft, initial, next, back, submit, resolve } from './wizard.machine';
+
+/** Organism: multi-step herregistratie wizard driven entirely by a state
+ machine (wizard.machine.ts). The signal IS the union, so the UI just folds
+ over its tag — no booleans like `submitting`/`submitted` that could contradict
+ each other. Composition-only: reuses existing form-field/input/button/alert. */
+@Component({
+ selector: 'app-herregistratie-wizard',
+ imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, AlertComponent, SpinnerComponent],
+ template: `
+ @switch (state().tag) {
+ @case ('Editing') {
+
Stap {{ step() }} van 2
+
+ }
+ @case ('Submitting') {
+ Aanvraag wordt verwerkt…
+ }
+ @case ('Submitted') {
+ Uw aanvraag tot herregistratie is ontvangen.
+ }
+ @case ('Failed') {
+ Indienen mislukt. Probeer het opnieuw.
+
+ }
+ }
+ `,
+})
+export class HerregistratieWizardComponent {
+ /** Optional seed so Storybook / the showcase can mount any state directly. */
+ seed = input(initial);
+ state = signal(initial);
+
+ back = back;
+
+ private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract) : null));
+ protected step = computed(() => this.editing()?.step ?? 1);
+ protected draft = computed(() => this.editing()?.draft ?? { uren: '', punten: '' });
+ protected errUren = computed(() => this.editing()?.errors.uren ?? '');
+ protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
+
+ constructor() {
+ queueMicrotask(() => this.state.set(this.seed()));
+ }
+
+ set(key: keyof Draft, value: string) {
+ const s = this.state();
+ if (s.tag !== 'Editing') return;
+ this.state.set({ ...s, draft: { ...s.draft, [key]: value } });
+ }
+
+ onPrimary() {
+ const s = this.state();
+ if (s.tag !== 'Editing') return;
+ this.state.set(s.step === 1 ? next(s) : submit(s));
+ this.runIfSubmitting();
+ }
+
+ onRetry() {
+ const s = this.state();
+ if (s.tag !== 'Failed') return;
+ this.state.set({ tag: 'Submitting', data: s.data });
+ this.runIfSubmitting();
+ }
+
+ // ponytail: fake the backend with a timer; swap for a real httpResource call later.
+ private runIfSubmitting() {
+ if (this.state().tag !== 'Submitting') return;
+ setTimeout(() => this.state.set(resolve(this.state(), ok(undefined))), 800);
+ }
+}
diff --git a/src/app/organisms/herregistratie-wizard/herregistratie-wizard.stories.ts b/src/app/organisms/herregistratie-wizard/herregistratie-wizard.stories.ts
new file mode 100644
index 0000000..dcb3af1
--- /dev/null
+++ b/src/app/organisms/herregistratie-wizard/herregistratie-wizard.stories.ts
@@ -0,0 +1,23 @@
+import type { Meta, StoryObj } from '@storybook/angular';
+import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
+import { WizardState } from './wizard.machine';
+import { Uren } from '../../core/parse';
+
+const validData = { uren: 4160 as Uren, punten: 200 };
+
+const meta: Meta = {
+ title: 'Organisms/Herregistratie Wizard',
+ component: HerregistratieWizardComponent,
+};
+export default meta;
+type Story = StoryObj;
+
+// Each story seeds one state of the machine — one render per union variant.
+export const Step1: Story = { args: { seed: { tag: 'Editing', step: 1, draft: { uren: '', punten: '' }, errors: {} } } };
+export const Step1Error: Story = {
+ args: { seed: { tag: 'Editing', step: 1, draft: { uren: 'abc', punten: '' }, errors: { uren: 'Vul een geheel aantal uren in (0 of meer).' } } satisfies WizardState },
+};
+export const Step2: Story = { args: { seed: { tag: 'Editing', step: 2, draft: { uren: '4160', punten: '' }, errors: {} } } };
+export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
+export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
+export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } };
diff --git a/src/app/organisms/herregistratie-wizard/wizard.machine.spec.ts b/src/app/organisms/herregistratie-wizard/wizard.machine.spec.ts
new file mode 100644
index 0000000..767516d
--- /dev/null
+++ b/src/app/organisms/herregistratie-wizard/wizard.machine.spec.ts
@@ -0,0 +1,32 @@
+import { describe, it, expect } from 'vitest';
+import { ok, err } from '../../core/fp';
+import { initial, next, back, submit, resolve, WizardState } from './wizard.machine';
+
+const editing1 = (uren: string, punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, punten }, errors: {} });
+const editing2 = (uren: string, punten: string): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, punten }, errors: {} });
+
+describe('wizard.machine', () => {
+ it('next advances only when step 1 parses', () => {
+ expect(next(initial).tag).toBe('Editing'); // empty uren -> stays, with error
+ expect((next(initial) as any).errors.uren).toBeTruthy();
+ expect((next(editing1('4160')) as any).step).toBe(2);
+ });
+
+ it('submit reaches Submitting ONLY with fully valid data', () => {
+ expect(submit(editing2('4160', 'x')).tag).toBe('Editing'); // invalid punten -> no Submitting
+ const good = submit(editing2('4160', '200'));
+ expect(good.tag).toBe('Submitting');
+ expect((good as any).data).toEqual({ uren: 4160, punten: 200 });
+ });
+
+ it('back / resolve are no-ops from illegal states', () => {
+ expect(back(initial)).toBe(initial); // step 1, nothing to go back to
+ expect(resolve(initial, ok(undefined))).toBe(initial); // not Submitting
+ });
+
+ it('resolve maps Submitting to Submitted / Failed', () => {
+ const submitting = submit(editing2('4160', '200'));
+ expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
+ expect(resolve(submitting, err('boom')).tag).toBe('Failed');
+ });
+});
diff --git a/src/app/organisms/herregistratie-wizard/wizard.machine.ts b/src/app/organisms/herregistratie-wizard/wizard.machine.ts
new file mode 100644
index 0000000..5da2e47
--- /dev/null
+++ b/src/app/organisms/herregistratie-wizard/wizard.machine.ts
@@ -0,0 +1,66 @@
+import { Result } from '../../core/fp';
+import { Uren, parseUren } from '../../core/parse';
+
+/** What the user is typing (raw, possibly invalid). */
+export interface Draft {
+ uren: string;
+ punten: string;
+}
+
+/** What we have AFTER parsing — branded/typed, guaranteed valid. */
+export interface Valid {
+ uren: Uren;
+ punten: number;
+}
+
+/**
+ * The whole wizard as one tagged union. `step` and `errors` exist ONLY while
+ * Editing; Submitting/Submitted/Failed carry a `Valid` payload and nothing else.
+ * So "submitting while a field is invalid" or "showing the success screen with
+ * errors set" are unrepresentable — the bug class is gone by construction.
+ */
+export type WizardState =
+ | { tag: 'Editing'; step: 1 | 2; draft: Draft; errors: Partial> }
+ | { tag: 'Submitting'; data: Valid }
+ | { tag: 'Submitted'; data: Valid }
+ | { tag: 'Failed'; data: Valid; error: string };
+
+export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', punten: '' }, errors: {} };
+
+/** Parse every field; on success hand back a Valid, else the per-field errors. */
+function validate(draft: Draft): Result>, Valid> {
+ const uren = parseUren(draft.uren);
+ const punten = parseUren(draft.punten);
+ const errors: Partial> = {};
+ if (!uren.ok) errors.uren = uren.error;
+ if (!punten.ok) errors.punten = punten.error;
+ if (uren.ok && punten.ok) return { ok: true, value: { uren: uren.value, punten: punten.value } };
+ return { ok: false, error: errors };
+}
+
+/** Step 1 → 2: only advance if the uren field parses. Illegal elsewhere = no-op. */
+export function next(s: WizardState): WizardState {
+ if (s.tag !== 'Editing' || s.step !== 1) return s;
+ const uren = parseUren(s.draft.uren);
+ return uren.ok
+ ? { ...s, step: 2, errors: {} }
+ : { ...s, errors: { uren: uren.error } };
+}
+
+export function back(s: WizardState): WizardState {
+ if (s.tag !== 'Editing' || s.step !== 2) return s;
+ return { ...s, step: 1, errors: {} };
+}
+
+/** Step 2 submit: parse everything; move to Submitting only with Valid data. */
+export function submit(s: WizardState): WizardState {
+ if (s.tag !== 'Editing' || s.step !== 2) return s;
+ const result = validate(s.draft);
+ return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
+}
+
+/** Resolve the async submit. Only meaningful while Submitting. */
+export function resolve(s: WizardState, r: Result): WizardState {
+ if (s.tag !== 'Submitting') return s;
+ return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error };
+}
diff --git a/src/app/pages/herregistratie/herregistratie.page.ts b/src/app/pages/herregistratie/herregistratie.page.ts
index b3e047b..a43d17a 100644
--- a/src/app/pages/herregistratie/herregistratie.page.ts
+++ b/src/app/pages/herregistratie/herregistratie.page.ts
@@ -1,54 +1,22 @@
-import { Component, signal } from '@angular/core';
-import { FormsModule } from '@angular/forms';
+import { Component } from '@angular/core';
import { PageShellComponent } from '../../templates/page-shell/page-shell.component';
import { AlertComponent } from '../../atoms/alert/alert.component';
-import { ButtonComponent } from '../../atoms/button/button.component';
-import { FormFieldComponent } from '../../molecules/form-field/form-field.component';
-import { TextInputComponent } from '../../atoms/text-input/text-input.component';
+import { HerregistratieWizardComponent } from '../../organisms/herregistratie-wizard/herregistratie-wizard.component';
/** A whole new page built from existing building blocks — no new components.
This is the atomic-design payoff: a new flow is just composition. */
@Component({
selector: 'app-herregistratie-page',
- imports: [
- FormsModule, PageShellComponent, AlertComponent,
- ButtonComponent, FormFieldComponent, TextInputComponent,
- ],
+ imports: [PageShellComponent, AlertComponent, HerregistratieWizardComponent],
template: `
Uw huidige registratie verloopt op 1 september 2027. Vraag tijdig herregistratie aan.
-
- @if (submitted()) {
-
-
Uw aanvraag tot herregistratie is ontvangen.
-
- } @else {
-
- }
+
`,
})
-export class HerregistratiePage {
- uren = '';
- punten = '';
- urenError = signal('');
- submitted = signal(false);
-
- onSubmit() {
- if (!this.uren.trim()) { this.urenError.set('Vul het aantal gewerkte uren in.'); return; }
- this.urenError.set('');
- this.submitted.set(true);
- }
-}
+export class HerregistratiePage {}