Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
187 lines
6.4 KiB
TypeScript
187 lines
6.4 KiB
TypeScript
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 {
|
|
uren: string;
|
|
jaren: string;
|
|
punten: string;
|
|
}
|
|
|
|
export type StepErrors = Partial<Record<keyof Draft | 'documenten', string>>;
|
|
|
|
/** 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 }>;
|
|
}
|
|
|
|
/**
|
|
* The whole wizard as one tagged union. `step` and `errors` exist ONLY while
|
|
* Editing; Submitting/Submitted/Failed carry a `Valid` payload and nothing else.
|
|
* So "submitting while a field is invalid" or "showing the success screen with
|
|
* errors set" are unrepresentable — the bug class is gone by construction.
|
|
*/
|
|
export type WizardState =
|
|
| { 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: {},
|
|
upload: initialUpload,
|
|
};
|
|
|
|
/** Has the user meaningfully started, so it's worth persisting as a Concept? */
|
|
export function hasProgress(s: Extract<WizardState, { tag: 'Editing' }>): boolean {
|
|
return (
|
|
s.step > 1 ||
|
|
!!s.draft.uren ||
|
|
!!s.draft.jaren ||
|
|
!!s.draft.punten ||
|
|
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)
|
|
);
|
|
}
|
|
|
|
/** Parse every field; on success hand back a Valid, else the per-field errors. */
|
|
function validate(draft: Draft, upload: UploadState): Result<StepErrors, Valid> {
|
|
const uren = parseUren(draft.uren);
|
|
const jaren = parseUren(draft.jaren);
|
|
const punten = parseUren(draft.punten);
|
|
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 (!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 };
|
|
}
|
|
|
|
/** Advance one step, gating on that step's fields. Illegal elsewhere = no-op. */
|
|
export function next(s: WizardState): WizardState {
|
|
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 === 1) return s;
|
|
return { ...s, step: (s.step - 1) as 1 | 2, errors: {} };
|
|
}
|
|
|
|
/** Jump back to an earlier step to correct data (controle → step N). Forward
|
|
jumps are not allowed (would skip validation). */
|
|
export function gaNaarStap(s: WizardState, step: 1 | 2 | 3): WizardState {
|
|
if (s.tag !== 'Editing' || step >= s.step) return s;
|
|
return { ...s, step, errors: {} };
|
|
}
|
|
|
|
/** Step 3 submit: parse everything + require documents; Submitting only with Valid. */
|
|
export function submit(s: WizardState): WizardState {
|
|
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<string, void>): WizardState {
|
|
if (s.tag !== 'Submitting') return s;
|
|
return r.ok
|
|
? { tag: 'Submitted', data: s.data }
|
|
: { tag: 'Failed', data: s.data, error: r.error };
|
|
}
|
|
|
|
/** Update one draft field while editing; ignored in any other state. */
|
|
export function setField(s: WizardState, key: keyof Draft, value: string): WizardState {
|
|
if (s.tag !== 'Editing') return s;
|
|
return { ...s, draft: { ...s.draft, [key]: value } };
|
|
}
|
|
|
|
/**
|
|
* Every event that can happen to the wizard, as one message type. The component
|
|
* sends a WizardMsg; `reduce` decides the next state. This is the Elm
|
|
* Model+Msg+update pattern: ONE pure function describes all state changes.
|
|
*/
|
|
export type WizardMsg =
|
|
| { tag: 'SetField'; key: keyof Draft; value: string }
|
|
| { tag: 'Next' }
|
|
| { tag: 'Back' }
|
|
| { tag: 'GaNaarStap'; step: 1 | 2 | 3 }
|
|
| { tag: 'Submit' }
|
|
| { 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 {
|
|
switch (m.tag) {
|
|
case 'SetField':
|
|
return setField(s, m.key, m.value);
|
|
case 'Next':
|
|
return next(s);
|
|
case 'Back':
|
|
return back(s);
|
|
case 'GaNaarStap':
|
|
return gaNaarStap(s, m.step);
|
|
case 'Submit':
|
|
return submit(s);
|
|
case 'Retry':
|
|
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
|
case 'SubmitConfirmed':
|
|
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:
|
|
return assertNever(m);
|
|
}
|
|
}
|