import { Component, computed, effect, inject, input, untracked } from '@angular/core'; import { ButtonComponent } from '@shared/ui/atoms/button/button.component'; import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component'; import { WizardShellComponent, WizardError, WizardPhase, naarStapLabel, } from '@shared/layout/wizard-shell/wizard-shell.component'; import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors'; import { createStore } from '@shared/application/store'; import { whenTag } from '@shared/kernel/fp'; import { BigProfileStore } from '@registratie/application/big-profile.store'; import { IntakeState, IntakeMsg, Answers, Errors, StepId, initial, reduce, STEPS, hasProgress, SCHOLING_THRESHOLD_DEFAULT, } from '@herregistratie/domain/intake.machine'; import { createDraftSync } from '@registratie/application/draft-sync'; import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store'; import { BuitenlandStep } from './buitenland.step'; import { WerkStep } from './werk.step'; import { ReviewStep } from './review.step'; /** Organism: a BRANCHING intake questionnaire. All state lives in one signal driven by the pure `reduce` (intake.machine.ts). Which step renders is derived from the answers via `visibleSteps`, never stored — so editing an earlier answer immediately changes the remaining steps. The draft persists to the backend as a Concept aanvraag (createDraftSync), so a reload — or a "Verder gaan" from the dashboard via `?aanvraag=` — resumes progress. */ @Component({ selector: 'app-intake-wizard', imports: [ ButtonComponent, ConfirmationComponent, WizardShellComponent, BuitenlandStep, WerkStep, ReviewStep, ], template: ` @switch (step()) { @case ('buitenland') { } @case ('werk') { } @case ('review') { } }
Opnieuw beginnen
`, }) export class IntakeWizardComponent { private profile = inject(BigProfileStore); // Server-owned policy (scholing threshold): fetched from the backend via the // application facade, not hardcoded. The backend stays the authority on submit. private policyStore = inject(IntakePolicyStore); /** Optional seed so Storybook / the showcase can mount any state directly. */ seed = input(initial); // --- The store: all state in one signal, changed only by a pure reduce ----- // The effect fires once, on the `Answering -> Submitting` transition. `Seed` is exempt, // so a story that mounts straight into `Submitting` does not call the network. // `draftSync` is declared below (both callbacks are deferred, so the cycle is safe). private store = createStore(initial, reduce, { Submitting: async (s, store) => { this.profile.beginHerregistratie(); // The scholing answer rides along so the server can re-validate it as the // authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by // JSON.stringify, so a wizard above the threshold sends neither field. const r = await this.draftSync.submit({ uren: s.data.uren, aanvullendeScholing: s.data.aanvullendeScholing, scholingPunten: s.data.punten, }); if (r.ok) { store.dispatch({ tag: 'SubmitConfirmed' }); this.profile.confirmHerregistratie(); } else { store.dispatch({ tag: 'SubmitFailed', error: r.error }); this.profile.rollbackHerregistratie(); } }, }); readonly state = this.store.model; readonly dispatch = this.store.dispatch; // --- Static copy: stepper labels and per-step headings --------------------- readonly stepLabels = [ $localize`:@@intake.step.buitenland:Buitenland`, $localize`:@@intake.step.werk:Werk`, $localize`:@@intake.step.controle:Controle`, ]; private stepTitles: Record = { buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`, werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`, review: $localize`:@@intake.title.review:Controleren en indienen`, }; // --- State projections: one narrow, then read-only views of it ------------- private answering = computed(() => whenTag(this.state(), 'Answering')); /** Public so the showcase can render the (fixed) step list next to the wizard. */ readonly steps = STEPS; protected cursor = computed(() => this.answering()?.cursor ?? 0); protected answers = computed(() => this.answering()?.answers ?? {}); protected step = computed(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]); /** Server-owned threshold from the policy endpoint (mirrored into machine state). */ protected scholingThreshold = computed( () => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT, ); protected errors = computed(() => this.answering()?.errors ?? {}); // --- Controllers: persistence and uploads ---------------------------------- // Create a Concept on first progress, then debounced-sync the snapshot. // `?aanvraag=` resumes it. The intake has no uploads. private draftSync = createDraftSync({ type: 'intake', snapshot: () => { const s = this.state(); if (s.tag !== 'Answering' || !hasProgress(s)) return null; return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds: [] }; }, onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as IntakeState }), enabled: () => this.seed() === initial, }); // --- Presentational wiring for the shared wizard shell --------------------- protected stepTitle = computed(() => this.stepTitles[this.step()]); protected primaryLabel = computed(() => { if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`; const next = this.cursor() + 1; return naarStapLabel(next + 1, this.stepLabels[next]); }); /** 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 'Answering': 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. The field ids match the answer keys, so the summary anchors jump to the field. */ protected errorList = computed(() => toWizardErrors(this.answering()?.errors ?? {}), ); constructor() { // An explicit seed (stories/tests) wins; otherwise resume the backend draft // (`?aanvraag=`) or start fresh. Persistence is the draftSync controller's job. // Read `seed()` INSIDE the microtask: Angular binds inputs after the constructor // runs, so an eager read here always returns the `initial` default. queueMicrotask(() => { const seeded = this.seed(); if (seeded !== initial) this.dispatch({ tag: 'Seed', state: seeded }); else void this.draftSync.resume(); }); // Apply the server-owned threshold into machine state as it arrives. Track // only the policy value; untrack the dispatch (it reads the state signal // internally, which would otherwise make this effect loop on its own write). effect(() => { const scholingThreshold = this.policyStore.scholingThreshold(); untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold })); }); } restart() { this.draftSync.reset(); this.dispatch({ tag: 'Seed', state: initial }); } }