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 { AlertComponent } from '@shared/ui/alert/alert.component'; import { WizardShellComponent, WizardError, WizardPhase, naarStapLabel, } from '@shared/layout/wizard-shell/wizard-shell.component'; import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component'; 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, 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/application/upload-controller'; import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/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 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, AlertComponent, ConfirmationComponent, WizardShellComponent, DocumentUploadComponent, ], template: ` @switch (step()) { @case (1) {
} @case (2) {
} @case (3) { @if (errDocumenten()) { {{ errDocumenten() }} } } }
`, }) export class HerregistratieWizardComponent { private profile = inject(BigProfileStore); // Effect fires once, on Editing -> Submitting (RD-05's tag-transition rule; `Seed` is // exempt, so a story mounting straight into `Submitting` does not call the network). private store = createStore(initial, reduce, { Submitting: async (s, store) => { this.profile.beginHerregistratie(); const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents }); if (r.ok) { store.dispatch({ tag: 'SubmitConfirmed' }); this.profile.confirmHerregistratie(); } else { store.dispatch({ tag: 'SubmitFailed', error: r.error }); this.profile.rollbackHerregistratie(); } }, }); /** Preview/download link for a completed upload; delegates to the upload controller (application layer), which knows the dev-simulation `demo-*` ids have no stored bytes and returns no link for them. */ protected previewUrlFor = (documentId: string): string | undefined => this.uploadCtl.previewUrlFor(documentId); /** Optional seed so Storybook / the showcase can mount any state directly. */ seed = input(initial); 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=`. 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 }), 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`, ]; private editing = computed(() => whenTag(this.state(), 'Editing')); protected step = computed(() => this.editing()?.step ?? 1); protected draft = computed( () => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' }, ); protected upload = computed(() => 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 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(() => { const step = this.step(); return step < 3 ? naarStapLabel(step + 1, this.stepLabels[step]) : $localize`:@@herregWizard.indienen:Herregistratie aanvragen`; }); /** Stepper emits a 0-based index for an earlier (visited) step. */ protected goToStep(index: number) { this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 }); } /** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary, composing the localized failure prefix so the `Failed` message arrives intact. */ protected phase = computed(() => { const s = this.state(); switch (s.tag) { case 'Editing': return { tag: 'Editing' }; case 'Submitting': return { tag: 'Submitting' }; case 'Submitted': return { tag: 'Submitted' }; case 'Failed': return { tag: 'Failed', message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`, }; } }); /** Current step's field errors, flattened for the shell's error summary. */ protected errorList = computed(() => { const e = this.editing()?.errors ?? {}; return (Object.keys(e) as (keyof typeof e)[]) .filter((k) => e[k]) .map((k) => ({ id: k, message: e[k]! })); }); constructor() { // An explicit seed (stories/tests) wins; otherwise resume the backend draft // (`?aanvraag=`) 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(), ); } /** Reset the wizard to a fresh, empty start. */ restart() { this.draftSync.reset(); this.dispatch({ tag: 'Seed', state: initial }); } }