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,16 @@
import { inject } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { Valid } from '@behandeling/domain/besluit.machine';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { BesluitAdapter } from '@behandeling/infrastructure/besluit.adapter';
/**
* Command factory: binds the besluit adapter in an injection context and returns the
* submit function the decision form calls. Same field-initializer shape as
* `createStore` — the UI holds an application command, never the network client.
*/
export function createSubmitBesluit() {
const adapter = inject(BesluitAdapter);
return (id: string, data: Valid): Promise<Result<string, void>> =>
runSubmit(() => adapter.besluit(id, data), SUBMIT_FAILED);
}
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { BesluitState, reduce, initial } from './besluit.machine';
const editingWith = (besluit: string, toelichting = ''): BesluitState => ({
tag: 'Editing',
draft: { besluit, toelichting },
errors: {},
});
describe('besluit reduce', () => {
it('SetField updates the draft while editing', () => {
const s = reduce(initial, { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' });
expect(s.tag).toBe('Editing');
expect((s as Extract<BesluitState, { tag: 'Editing' }>).draft.besluit).toBe('Goedkeuren');
});
it('Submit with no besluit chosen stays Editing and reports a field error', () => {
const s = reduce(editingWith(''), { tag: 'Submit' });
expect(s.tag).toBe('Editing');
expect((s as Extract<BesluitState, { tag: 'Editing' }>).errors.besluit).toBeTruthy();
});
it('Submit Afwijzen without a toelichting stays Editing and reports a field error', () => {
const s = reduce(editingWith('Afwijzen'), { tag: 'Submit' });
expect(s.tag).toBe('Editing');
expect((s as Extract<BesluitState, { tag: 'Editing' }>).errors.toelichting).toBeTruthy();
});
it('Submit Goedkeuren with no toelichting moves to Submitting (optional there)', () => {
const s = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
expect((s as Extract<BesluitState, { tag: 'Submitting' }>).data).toEqual({
besluit: 'Goedkeuren',
toelichting: undefined,
});
});
it('Submit Afwijzen with a toelichting moves to Submitting with the trimmed value', () => {
const s = reduce(editingWith('Afwijzen', ' niet erkend '), { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
expect((s as Extract<BesluitState, { tag: 'Submitting' }>).data).toEqual({
besluit: 'Afwijzen',
toelichting: 'niet erkend',
});
});
it('SubmitConfirmed maps Submitting to Submitted', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
expect(reduce(submitting, { tag: 'SubmitConfirmed' }).tag).toBe('Submitted');
});
it('SubmitFailed maps Submitting to Failed with the error', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' });
});
it('Retry re-submits a failure', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting');
});
it('Reset returns to the initial editing state', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
});
});
@@ -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);
}
}
@@ -0,0 +1,18 @@
import { Injectable, inject } from '@angular/core';
import { ApiClient } from '@shared/infrastructure/api-client';
import { Valid } from '@behandeling/domain/besluit.machine';
/**
* Infrastructure adapter for recording a behandelaar's decision (WP-65b) — the single
* place its HTTP lives. No return value: a successful call means the server accepted
* the transition; the caller reloads `BeoordelingStore` to see the new status (the
* server, not this adapter, re-validates and is the authority).
*/
@Injectable({ providedIn: 'root' })
export class BesluitAdapter {
private client = inject(ApiClient);
async besluit(id: string, data: Valid): Promise<void> {
await this.client.besluit(id, { besluit: data.besluit, toelichting: data.toelichting });
}
}
@@ -10,12 +10,13 @@ import { ASYNC } from '@shared/ui/async/async.component';
import { BeoordelingStore } from '@behandeling/application/beoordeling.store';
import { detailRows } from '@behandeling/domain/beoordeling-view';
import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component';
import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.component';
/**
* Page: one aanvraag's beoordeling detail (WP-65, read side). The werkvoorraad list
* (WP-64) links here. Recording a decision is this WP's second half — for now the
* page only shows status/documents; `canBesluiten` is already carried by the view so
* the decision form has zero further backend round-trip to add.
* Page: one aanvraag's beoordeling detail (WP-65). The werkvoorraad list (WP-64) links
* here. `canBesluiten` (server-computed, ADR-0001) gates the decision form (WP-65b) —
* the page never recomputes the lifecycle itself. On a recorded decision the form emits
* `decided`, and the page just reloads (the server is the authority on the new status).
*/
@Component({
selector: 'app-beoordeling-page',
@@ -27,6 +28,7 @@ import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-docu
DataBlockComponent,
DataRowComponent,
BeoordelingDocumentenComponent,
BesluitFormComponent,
...ASYNC,
],
template: `
@@ -49,6 +51,11 @@ import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-docu
<app-data-block [heading]="documentenHeading" class="app-section">
<app-beoordeling-documenten [documenten]="v.documenten" />
</app-data-block>
@if (v.canBesluiten) {
<div class="app-section">
<app-besluit-form [id]="v.id" (decided)="reload()" />
</div>
}
}
</ng-template>
</app-async>
@@ -0,0 +1,140 @@
import { Component, computed, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine';
import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
/**
* Organism: the decision form (WP-65b) — goedkeuren/afwijzen/meer-info-opvragen. Same
* idiom as every other form in this house (`change-request-form`): all state in one
* signal driven by the pure `reduce` (besluit.machine.ts), submitted via a `submit-*`
* command returning `Result`. The server re-validates the transition and is the
* authority; on success this only emits `decided` — the page reloads the detail
* (BeoordelingStore.reload()), it doesn't guess the new state itself.
*/
@Component({
selector: 'app-besluit-form',
imports: [
FormsModule,
ButtonComponent,
HeadingComponent,
AlertComponent,
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
],
template: `
@if (state().tag === 'Submitted') {
<app-alert type="ok" i18n="@@besluit.success">Het besluit is vastgelegd.</app-alert>
} @else {
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
<form (ngSubmit)="onSubmit()" class="form-horizontal app-section">
<app-form-field
i18n-label="@@besluit.besluitLabel"
label="Besluit"
fieldId="besluit-keuze"
required
[error]="errors().besluit"
>
<app-radio-group
name="besluit-keuze"
[options]="BESLUIT_OPTIONS"
[invalid]="!!errors().besluit"
[ngModel]="besluit()"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'besluit', value: $event })"
[ngModelOptions]="{ standalone: true }"
/>
</app-form-field>
<app-form-field
i18n-label="@@besluit.toelichtingLabel"
label="Toelichting"
fieldId="besluit-toelichting"
[error]="errors().toelichting"
>
<app-text-input
inputId="besluit-toelichting"
[invalid]="!!errors().toelichting"
[ngModel]="toelichting()"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'toelichting', value: $event })"
name="toelichting"
[ngModelOptions]="{ standalone: true }"
/>
</app-form-field>
@if (failedError()) {
<app-alert type="error"
><ng-container i18n="@@besluit.failed">Het vastleggen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
}
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
{{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}
</app-button>
</form>
}
`,
})
export class BesluitFormComponent {
private submit = createSubmitBesluit();
private store = createStore<BesluitState, BesluitMsg>(initial, reduce);
id = input.required<string>();
decided = output<void>();
/** Optional seed so Storybook / tests can mount any state directly. */
seed = input<BesluitState>(initial);
readonly state = this.store.model;
protected dispatch = this.store.dispatch;
protected readonly BESLUIT_OPTIONS: RadioOption[] = [
{ value: 'Goedkeuren', label: $localize`:@@besluit.optie.goedkeuren:Goedkeuren` },
{ value: 'Afwijzen', label: $localize`:@@besluit.optie.afwijzen:Afwijzen` },
{
value: 'MeerInfoOpvragen',
label: $localize`:@@besluit.optie.meerInfoOpvragen:Meer informatie opvragen`,
},
];
protected readonly submitLabel = $localize`:@@besluit.submit:Besluit vastleggen`;
protected readonly submitBezigLabel = $localize`:@@besluit.submitBezig:Bezig met vastleggen…`;
private editing = computed(() => whenTag(this.state(), 'Editing'));
protected errors = computed(() => this.editing()?.errors ?? {});
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
protected besluit = computed(() => this.editing()?.draft.besluit ?? '');
protected toelichting = computed(() => this.editing()?.draft.toelichting ?? '');
constructor() {
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
}
onSubmit() {
this.dispatch({ tag: 'Submit' });
this.runIfSubmitting();
}
/** Effect: when we entered Submitting, call the command, then dispatch the outcome. */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
const r = await this.submit(this.id(), s.data);
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.decided.emit();
} else {
this.dispatch({ tag: 'SubmitFailed', error: r.error });
}
}
}
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { BesluitFormComponent } from './besluit-form.component';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { Valid } from '@behandeling/domain/besluit.machine';
const validData: Valid = { besluit: 'Afwijzen', toelichting: 'Diploma niet erkend' };
const meta: Meta<BesluitFormComponent> = {
title: 'Domein/Behandeling/Besluit Form',
component: BesluitFormComponent,
// The form injects ApiClient (over HttpClient) for the submit command.
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
args: { id: 'aanvraag-1' },
};
export default meta;
type Story = StoryObj<BesluitFormComponent>;
// One render per state of the machine.
export const Empty: Story = {
args: { seed: { tag: 'Editing', draft: { besluit: '', toelichting: '' }, errors: {} } },
};
export const WithErrors: Story = {
args: {
seed: {
tag: 'Editing',
draft: { besluit: 'Afwijzen', toelichting: '' },
errors: { toelichting: 'Geef een toelichting.' },
},
},
};
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' } },
};