Upload feature (e): wire inline upload (registratie beroep) + documenten step (herregistratie)

- Fold UploadState into both wizard machines; route via { tag: 'Upload', msg }
- Gate step validation on requiredCategoriesSatisfied; include deliveryRefs in submit
- Shared createUploadController (effectful glue: categories, transport, focus-poll, File map)
- rejectReason pure format validator + specs; bump registratie storage key to v2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 08:38:37 +02:00
parent 9521739ac1
commit bfd957a6d4
13 changed files with 341 additions and 66 deletions

View File

@@ -10,6 +10,9 @@ 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';
import { ApiClient } from '@shared/infrastructure/api-client';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/upload/upload-controller';
import { UploadState, initialUpload } from '@shared/upload/upload.machine';
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
@@ -19,7 +22,7 @@ import { ApiClient } from '@shared/infrastructure/api-client';
the dashboard shows "in behandeling" immediately. */
@Component({
selector: 'app-herregistratie-wizard',
imports: [FormsModule, FormFieldComponent, TextInputComponent, AlertComponent, WizardShellComponent],
imports: [FormsModule, FormFieldComponent, TextInputComponent, AlertComponent, WizardShellComponent, DocumentUploadComponent],
template: `
<app-wizard-shell
[steps]="stepLabels"
@@ -27,7 +30,7 @@ import { ApiClient } from '@shared/infrastructure/api-client';
[stepTitle]="stepTitle()"
[status]="shellStatus()"
[primaryLabel]="primaryLabel()"
[canGoBack]="step() === 2"
[canGoBack]="step() > 1"
[errors]="errorList()"
[errorMessage]="errorMessage()"
(primary)="onPrimary()"
@@ -35,20 +38,35 @@ import { ApiClient } from '@shared/infrastructure/api-client';
(cancel)="restart()"
(retry)="onRetry()">
@if (step() === 1) {
<app-form-field i18n-label="@@herregWizard.urenLabel" label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" required [error]="errUren()">
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
name="uren" [invalid]="!!errUren()" i18n-placeholder="@@herregWizard.urenPlaceholder" placeholder="bijv. 4160" />
</app-form-field>
<app-form-field i18n-label="@@herregWizard.jarenLabel" label="Aantal jaren werkzaam" fieldId="jaren" required [error]="errJaren()">
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
name="jaren" [invalid]="!!errJaren()" i18n-placeholder="@@herregWizard.jarenPlaceholder" placeholder="bijv. 5" />
</app-form-field>
} @else {
<app-form-field i18n-label="@@herregWizard.puntenLabel" label="Behaalde nascholingspunten" fieldId="punten" required [error]="errPunten()">
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
name="punten" [invalid]="!!errPunten()" i18n-placeholder="@@herregWizard.puntenPlaceholder" placeholder="bijv. 200" />
</app-form-field>
@switch (step()) {
@case (1) {
<app-form-field i18n-label="@@herregWizard.urenLabel" label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" required [error]="errUren()">
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
name="uren" [invalid]="!!errUren()" i18n-placeholder="@@herregWizard.urenPlaceholder" placeholder="bijv. 4160" />
</app-form-field>
<app-form-field i18n-label="@@herregWizard.jarenLabel" label="Aantal jaren werkzaam" fieldId="jaren" required [error]="errJaren()">
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
name="jaren" [invalid]="!!errJaren()" i18n-placeholder="@@herregWizard.jarenPlaceholder" placeholder="bijv. 5" />
</app-form-field>
}
@case (2) {
<app-form-field i18n-label="@@herregWizard.puntenLabel" label="Behaalde nascholingspunten" fieldId="punten" required [error]="errPunten()">
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
name="punten" [invalid]="!!errPunten()" i18n-placeholder="@@herregWizard.puntenPlaceholder" placeholder="bijv. 200" />
</app-form-field>
}
@case (3) {
<app-document-upload
[state]="upload()"
(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 (errDocumenten()) {
<app-alert type="warning">{{ errDocumenten() }}</app-alert>
}
}
}
<div wizardSuccess>
@@ -69,20 +87,27 @@ export class HerregistratieWizardComponent {
protected dispatch = this.store.dispatch;
// Stepper labels + per-step heading titles (presentational only).
readonly stepLabels = [$localize`:@@herregWizard.step.werkervaring:Werkervaring`, $localize`:@@herregWizard.step.nascholing:Nascholing`];
private stepTitles = [$localize`:@@herregWizard.title.werkervaring:Werkervaring (afgelopen 5 jaar)`, $localize`:@@herregWizard.title.nascholing:Nascholing`];
readonly stepLabels = [$localize`:@@herregWizard.step.werkervaring:Werkervaring`, $localize`:@@herregWizard.step.nascholing:Nascholing`, $localize`:@@herregWizard.step.documenten:Documenten`];
private stepTitles = [$localize`:@@herregWizard.title.werkervaring:Werkervaring (afgelopen 5 jaar)`, $localize`:@@herregWizard.title.nascholing:Nascholing`, $localize`:@@herregWizard.title.documenten:Documenten aanleveren`];
private editing = computed(() => whenTag(this.state(), 'Editing'));
protected step = computed(() => this.editing()?.step ?? 1);
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });
protected upload = computed<UploadState>(() => this.editing()?.upload ?? initialUpload);
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? '');
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
protected uploadCtl = createUploadController({
wizardId: 'herregistratie',
getUpload: () => this.upload(),
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
});
// --- Presentational wiring for the shared wizard shell ---------------------
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
protected primaryLabel = computed(() => (this.step() === 1 ? $localize`:@@wizard.volgende:Volgende` : $localize`:@@herregWizard.indienen:Herregistratie aanvragen`));
protected primaryLabel = computed(() => (this.step() < 3 ? $localize`:@@wizard.volgende:Volgende` : $localize`:@@herregWizard.indienen:Herregistratie aanvragen`));
protected errorMessage = computed(() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`);
protected shellStatus = computed<WizardStatus>(() => {
switch (this.state().tag) {
@@ -107,7 +132,7 @@ export class HerregistratieWizardComponent {
onPrimary() {
const s = this.state();
if (s.tag !== 'Editing') return;
this.dispatch(s.step === 1 ? { tag: 'Next' } : { tag: 'Submit' });
this.dispatch(s.step < 3 ? { tag: 'Next' } : { tag: 'Submit' });
this.runIfSubmitting();
}