Mijn aanvragen (E): two-flow submit through the aanvraag + all-wizard persistence

All three wizards now submit through the backend aanvraag lifecycle, so a
submitted Concept actually transitions (dashboard shows it correctly in F).

- blockActions(status) (domain + spec): the pure per-status action decision
  (Concept → resume/cancel; In behandeling → viewDocuments; resolved → none).
- createDraftSync.submit(): ensure the Concept exists, then
  POST /applications/{id}/submit; folded into a Result like the old commands.
- registratie: submit via draftSync (duo → auto, handmatig → manual pending — the
  old 422 path is gone from the wizard).
- intake + herregistratie: adopt createDraftSync (persistence + resume-by-link);
  intake retires sessionStorage `intake-v3`; herregistratie gains persistence.
  Both submit through the aanvraag too. hasProgress added to each machine (+spec).
- Delete now-dead submit-registratie/submit-intake/submit-herregistratie commands.

Deferred: the old /registrations, /intakes, /herregistraties backend endpoints +
RejectRegistratie are now unused by the FE but still present (+ tested) — retiring
them cascades into backend test rewrites, so it's a focused follow-up cleanup.
Gates green: vitest 128, lint, build; backend unchanged (dotnet 56).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 14:25:02 +02:00
parent 6db7f1e673
commit 9f217abe19
15 changed files with 136 additions and 144 deletions

View File

@@ -7,12 +7,11 @@ import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
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 { WizardState, WizardMsg, Draft, initial, reduce, hasProgress } from '@herregistratie/domain/herregistratie.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
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';
import { UploadState, initialUpload, deliveryRefs } 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
@@ -77,7 +76,6 @@ import { UploadState, initialUpload } from '@shared/upload/upload.machine';
})
export class HerregistratieWizardComponent {
private profile = inject(BigProfileStore);
private apiClient = inject(ApiClient);
private store = createStore<WizardState, WizardMsg>(initial, reduce);
/** Optional seed so Storybook / the showcase can mount any state directly. */
@@ -86,6 +84,20 @@ export class HerregistratieWizardComponent {
readonly state = this.store.model; // public so the showcase can highlight the live state
protected dispatch = this.store.dispatch;
// Backend draft-sync (new persistence for this wizard): create a Concept on first
// progress, debounced-sync the snapshot, resume by `?aanvraag=<id>`.
private draftSync = createDraftSync({
type: 'herregistratie',
snapshot: () => {
const s = this.state();
if (s.tag !== 'Editing' || !hasProgress(s)) return null;
const documentIds = deliveryRefs(s.upload).filter((r) => r.channel === 'digital' && r.documentId).map((r) => r.documentId!);
return { draft: s, stepIndex: s.step - 1, stepCount: this.stepLabels.length, documentIds };
},
onResume: (draft) => this.dispatch({ tag: 'Seed', state: (draft as WizardState | null) ?? initial }),
enabled: () => this.seed() === initial,
});
// Stepper labels + per-step heading titles (presentational only).
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`];
@@ -126,7 +138,10 @@ export class HerregistratieWizardComponent {
});
constructor() {
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
const seeded = this.seed();
queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume()));
}
onPrimary() {
@@ -143,16 +158,17 @@ export class HerregistratieWizardComponent {
/** Reset the wizard to a fresh, empty start. */
restart() {
this.draftSync.reset();
this.dispatch({ tag: 'Seed', state: initial });
}
/** The effect: when we entered Submitting, call the backend command, flip the
optimistic cross-page flag, then dispatch the result (and commit/rollback). */
/** The effect: when we entered Submitting, submit through the aanvraag lifecycle,
flip the optimistic cross-page flag, then dispatch the result (commit/rollback). */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie();
const r = await submitHerregistratie(this.apiClient, s.data);
const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents });
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();