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:
2026-06-27 13:48:35 +02:00
parent 94ffcf3d41
commit 1c65025fef
14 changed files with 118 additions and 68 deletions

View File

@@ -1,22 +1,20 @@
import { Result, ok, err } from '@shared/kernel/fp';
import { Result } from '@shared/kernel/fp';
import { Valid } from '@registratie/domain/change-request.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 an address change to the backend (`/api/v1/change-requests`),
* which re-validates and returns a reference. Same shape as the other submit
* commands — a `Result`, never a thrown error — so the form's reduce can branch.
*/
export async function submitChangeRequest(client: ApiClient, data: Valid): Promise<Result<string, string>> {
try {
export function submitChangeRequest(client: ApiClient, data: Valid): Promise<Result<string, string>> {
return runSubmit(async () => {
const res = await client.changeRequests({
straat: data.straat,
postcode: data.postcode,
woonplaats: data.woonplaats,
});
return ok(res.referentie ?? '');
} catch (e) {
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
}
return res.referentie ?? '';
}, SUBMIT_FAILED);
}

View File

@@ -1,20 +1,17 @@
import { Result, ok, err } from '@shared/kernel/fp';
import { Result } from '@shared/kernel/fp';
import { ValidRegistratie } from '@registratie/domain/registratie-wizard.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 completed registration to the backend (`/api/registrations`),
* which re-validates and decides. The business rule that a manually entered
* diploma cannot be auto-verified now lives server-side; the backend returns it as
* a 422 ProblemDetails, which we surface as the error. On success it yields the
* server-generated confirmation reference (PRD §9).
* which re-validates and decides. The rule that a manually entered diploma cannot
* be auto-verified lives server-side (surfaced as a 422 by `runSubmit`). On
* success it yields the server-generated confirmation reference (PRD §9).
*/
export async function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise<Result<string, string>> {
try {
export function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise<Result<string, string>> {
return runSubmit(async () => {
const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst });
return ok(res.referentie ?? '');
} catch (e) {
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
}
return res.referentie ?? '';
}, SUBMIT_FAILED);
}

View File

@@ -12,21 +12,20 @@ const base: Registration = {
};
describe('tasksFromProfile', () => {
it('offers herregistratie when within the deadline window', () => {
const tasks = tasksFromProfile(base, new Date('2026-06-27'));
it('offers herregistratie when the server says eligible, with the formatted deadline', () => {
const tasks = tasksFromProfile(base, true);
expect(tasks).toHaveLength(1);
expect(tasks[0].to).toBe('/herregistratie');
expect(tasks[0].description).toContain('31 december 2026');
});
it('offers nothing when the deadline is far away', () => {
const far: Registration = { ...base, status: { tag: 'Geregistreerd', herregistratieDatum: '2030-12-31' } };
expect(tasksFromProfile(far, new Date('2026-06-27'))).toHaveLength(0);
it('offers nothing when the server says not eligible', () => {
expect(tasksFromProfile(base, false)).toHaveLength(0);
});
it('surfaces a notice for a suspended registration', () => {
it('surfaces a notice for a suspended registration (independent of eligibility)', () => {
const reg: Registration = { ...base, status: { tag: 'Geschorst', geschorstTot: '2027-01-01', reden: 'Onderzoek' } };
const tasks = tasksFromProfile(reg, new Date('2026-06-27'));
const tasks = tasksFromProfile(reg, false);
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toContain('geschorst');
expect(tasks[0].description).toBe('Onderzoek');
@@ -34,7 +33,7 @@ describe('tasksFromProfile', () => {
it('surfaces a notice for a struck-off registration', () => {
const reg: Registration = { ...base, status: { tag: 'Doorgehaald', doorgehaaldOp: '2025-01-01', reden: 'Op eigen verzoek' } };
const tasks = tasksFromProfile(reg, new Date('2026-06-27'));
const tasks = tasksFromProfile(reg, false);
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toContain('doorgehaald');
});

View File

@@ -1,5 +1,5 @@
import { Registration } from './registration';
import { herregistratieDeadline, isHerregistratieEligible } from './registration.policy';
import { herregistratieDeadline } from './registration.policy';
/**
* What the dashboard's "Wat moet ik regelen" list needs. Pure presentation data
@@ -17,15 +17,15 @@ function formatNL(d: Date): string {
}
/**
* Derive the open tasks for a professional from their registration (pure;
* `today` is injected so it's testable). Herregistratie within its window is the
* primary action; a suspended/struck-off registration surfaces as a notice-task.
* Reuses the same rules the detail/summary use (registration.policy).
* Derive the open tasks for a professional (pure). Eligibility is the server's
* decision (`decisions.eligibleForHerregistratie`), passed in — the FE renders it,
* it does not recompute the rule (ADR-0001). The deadline is still formatted
* client-side for the task copy (presentation, not a rule).
*/
export function tasksFromProfile(reg: Registration, today: Date): PortalTask[] {
export function tasksFromProfile(reg: Registration, eligibleForHerregistratie: boolean): PortalTask[] {
const tasks: PortalTask[] = [];
if (isHerregistratieEligible(reg, today)) {
if (eligibleForHerregistratie) {
const deadline = herregistratieDeadline(reg);
tasks.push({
title: 'Vraag uw herregistratie aan',

View File

@@ -4,6 +4,7 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { AddressFieldsComponent, AdresValue, AdresErrors } from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine';
import { submitChangeRequest } from '@registratie/application/submit-change-request';
import { ApiClient } from '@shared/infrastructure/api-client';
@@ -55,10 +56,10 @@ export class ChangeRequestFormComponent {
readonly state = this.store.model;
protected dispatch = this.store.dispatch;
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<State, { tag: 'Editing' }>) : null));
private editing = computed(() => whenTag(this.state(), 'Editing'));
protected errors = computed<AdresErrors>(() => this.editing()?.errors ?? {});
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<State, { tag: 'Failed' }>).error : ''));
protected referentie = computed(() => (this.state().tag === 'Submitted' ? (this.state() as Extract<State, { tag: 'Submitted' }>).referentie : ''));
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
protected referentie = computed(() => whenTag(this.state(), 'Submitted')?.referentie ?? '');
/** The address shown in the fields — the live draft while editing, the parsed
data while submitting/failed (so the user sees what they sent). */

View File

@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core';
import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { LinkComponent } from '@shared/ui/link/link.component';
@@ -103,10 +103,15 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
})
export class DashboardPage {
protected store = inject(BigProfileStore);
private readonly today = new Date();
/** Server-computed eligibility (rendered, not recomputed). */
private readonly eligible = computed(() => {
const d = this.store.decisions();
return d.tag === 'Success' && d.value.eligibleForHerregistratie;
});
protected tasksFor(reg: Registration) {
return tasksFromProfile(reg, this.today);
return tasksFromProfile(reg, this.eligible());
}
/** Portal sections (navigation, not actions). */

View File

@@ -11,6 +11,7 @@ import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/
import { ASYNC } from '@shared/ui/async/async.component';
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { RemoteData, fromResource } from '@shared/application/remote-data';
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';
@@ -180,13 +181,13 @@ export class RegistratieWizardComponent {
readonly state = this.store.model;
readonly dispatch = this.store.dispatch;
private invullen = computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract<RegistratieState, { tag: 'Invullen' }>) : null));
private invullen = computed(() => whenTag(this.state(), 'Invullen'));
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]);
protected referentie = computed(() => (this.state().tag === 'Ingediend' ? (this.state() as Extract<RegistratieState, { tag: 'Ingediend' }>).referentie : ''));
protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract<RegistratieState, { tag: 'Mislukt' }>).error : ''));
protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
protected failedError = computed(() => whenTag(this.state(), 'Mislukt')?.error ?? '');
// --- Presentational wiring for the shared wizard shell ---------------------
protected primaryLabel = computed(() => (this.step() === 'controle' ? 'Registratie indienen' : 'Volgende'));