Step 2 (code quality): dedup + stop FE recomputing a server rule

- H1: tasksFromProfile takes the server's eligibleForHerregistratie decision
  instead of recomputing isHerregistratieEligible — the FE renders the rule,
  doesn't own it (ADR-0001). Policy reference impl kept for tests.
- M1: one shared runSubmit(fn, fallback) wrapper; the 4 submit-* commands keep
  only their payload mapping. +spec.
- M2: whenTag() kernel helper removes 10 repeated `as Extract<U,{tag}>` casts
  across the wizard/form components.

M4 (shared JA_NEE) folded into the upcoming i18n pass (clean dedup needs
$localize labels to sit in shared without breaking the English-shared-UI rule).
L1 already resolved by the restyle commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-06-27 13:48:35 +02:00
co-authored by Claude Opus 4.8
parent 94ffcf3d41
commit 1c65025fef
14 changed files with 118 additions and 68 deletions
@@ -1,18 +1,15 @@
import { Result, ok, err } from '@shared/kernel/fp';
import { Result } from '@shared/kernel/fp';
import { Valid } from '../domain/herregistratie.machine';
import { ApiClient } from '@shared/infrastructure/api-client';
import { problemDetail } from '@shared/infrastructure/api-error';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
/**
* Command: POST a herregistratie application to the backend (`/api/herregistraties`).
* The "uren must be > 0" rule now lives server-side; the backend returns a 422
* ProblemDetails on rejection, which we surface as the error.
* The "uren must be > 0" rule lives server-side; a 422 ProblemDetails is surfaced
* as the error by `runSubmit`.
*/
export async function submitHerregistratie(client: ApiClient, data: Valid): Promise<Result<string, void>> {
try {
export function submitHerregistratie(client: ApiClient, data: Valid): Promise<Result<string, void>> {
return runSubmit<void>(async () => {
await client.herregistraties({ uren: data.uren });
return ok(undefined);
} catch (e) {
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
}
}, SUBMIT_FAILED);
}
@@ -1,18 +1,15 @@
import { Result, ok, err } from '@shared/kernel/fp';
import { Result } from '@shared/kernel/fp';
import { ValidIntake } from '../domain/intake.machine';
import { ApiClient } from '@shared/infrastructure/api-client';
import { problemDetail } from '@shared/infrastructure/api-error';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
/**
* Command: POST the intake questionnaire to the backend (`/api/intakes`). The
* "uren must be > 0" rule now lives server-side; the backend returns a 422
* ProblemDetails on rejection, which we surface as the error.
* "uren must be > 0" rule lives server-side; a 422 ProblemDetails is surfaced as
* the error by `runSubmit`.
*/
export async function submitIntake(client: ApiClient, data: ValidIntake): Promise<Result<string, void>> {
try {
export function submitIntake(client: ApiClient, data: ValidIntake): Promise<Result<string, void>> {
return runSubmit<void>(async () => {
await client.intakes({ uren: data.uren });
return ok(undefined);
} catch (e) {
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
}
}, SUBMIT_FAILED);
}
@@ -5,6 +5,7 @@ import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.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 } from '@herregistratie/domain/herregistratie.machine';
import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie';
@@ -71,13 +72,13 @@ export class HerregistratieWizardComponent {
readonly stepLabels = ['Werkervaring', 'Nascholing'];
private stepTitles = ['Werkervaring (afgelopen 5 jaar)', 'Nascholing'];
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<WizardState, { tag: 'Editing' }>) : null));
private editing = computed(() => whenTag(this.state(), 'Editing'));
protected step = computed(() => this.editing()?.step ?? 1);
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<WizardState, { tag: 'Failed' }>).error : ''));
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
// --- Presentational wiring for the shared wizard shell ---------------------
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
@@ -8,6 +8,7 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import {
IntakeState,
@@ -126,7 +127,7 @@ export class IntakeWizardComponent {
readonly state = this.store.model;
readonly dispatch = this.store.dispatch;
private answering = computed(() => (this.state().tag === 'Answering' ? (this.state() as Extract<IntakeState, { tag: 'Answering' }>) : null));
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);
@@ -136,7 +137,7 @@ export class IntakeWizardComponent {
protected scholingThreshold = computed(() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT);
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<IntakeState, { tag: 'Failed' }>).error : ''));
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
// --- Presentational wiring for the shared wizard shell ---------------------
readonly stepLabels = ['Buitenland', 'Werk', 'Controle'];