Step 1 was a single field, making the wizard feel thin. Add "Aantal jaren werkzaam" beside "Gewerkte uren" on the same step (no new step): Draft/Valid gain `jaren`, `next` validates both step-1 fields before advancing, and `validate` parses it for the submitted payload. Verified live: an empty jaren blocks advancing with an inline error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
114 lines
5.6 KiB
TypeScript
114 lines
5.6 KiB
TypeScript
import { Component, computed, inject, input } 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 { ButtonComponent } from '@shared/ui/button/button.component';
|
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
|
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
|
import { createStore } from '@shared/application/store';
|
|
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
|
import { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine';
|
|
import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie';
|
|
|
|
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
|
|
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
|
|
Elm-style store. The UI just sends messages and folds over the state's tag —
|
|
no booleans like `submitting`/`submitted` that could contradict each other.
|
|
Submitting also flips an optimistic flag on the shared BigProfileStore, so
|
|
the dashboard shows "in behandeling" immediately. */
|
|
@Component({
|
|
selector: 'app-herregistratie-wizard',
|
|
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, AlertComponent, SpinnerComponent],
|
|
template: `
|
|
@switch (state().tag) {
|
|
@case ('Editing') {
|
|
<p class="rhc-paragraph" style="color:var(--rhc-color-grijs-700)">Stap {{ step() }} van 2</p>
|
|
<form (ngSubmit)="onPrimary()" style="max-width:28rem">
|
|
@if (step() === 1) {
|
|
<app-form-field label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" [error]="errUren()">
|
|
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
|
|
name="uren" [invalid]="!!errUren()" placeholder="bijv. 4160" />
|
|
</app-form-field>
|
|
<app-form-field label="Aantal jaren werkzaam" fieldId="jaren" [error]="errJaren()">
|
|
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
|
|
name="jaren" [invalid]="!!errJaren()" placeholder="bijv. 5" />
|
|
</app-form-field>
|
|
<app-button type="submit" variant="primary">Volgende</app-button>
|
|
} @else {
|
|
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="errPunten()">
|
|
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
|
|
name="punten" [invalid]="!!errPunten()" placeholder="bijv. 200" />
|
|
</app-form-field>
|
|
<div style="display:flex;gap:0.5rem">
|
|
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
|
|
<app-button type="submit" variant="primary">Herregistratie aanvragen</app-button>
|
|
</div>
|
|
}
|
|
</form>
|
|
}
|
|
@case ('Submitting') {
|
|
<app-spinner /> <span>Aanvraag wordt verwerkt…</span>
|
|
}
|
|
@case ('Submitted') {
|
|
<app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
|
|
}
|
|
@case ('Failed') {
|
|
<app-alert type="error">Indienen mislukt: {{ failedError() }}</app-alert>
|
|
<div style="margin-top:1rem">
|
|
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
|
|
</div>
|
|
}
|
|
}
|
|
`,
|
|
})
|
|
export class HerregistratieWizardComponent {
|
|
private profile = inject(BigProfileStore);
|
|
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
|
|
|
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
|
seed = input<WizardState>(initial);
|
|
|
|
readonly state = this.store.model; // public so the showcase can highlight the live state
|
|
protected dispatch = this.store.dispatch;
|
|
|
|
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<WizardState, { tag: 'Editing' }>) : null));
|
|
protected step = computed(() => this.editing()?.step ?? 1);
|
|
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });
|
|
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
|
|
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
|
|
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
|
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<WizardState, { tag: 'Failed' }>).error : ''));
|
|
|
|
constructor() {
|
|
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
|
|
}
|
|
|
|
onPrimary() {
|
|
const s = this.state();
|
|
if (s.tag !== 'Editing') return;
|
|
this.dispatch(s.step === 1 ? { tag: 'Next' } : { tag: 'Submit' });
|
|
this.runIfSubmitting();
|
|
}
|
|
|
|
onRetry() {
|
|
this.dispatch({ tag: 'Retry' });
|
|
this.runIfSubmitting();
|
|
}
|
|
|
|
/** The effect: when we entered Submitting, call the backend command, flip the
|
|
optimistic cross-page flag, then dispatch the result (and commit/rollback). */
|
|
private async runIfSubmitting() {
|
|
const s = this.state();
|
|
if (s.tag !== 'Submitting') return;
|
|
this.profile.beginHerregistratie();
|
|
const r = await submitHerregistratie(s.data);
|
|
if (r.ok) {
|
|
this.dispatch({ tag: 'SubmitConfirmed' });
|
|
this.profile.confirmHerregistratie();
|
|
} else {
|
|
this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
|
this.profile.rollbackHerregistratie();
|
|
}
|
|
}
|
|
}
|