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
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:
@@ -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' } },
|
||||
};
|
||||
Reference in New Issue
Block a user