feat(behandelportal): WP-65b beoordeling besluit (decision write)
CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 56s
CI / frontend (pull_request) Successful in 2m36s
CI / storybook-a11y (pull_request) Failing after 3m19s
CI / backend (pull_request) Failing after 1m55s
CI / api-client-drift (pull_request) Canceled after 0s
CI / e2e (pull_request) Canceled after 40s
CI / semgrep (pull_request) Canceled after 24s

Adds POST /beoordeling/{id}/besluit: a Besluit enum (Goedkeuren/Afwijzen/
MeerInfoOpvragen) backed by new Aanvraag.BesluitStatus/BesluitToelichting
columns, gated by the same BeoordelingRules.CanDecide the read side's
canBesluiten flag already uses (409 on an illegal transition, 400 on a
missing required toelichting). Mappers.ToStatusDto gains the "a recorded
decision wins" branch. FE: besluit.machine.ts + besluit-form organism
(same form idiom as change-request-form), wired into the beoordeling page
behind the server's canBesluiten flag.

Completes WP-65 (65a + 65b) — verified end-to-end against a running
backend (werkvoorraad -> beoordeling -> besluit -> status reflected back).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-03 09:46:20 +02:00
co-authored by Claude Sonnet 5
parent 4133b30e5d
commit af8a011819
20 changed files with 1303 additions and 20 deletions
@@ -0,0 +1,92 @@
import { Result, assertNever } from '@shared/kernel/fp';
/** The three actions the beoordeling screen offers a behandelaar (WP-65b) — mirrors the
backend's `Besluit` enum member names 1:1 (the wire convention: a string, not a raw
enum — see `RecordBesluitRequest`). */
const BESLUIT_TAGS = ['Goedkeuren', 'Afwijzen', 'MeerInfoOpvragen'] as const;
export type BesluitTag = (typeof BESLUIT_TAGS)[number];
function isBesluitTag(v: string): v is BesluitTag {
return (BESLUIT_TAGS as readonly string[]).includes(v);
}
/** What the user picked (raw, possibly empty while nothing is selected yet). */
export interface Draft {
besluit: string;
toelichting: string;
}
/** After parsing — besluit is the narrow tag; toelichting is present only when given
(required for Afwijzen/MeerInfoOpvragen, optional for Goedkeuren — enforced by validate). */
export interface Valid {
besluit: BesluitTag;
toelichting?: string;
}
export type Errors = Partial<Record<keyof Draft, string>>;
/** The decision form as one tagged union — same idiom as every other form in this
house (form-machine skill), single-step. draft/errors exist only while Editing. */
export type BesluitState =
| { tag: 'Editing'; draft: Draft; errors: Errors }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid }
| { tag: 'Failed'; data: Valid; error: string };
export const initial: BesluitState = {
tag: 'Editing',
draft: { besluit: '', toelichting: '' },
errors: {},
};
function validate(draft: Draft): Result<Errors, Valid> {
if (!isBesluitTag(draft.besluit)) {
return {
ok: false,
error: { besluit: $localize`:@@besluit.error.verplicht:Kies een besluit.` },
};
}
const toelichting = draft.toelichting.trim();
if (draft.besluit !== 'Goedkeuren' && toelichting === '') {
return {
ok: false,
error: {
toelichting: $localize`:@@besluit.error.toelichtingVerplicht:Geef een toelichting.`,
},
};
}
return { ok: true, value: { besluit: draft.besluit, toelichting: toelichting || undefined } };
}
export type BesluitMsg =
| { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Reset' }
| { tag: 'Seed'; state: BesluitState }; // mount a specific state (stories/tests)
export function reduce(s: BesluitState, m: BesluitMsg): BesluitState {
switch (m.tag) {
case 'SetField':
return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;
case 'Submit': {
if (s.tag !== 'Editing') return s;
const r = validate(s.draft);
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
}
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 'Reset':
return initial;
case 'Seed':
return m.state;
default:
return assertNever(m);
}
}