diff --git a/src/app/herregistratie/application/submit-herregistratie.ts b/src/app/herregistratie/application/submit-herregistratie.ts index c82dbc7..ff41329 100644 --- a/src/app/herregistratie/application/submit-herregistratie.ts +++ b/src/app/herregistratie/application/submit-herregistratie.ts @@ -10,6 +10,6 @@ import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; */ export function submitHerregistratie(client: ApiClient, data: Valid): Promise> { return runSubmit(async () => { - await client.herregistraties({ uren: data.uren }); + await client.herregistraties({ uren: data.uren, documents: data.documents }); }, SUBMIT_FAILED); } diff --git a/src/app/herregistratie/domain/herregistratie.machine.spec.ts b/src/app/herregistratie/domain/herregistratie.machine.spec.ts index 4ad8632..ee8dc49 100644 --- a/src/app/herregistratie/domain/herregistratie.machine.spec.ts +++ b/src/app/herregistratie/domain/herregistratie.machine.spec.ts @@ -1,9 +1,11 @@ import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; +import { initialUpload } from '@shared/upload/upload.machine'; import { initial, next, back, submit, resolve, reduce, WizardState } from './herregistratie.machine'; -const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, jaren, punten }, errors: {} }); -const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, jaren, punten }, errors: {} }); +const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload }); +const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload }); +const editing3 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 3, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload }); describe('wizard.machine', () => { it('next advances only when step 1 parses', () => { @@ -12,11 +14,18 @@ describe('wizard.machine', () => { expect((next(editing1('4160')) as any).step).toBe(2); }); - it('submit reaches Submitting ONLY with fully valid data', () => { - expect(submit(editing2('4160', 'x')).tag).toBe('Editing'); // invalid punten -> no Submitting - const good = submit(editing2('4160', '200')); + it('next advances step 2 → 3 only when punten parses', () => { + expect((next(editing2('4160', 'x')) as any).step).toBe(2); // invalid punten -> stays + expect((next(editing2('4160', 'x')) as any).errors.punten).toBeTruthy(); + expect((next(editing2('4160', '200')) as any).step).toBe(3); + }); + + it('submit reaches Submitting ONLY from step 3 with fully valid data', () => { + expect(submit(editing2('4160', '200')).tag).toBe('Editing'); // not on step 3 -> no Submitting + expect(submit(editing3('4160', 'x')).tag).toBe('Editing'); // invalid punten + const good = submit(editing3('4160', '200')); expect(good.tag).toBe('Submitting'); - expect((good as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200 }); + expect((good as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] }); }); it('next requires BOTH step-1 fields (uren and jaren)', () => { @@ -25,13 +34,15 @@ describe('wizard.machine', () => { expect((next(editing1('4160', '5')) as any).step).toBe(2); // both valid -> advance }); - it('back / resolve are no-ops from illegal states', () => { + it('back steps down one (3 → 2 → 1) and is a no-op from step 1', () => { expect(back(initial)).toBe(initial); // step 1, nothing to go back to + expect((back(editing3('1', '2')) as any).step).toBe(2); + expect((back(editing2('1', '2')) as any).step).toBe(1); expect(resolve(initial, ok(undefined))).toBe(initial); // not Submitting }); it('resolve maps Submitting to Submitted / Failed', () => { - const submitting = submit(editing2('4160', '200')); + const submitting = submit(editing3('4160', '200')); expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted'); expect(resolve(submitting, err('boom')).tag).toBe('Failed'); }); @@ -45,18 +56,32 @@ describe('reduce (message-driven)', () => { s = reduce(s, { tag: 'Next' }); expect(s.tag === 'Editing' && s.step).toBe(2); s = reduce(s, { tag: 'SetField', key: 'punten', value: '200' }); + s = reduce(s, { tag: 'Next' }); + expect(s.tag === 'Editing' && s.step).toBe(3); s = reduce(s, { tag: 'Submit' }); expect(s.tag).toBe('Submitting'); s = reduce(s, { tag: 'SubmitConfirmed' }); expect(s.tag).toBe('Submitted'); }); + it('blocks submit until required documents are satisfied', () => { + const cat = { categoryId: 'bewijs', label: 'Bewijs', description: '', required: true, acceptedTypes: [], maxSizeMb: 10, multiple: false, allowPostDelivery: true }; + let s = reduce(editing3('4160', '200'), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } }); + s = reduce(s, { tag: 'Submit' }); + expect(s.tag).toBe('Editing'); + expect((s as any).errors.documenten).toBeTruthy(); + s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'bewijs', channel: 'post' } }); + s = reduce(s, { tag: 'Submit' }); + expect(s.tag).toBe('Submitting'); + expect((s as any).data.documents).toEqual([{ categoryId: 'bewijs', channel: 'post' }]); + }); + it('SubmitFailed then Retry returns to Submitting with the same data', () => { - let s = reduce(reduce(editing2('4160', '200'), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' }); + let s = reduce(reduce(editing3('4160', '200'), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' }); expect(s.tag).toBe('Failed'); s = reduce(s, { tag: 'Retry' }); expect(s.tag).toBe('Submitting'); - expect((s as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200 }); + expect((s as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] }); }); it('Seed mounts an arbitrary state', () => { diff --git a/src/app/herregistratie/domain/herregistratie.machine.ts b/src/app/herregistratie/domain/herregistratie.machine.ts index 157efb3..5bb1a95 100644 --- a/src/app/herregistratie/domain/herregistratie.machine.ts +++ b/src/app/herregistratie/domain/herregistratie.machine.ts @@ -1,5 +1,13 @@ import { Result, assertNever } from '@shared/kernel/fp'; import { Uren, parseUren } from '@registratie/domain/value-objects/uren'; +import { + UploadState, + UploadMsg, + initialUpload, + reduceUpload, + requiredCategoriesSatisfied, + deliveryRefs, +} from '@shared/upload/upload.machine'; /** What the user is typing (raw, possibly invalid). */ export interface Draft { @@ -8,11 +16,14 @@ export interface Draft { punten: string; } +export type StepErrors = Partial>; + /** What we have AFTER parsing — branded/typed, guaranteed valid. */ export interface Valid { uren: Uren; jaren: number; punten: number; + documents: Array<{ categoryId: string; channel: 'digital' | 'post'; documentId?: string }>; } /** @@ -22,51 +33,68 @@ export interface Valid { * errors set" are unrepresentable — the bug class is gone by construction. */ export type WizardState = - | { tag: 'Editing'; step: 1 | 2; draft: Draft; errors: Partial> } + | { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors; upload: UploadState } | { tag: 'Submitting'; data: Valid } | { tag: 'Submitted'; data: Valid } | { tag: 'Failed'; data: Valid; error: string }; -export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {} }; +export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {}, upload: initialUpload }; /** Parse every field; on success hand back a Valid, else the per-field errors. */ -function validate(draft: Draft): Result>, Valid> { +function validate(draft: Draft, upload: UploadState): Result { const uren = parseUren(draft.uren); const jaren = parseUren(draft.jaren); const punten = parseUren(draft.punten); - const errors: Partial> = {}; + const errors: StepErrors = {}; if (!uren.ok) errors.uren = uren.error; if (!jaren.ok) errors.jaren = jaren.error; if (!punten.ok) errors.punten = punten.error; - if (uren.ok && jaren.ok && punten.ok) { - return { ok: true, value: { uren: uren.value, jaren: jaren.value, punten: punten.value } }; + if (!requiredCategoriesSatisfied(upload)) { + errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`; + } + if (uren.ok && jaren.ok && punten.ok && !errors.documenten) { + return { ok: true, value: { uren: uren.value, jaren: jaren.value, punten: punten.value, documents: deliveryRefs(upload) } }; } return { ok: false, error: errors }; } -/** Step 1 → 2: advance only when BOTH step-1 fields parse. Illegal elsewhere = no-op. */ +/** Advance one step, gating on that step's fields. Illegal elsewhere = no-op. */ export function next(s: WizardState): WizardState { - if (s.tag !== 'Editing' || s.step !== 1) return s; - const uren = parseUren(s.draft.uren); - const jaren = parseUren(s.draft.jaren); - const errors: Partial> = {}; - if (!uren.ok) errors.uren = uren.error; - if (!jaren.ok) errors.jaren = jaren.error; - return Object.keys(errors).length === 0 ? { ...s, step: 2, errors: {} } : { ...s, errors }; + if (s.tag !== 'Editing') return s; + const errors: StepErrors = {}; + if (s.step === 1) { + const uren = parseUren(s.draft.uren); + const jaren = parseUren(s.draft.jaren); + if (!uren.ok) errors.uren = uren.error; + if (!jaren.ok) errors.jaren = jaren.error; + return Object.keys(errors).length === 0 ? { ...s, step: 2, errors: {} } : { ...s, errors }; + } + if (s.step === 2) { + const punten = parseUren(s.draft.punten); + if (!punten.ok) errors.punten = punten.error; + return punten.ok ? { ...s, step: 3, errors: {} } : { ...s, errors }; + } + return s; } export function back(s: WizardState): WizardState { - if (s.tag !== 'Editing' || s.step !== 2) return s; - return { ...s, step: 1, errors: {} }; + if (s.tag !== 'Editing' || s.step === 1) return s; + return { ...s, step: (s.step - 1) as 1 | 2, errors: {} }; } -/** Step 2 submit: parse everything; move to Submitting only with Valid data. */ +/** Step 3 submit: parse everything + require documents; Submitting only with Valid. */ export function submit(s: WizardState): WizardState { - if (s.tag !== 'Editing' || s.step !== 2) return s; - const result = validate(s.draft); + if (s.tag !== 'Editing' || s.step !== 3) return s; + const result = validate(s.draft, s.upload); return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error }; } +/** Route an upload sub-message through the pure upload reducer (Editing only). */ +export function upload(s: WizardState, msg: UploadMsg): WizardState { + if (s.tag !== 'Editing') return s; + return { ...s, upload: reduceUpload(s.upload, msg) }; +} + /** Resolve the async submit. Only meaningful while Submitting. */ export function resolve(s: WizardState, r: Result): WizardState { if (s.tag !== 'Submitting') return s; @@ -92,6 +120,7 @@ export type WizardMsg = | { tag: 'Retry' } | { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error: string } + | { tag: 'Upload'; msg: UploadMsg } | { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase) export function reduce(s: WizardState, m: WizardMsg): WizardState { @@ -110,6 +139,8 @@ export function reduce(s: WizardState, m: WizardMsg): WizardState { return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s; case 'SubmitFailed': return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s; + case 'Upload': + return upload(s, m.msg); case 'Seed': return m.state; default: diff --git a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts index 73fcfa5..7b356f3 100644 --- a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts +++ b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts @@ -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: ` - @if (step() === 1) { - - - - - - - } @else { - - - + @switch (step()) { + @case (1) { + + + + + + + } + @case (2) { + + + + } + @case (3) { + + @if (errDocumenten()) { + {{ errDocumenten() }} + } + } }
@@ -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(() => 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 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(() => { 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(); } diff --git a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts index fda71ca..d5134cb 100644 --- a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts +++ b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts @@ -3,9 +3,10 @@ import { applicationConfig } from '@storybook/angular'; import { provideHttpClient } from '@angular/common/http'; import { HerregistratieWizardComponent } from './herregistratie-wizard.component'; import { WizardState } from '@herregistratie/domain/herregistratie.machine'; +import { initialUpload } from '@shared/upload/upload.machine'; import { Uren } from '@registratie/domain/value-objects/uren'; -const validData = { uren: 4160 as Uren, jaren: 5, punten: 200 }; +const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] }; const meta: Meta = { title: 'Herregistratie/Wizard', @@ -18,11 +19,12 @@ export default meta; type Story = StoryObj; // Each story seeds one state of the machine — one render per union variant. -export const Step1: Story = { args: { seed: { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {} } } }; +export const Step1: Story = { args: { seed: { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {}, upload: initialUpload } } }; export const Step1Error: Story = { - args: { seed: { tag: 'Editing', step: 1, draft: { uren: 'abc', jaren: '', punten: '' }, errors: { uren: 'Vul een geheel aantal in (0 of meer).', jaren: 'Vul een geheel aantal in (0 of meer).' } } satisfies WizardState }, + args: { seed: { tag: 'Editing', step: 1, draft: { uren: 'abc', jaren: '', punten: '' }, errors: { uren: 'Vul een geheel aantal in (0 of meer).', jaren: 'Vul een geheel aantal in (0 of meer).' }, upload: initialUpload } satisfies WizardState }, }; -export const Step2: Story = { args: { seed: { tag: 'Editing', step: 2, draft: { uren: '4160', jaren: '5', punten: '' }, errors: {} } } }; +export const Step2: Story = { args: { seed: { tag: 'Editing', step: 2, draft: { uren: '4160', jaren: '5', punten: '' }, errors: {}, upload: initialUpload } } }; +export const Step3: Story = { args: { seed: { tag: 'Editing', step: 3, draft: { uren: '4160', jaren: '5', punten: '200' }, errors: {}, upload: initialUpload } } }; export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } }; export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } }; export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } }; diff --git a/src/app/registratie/application/submit-registratie.ts b/src/app/registratie/application/submit-registratie.ts index 79f89be..b3cdcff 100644 --- a/src/app/registratie/application/submit-registratie.ts +++ b/src/app/registratie/application/submit-registratie.ts @@ -11,7 +11,7 @@ import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; */ export function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise> { return runSubmit(async () => { - const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst }); + const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst, documents: data.documents }); return res.referentie ?? ''; }, SUBMIT_FAILED); } diff --git a/src/app/registratie/domain/registratie-wizard.machine.spec.ts b/src/app/registratie/domain/registratie-wizard.machine.spec.ts index 788f879..a2ccc74 100644 --- a/src/app/registratie/domain/registratie-wizard.machine.spec.ts +++ b/src/app/registratie/domain/registratie-wizard.machine.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; +import { initialUpload } from '@shared/upload/upload.machine'; import { Draft, RegistratieState, @@ -25,6 +26,7 @@ const invullen = (draft: Partial, cursor = 0): RegistratieState => ({ draft: { antwoorden: {}, ...draft }, cursor, errors: {}, + upload: initialUpload, }); const validAdres = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', correspondentie: 'post' as const, adresHerkomst: 'brp' as const }; @@ -204,3 +206,31 @@ describe('reduce (message-driven happy path)', () => { expect((s as any).data.beroep).toBe('Arts'); }); }); + +describe('inline document upload (beroep step)', () => { + const cat = { categoryId: 'diploma', label: 'Diploma', description: '', required: true, acceptedTypes: [], maxSizeMb: 10, multiple: false, allowPostDelivery: true }; + + it('routes Upload messages through the upload reducer', () => { + const s = reduce(invullen(validDraft), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } }); + expect((s as any).upload.categories).toHaveLength(1); + }); + + it('blocks the beroep step until a required category is satisfied', () => { + let s = reduce(invullen(validDraft, 1), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } }); + s = reduce(s, { tag: 'Next' }); // beroep → controle blocked + expect(currentStep(s as any)).toBe('beroep'); + expect((s as any).errors.documenten).toBeTruthy(); + // choosing post delivery satisfies the requirement + s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' } }); + s = reduce(s, { tag: 'Next' }); + expect(currentStep(s as any)).toBe('controle'); + }); + + it('includes delivery refs in the submitted data', () => { + let s = reduce(invullen(validDraft), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } }); + s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' } }); + const done = submit(s as any); + expect(done.tag).toBe('Indienen'); + expect((done as any).data.documents).toEqual([{ categoryId: 'diploma', channel: 'post' }]); + }); +}); diff --git a/src/app/registratie/domain/registratie-wizard.machine.ts b/src/app/registratie/domain/registratie-wizard.machine.ts index c45b4b8..d625e5e 100644 --- a/src/app/registratie/domain/registratie-wizard.machine.ts +++ b/src/app/registratie/domain/registratie-wizard.machine.ts @@ -1,6 +1,15 @@ import { Result, ok, err, assertNever } from '@shared/kernel/fp'; import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode'; import { Email, parseEmail } from '@registratie/domain/value-objects/email'; +import { + UploadState, + UploadMsg, + DeliveryChannel, + initialUpload, + reduceUpload, + requiredCategoriesSatisfied, + deliveryRefs, +} from '@shared/upload/upload.machine'; /** * A FIXED 3-step registration wizard. The steps never change in number (always @@ -49,6 +58,7 @@ export interface ValidRegistratie { diplomaHerkomst: DiplomaHerkomst; beroep: string; antwoorden: Record; + documents: Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }>; } /** Text fields settable via SetField. */ @@ -63,17 +73,18 @@ export interface Errors { email?: string; correspondentie?: string; diploma?: string; + documenten?: string; antwoorden?: Record; } export type RegistratieState = - | { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors } + | { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors; upload: UploadState } | { tag: 'Indienen'; data: ValidRegistratie } | { tag: 'Ingediend'; data: ValidRegistratie; referentie: string } | { tag: 'Mislukt'; data: ValidRegistratie; error: string }; const emptyDraft: Draft = { antwoorden: {} }; -export const initial: RegistratieState = { tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {} }; +export const initial: RegistratieState = { tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {}, upload: initialUpload }; /** Which step the cursor currently points at (clamped to the fixed list). */ export function currentStep(s: Extract): StepId { @@ -81,7 +92,7 @@ export function currentStep(s: Extract): } /** Validate every question currently visible in ONE step. Errors keyed per field. */ -function validateStep(step: StepId, d: Draft): Result { +function validateStep(step: StepId, d: Draft, upload: UploadState): Result { const errors: Errors = {}; switch (step) { case 'adres': { @@ -111,6 +122,10 @@ function validateStep(step: StepId, d: Draft): Result { if (!(d.antwoorden[id] ?? '').trim()) open[id] = $localize`:@@validation.beantwoordVraag:Beantwoord deze vraag.`; } if (Object.keys(open).length > 0) errors.antwoorden = open; + // Required documents for this wizard attach to the beroep step (inline upload). + if (!requiredCategoriesSatisfied(upload)) { + errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`; + } break; } case 'controle': @@ -122,10 +137,10 @@ function validateStep(step: StepId, d: Draft): Result { } /** Parse the whole wizard into a ValidRegistratie (called on submit). */ -function validateAll(d: Draft): Result { +function validateAll(d: Draft, upload: UploadState): Result { const errors: Errors = {}; for (const step of STEPS) { - const r = validateStep(step, d); + const r = validateStep(step, d, upload); if (!r.ok) Object.assign(errors, r.error); } if (Object.keys(errors).length > 0) return err(errors); @@ -147,6 +162,7 @@ function validateAll(d: Draft): Result { diplomaHerkomst: d.diplomaHerkomst ?? 'handmatig', beroep: d.beroep, antwoorden, + documents: deliveryRefs(upload), }); } @@ -197,7 +213,7 @@ export function setAntwoord(s: RegistratieState, vraagId: string, value: string) export function next(s: RegistratieState): RegistratieState { if (s.tag !== 'Invullen') return s; - const r = validateStep(currentStep(s), s.draft); + const r = validateStep(currentStep(s), s.draft, s.upload); if (!r.ok) return { ...s, errors: r.error }; return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} }; } @@ -216,10 +232,16 @@ export function gaNaarStap(s: RegistratieState, cursor: number): RegistratieStat export function submit(s: RegistratieState): RegistratieState { if (s.tag !== 'Invullen') return s; - const r = validateAll(s.draft); + const r = validateAll(s.draft, s.upload); return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error }; } +/** Route an upload sub-message through the pure upload reducer (Invullen only). */ +export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState { + if (s.tag !== 'Invullen') return s; + return { ...s, upload: reduceUpload(s.upload, msg) }; +} + export function resolve(s: RegistratieState, r: Result): RegistratieState { if (s.tag !== 'Indienen') return s; return r.ok ? { tag: 'Ingediend', data: s.data, referentie: r.value } : { tag: 'Mislukt', data: s.data, error: r.error }; @@ -240,6 +262,7 @@ export type RegistratieMsg = | { tag: 'Retry' } | { tag: 'SubmitConfirmed'; referentie: string } | { tag: 'SubmitFailed'; error: string } + | { tag: 'Upload'; msg: UploadMsg } | { tag: 'Seed'; state: RegistratieState }; export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState { @@ -272,6 +295,8 @@ export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState return s.tag === 'Indienen' ? { tag: 'Ingediend', data: s.data, referentie: m.referentie } : s; case 'SubmitFailed': return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s; + case 'Upload': + return upload(s, m.msg); case 'Seed': return m.state; default: diff --git a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts index 9d0a65c..081a469 100644 --- a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -29,8 +29,11 @@ import { } from '@registratie/domain/registratie-wizard.machine'; import { submitRegistratie } from '@registratie/application/submit-registratie'; 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'; -const STORAGE_KEY = 'registratie-v1'; // ponytail: bump the suffix if the persisted shape changes; no migration. +const STORAGE_KEY = 'registratie-v2'; // ponytail: bump the suffix if the persisted shape changes; no migration. const KANALEN = [ { value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` }, { value: 'post', label: $localize`:@@registratie.kanaalPost:Post` }, @@ -48,7 +51,7 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed" imports: [ FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, SkeletonComponent, DataRowComponent, WizardShellComponent, - AddressFieldsComponent, ...ASYNC, + AddressFieldsComponent, DocumentUploadComponent, ...ASYNC, ], template: ` + + + @if (err('documenten')) { + {{ err('documenten') }} + } } @case ('controle') { Controleer uw gegevens en dien de registratie in. @@ -186,6 +201,12 @@ export class RegistratieWizardComponent { private invullen = computed(() => whenTag(this.state(), 'Invullen')); protected cursor = computed(() => this.invullen()?.cursor ?? 0); protected draft = computed(() => this.invullen()?.draft ?? { antwoorden: {} }); + protected upload = computed(() => this.invullen()?.upload ?? initialUpload); + protected uploadCtl = createUploadController({ + wizardId: 'registratie', + getUpload: () => this.upload(), + dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }), + }); protected step = computed(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]); protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]); protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? ''); @@ -252,7 +273,7 @@ export class RegistratieWizardComponent { readonly jaNee = JA_NEE; - protected err = (k: DraftField | 'correspondentie' | 'diploma') => this.invullen()?.errors[k] ?? ''; + protected err = (k: DraftField | 'correspondentie' | 'diploma' | 'documenten') => this.invullen()?.errors[k] ?? ''; protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? ''; protected set = (key: DraftField, value: string) => this.dispatch({ tag: 'SetField', key, value }); protected setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie }); diff --git a/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts b/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts index c715436..5adb95b 100644 --- a/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts +++ b/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts @@ -3,12 +3,13 @@ import { applicationConfig } from '@storybook/angular'; import { provideHttpClient } from '@angular/common/http'; import { RegistratieWizardComponent } from './registratie-wizard.component'; import { Draft, RegistratieState, ValidRegistratie } from '@registratie/domain/registratie-wizard.machine'; +import { initialUpload } from '@shared/upload/upload.machine'; import { Postcode } from '@registratie/domain/value-objects/postcode'; const adres: Partial = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', adresHerkomst: 'brp', correspondentie: 'post' }; const filled: Partial = { ...adres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo', vraagIds: [] }; -const invullen = (draft: Partial, cursor = 0): RegistratieState => ({ tag: 'Invullen', draft: { antwoorden: {}, ...draft }, cursor, errors: {} }); +const invullen = (draft: Partial, cursor = 0): RegistratieState => ({ tag: 'Invullen', draft: { antwoorden: {}, ...draft }, cursor, errors: {}, upload: initialUpload }); const validData: ValidRegistratie = { adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' }, @@ -18,6 +19,7 @@ const validData: ValidRegistratie = { diplomaHerkomst: 'duo', beroep: 'Arts', antwoorden: {}, + documents: [], }; const meta: Meta = { diff --git a/src/app/shared/upload/upload-controller.ts b/src/app/shared/upload/upload-controller.ts new file mode 100644 index 0000000..760b5a0 --- /dev/null +++ b/src/app/shared/upload/upload-controller.ts @@ -0,0 +1,88 @@ +import { DestroyRef, effect, inject } from '@angular/core'; +import { UploadAdapter } from './upload.adapter'; +import { UploadShellService } from './upload-shell.service'; +import { problemDetail } from '@shared/infrastructure/api-error'; +import { SUBMIT_FAILED } from '@shared/application/submit'; +import { DeliveryChannel, UploadMsg, UploadState, inFlight, rejectReason } from './upload.machine'; + +export interface UploadControllerDeps { + wizardId: string; + getUpload: () => UploadState; + dispatch: (m: UploadMsg) => void; +} + +/** + * The effectful glue between the `` organism's events and the + * pure upload reducer + transport. Both wizards instantiate one (in a field + * initializer, like `createStore`). Holds the only state a reducer can't: the live + * `File` blobs keyed by localId (needed to retry an upload). Loads categories, + * reports background-sync availability, and polls on tab refocus. + */ +export function createUploadController(deps: UploadControllerDeps) { + const adapter = inject(UploadAdapter); + const shell = inject(UploadShellService); + const files = new Map(); + const categoriesRes = adapter.categoriesResource(deps.wizardId); + + // Runs after the host's `Seed`/restore microtask (effects fire in CD, not field + // init), so these dispatches aren't wiped by a state reseed. + effect(() => { + deps.dispatch({ type: 'BackgroundSyncAvailability', available: shell.backgroundSyncAvailable }); + const status = categoriesRes.status(); + if (status === 'resolved' || status === 'local') { + deps.dispatch({ type: 'CategoriesLoaded', categories: categoriesRes.value() ?? [] }); + } else if (status === 'error') { + deps.dispatch({ type: 'CategoriesLoadFailed', reason: problemDetail(categoriesRes.error(), SUBMIT_FAILED) }); + } + }); + + const onFocus = () => void shell.pollReturning(inFlight(deps.getUpload()), deps.dispatch); + window.addEventListener('focus', onFocus); + inject(DestroyRef).onDestroy(() => window.removeEventListener('focus', onFocus)); + + function start(categoryId: string, file: File) { + const localId = crypto.randomUUID(); + files.set(localId, file); + deps.dispatch({ type: 'FileSelected', categoryId, localId, fileName: file.name, fileSizeMb: file.size / 1e6 }); + shell.upload({ localId, categoryId, wizardId: deps.wizardId, file }, deps.dispatch); + } + + return { + onFileSelected(categoryId: string, selected: File[]) { + const cat = deps.getUpload().categories.find((c) => c.categoryId === categoryId); + if (!cat) return; + if (!cat.multiple && selected.length > 1) { + deps.dispatch({ type: 'FileRejected', categoryId, reason: 'multiple' }); + return; + } + for (const file of selected) { + const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 }); + if (reason) deps.dispatch({ type: 'FileRejected', categoryId, reason }); + else start(categoryId, file); + } + }, + onRemove(localId: string) { + shell.cancel([localId]); + files.delete(localId); + deps.dispatch({ type: 'UploadRemoved', localId }); + }, + onRetry(localId: string) { + const file = files.get(localId); + const up = deps.getUpload().uploads.find((u) => u.localId === localId); + if (!file || !up) return; + deps.dispatch({ type: 'UploadRetried', localId }); + shell.upload({ localId, categoryId: up.categoryId, wizardId: deps.wizardId, file }, deps.dispatch); + }, + onDelete(e: { localId: string; documentId: string }) { + shell.delete(e.localId, e.documentId, deps.dispatch); + }, + onChannelChange(categoryId: string, channel: DeliveryChannel) { + const ids = deps + .getUpload() + .uploads.filter((u) => u.categoryId === categoryId) + .map((u) => u.localId); + shell.cancel(ids); + deps.dispatch({ type: 'DeliveryChannelChanged', categoryId, channel }); + }, + }; +} diff --git a/src/app/shared/upload/upload.machine.spec.ts b/src/app/shared/upload/upload.machine.spec.ts index 02c9df9..77be6b5 100644 --- a/src/app/shared/upload/upload.machine.spec.ts +++ b/src/app/shared/upload/upload.machine.spec.ts @@ -9,6 +9,7 @@ import { requiredCategoriesSatisfied, deliveryRefs, inFlight, + rejectReason, } from './upload.machine'; const cat = (over: Partial = {}): DocumentCategory => ({ @@ -217,6 +218,21 @@ describe('deliveryRefs', () => { }); }); +describe('rejectReason', () => { + it('rejects a disallowed type', () => { + expect(rejectReason(cat({ acceptedTypes: ['application/pdf'] }), { type: 'image/png', sizeMb: 1 })).toBe('type'); + }); + it('rejects an oversized file', () => { + expect(rejectReason(cat({ maxSizeMb: 10 }), { type: 'application/pdf', sizeMb: 11 })).toBe('size'); + }); + it('accepts a valid file', () => { + expect(rejectReason(cat({ acceptedTypes: ['application/pdf'], maxSizeMb: 10 }), { type: 'application/pdf', sizeMb: 1 })).toBeNull(); + }); + it('allows any type when the category lists none', () => { + expect(rejectReason(cat({ acceptedTypes: [], maxSizeMb: 10 }), { type: 'image/png', sizeMb: 1 })).toBeNull(); + }); +}); + describe('inFlight', () => { it('returns only queued/uploading uploads', () => { let s = select(stateWith([cat({ multiple: true })]), 'diploma', 'u1'); diff --git a/src/app/shared/upload/upload.machine.ts b/src/app/shared/upload/upload.machine.ts index b584b5f..2868fde 100644 --- a/src/app/shared/upload/upload.machine.ts +++ b/src/app/shared/upload/upload.machine.ts @@ -96,6 +96,16 @@ export function requiredCategoriesSatisfied(s: UploadState): boolean { return s.categories.filter((c) => c.required).every((c) => categorySatisfied(s, c.categoryId)); } +/** + * FE format-validation (never authority — the server re-validates). Returns the + * rejection reason for one file, or null if it passes the category's type/size. + */ +export function rejectReason(cat: DocumentCategory, file: { type: string; sizeMb: number }): 'type' | 'size' | null { + if (cat.acceptedTypes.length > 0 && !cat.acceptedTypes.includes(file.type)) return 'type'; + if (cat.maxSizeMb > 0 && file.sizeMb > cat.maxSizeMb) return 'size'; + return null; +} + /** Map one upload's status, leaving the rest of the list untouched. */ function mapUpload(s: UploadState, localId: string, f: (u: Upload) => Upload): UploadState { return { ...s, uploads: s.uploads.map((u) => (u.localId === localId ? f(u) : u)) };