feat(behandelportal): WP-64 werkvoorraad (queue) screen
CI / changes (pull_request) Successful in 15s
CI / lint (pull_request) Successful in 57s
CI / frontend (pull_request) Successful in 2m36s
CI / storybook-a11y (pull_request) Failing after 3m14s
CI / backend (pull_request) Successful in 2m1s
CI / semgrep (pull_request) Successful in 1m10s
CI / e2e (pull_request) Successful in 3m3s
CI / api-client-drift (pull_request) Successful in 2m1s

New GET /werkvoorraad endpoint lists aanvragen still open (Ingediend/InBehandeling),
gated by the medewerker capability (CanBeoordelen) rather than the admin role — reuses
the existing ApplicationSummaryDto, no new DTO. GET /me now surfaces aanvraag:beoordelen
for a behandelaar so the FE can gate with the same AccessStore/capabilityGuard idiom
every other page uses.

FE: a behandeling domain type deliberately narrower than ssp's full AanvraagStatus
union (only the two open tags — illegal states unrepresentable), composed into a
werkvoorraad-list organism from existing shared/ui molecules. Replaces WP-61's
scaffold placeholder as the app's real landing page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-02 22:02:35 +02:00
co-authored by Claude Sonnet 5
parent e7156c5132
commit fe69caee63
22 changed files with 958 additions and 198 deletions
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { werkvoorraadRow, statusLabel, TYPE_LABELS } from './werkvoorraad-item-view';
import { WerkvoorraadItem } from './werkvoorraad-item';
const base: Omit<WerkvoorraadItem, 'status'> = {
id: '1',
type: 'herregistratie',
owner: '111222333',
submittedAt: '2024-05-12',
};
describe('werkvoorraadRow', () => {
it('heading is the type, subtitle carries the owner BSN', () => {
const row = werkvoorraadRow({
...base,
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
});
expect(row.heading).toBe(TYPE_LABELS.herregistratie);
expect(row.subtitle).toContain('111222333');
});
it('status line carries the status label, reference and submit date', () => {
const row = werkvoorraadRow({
...base,
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
});
expect(row.status).toContain(
statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }),
);
expect(row.status).toContain('R1');
expect(row.status).toContain('12 mei 2024');
});
it('manual review is called out distinctly from an automatic InBehandeling', () => {
const manual = statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: true });
const auto = statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false });
expect(manual).not.toBe(auto);
});
it('a missing submit date leaves no dangling separator', () => {
const row = werkvoorraadRow({
...base,
submittedAt: undefined,
status: { tag: 'Ingediend', referentie: 'R9' },
});
expect(row.status).toBe(`${statusLabel({ tag: 'Ingediend', referentie: 'R9' })} · R9`);
});
});
@@ -0,0 +1,44 @@
import { formatDatumNl } from '@shared/kernel/datum';
import { WerkvoorraadItem, WerkvoorraadStatus, AanvraagType } from './werkvoorraad-item';
/** View-model mapping for a queue row: type/status → the fields for a CIBG
"aanvragen" row. Pure, no Angular — the UI renders these, it does not derive them. */
export const TYPE_LABELS: Record<AanvraagType, string> = {
registratie: $localize`:@@werkvoorraad.type.registratie:Inschrijving`,
herregistratie: $localize`:@@werkvoorraad.type.herregistratie:Herregistratie`,
intake: $localize`:@@werkvoorraad.type.intake:Herregistratie-intake`,
};
export function statusLabel(status: WerkvoorraadStatus): string {
switch (status.tag) {
case 'Ingediend':
return $localize`:@@werkvoorraad.status.ingediend:Ingediend`;
case 'InBehandeling':
return status.manual
? $localize`:@@werkvoorraad.status.inBehandelingHandmatig:In behandeling (handmatig)`
: $localize`:@@werkvoorraad.status.inBehandeling:In behandeling`;
}
}
export interface WerkvoorraadRow {
heading: string;
subtitle: string;
status: string;
}
/** Fields for one queue row: type as heading, owner (BSN) as subtitle, status +
reference + submit date as the status line. */
export function werkvoorraadRow(item: WerkvoorraadItem): WerkvoorraadRow {
const parts = [statusLabel(item.status), item.status.referentie];
if (item.submittedAt) {
parts.push(
$localize`:@@werkvoorraad.row.ingediend:ingediend op ${formatDatumNl(item.submittedAt)}:datum:`,
);
}
return {
heading: TYPE_LABELS[item.type],
subtitle: $localize`:@@werkvoorraad.row.bsn:BSN ${item.owner}:bsn:`,
status: parts.join(' · '),
};
}
@@ -0,0 +1,23 @@
/**
* A queue entry as the behandelportal sees it (WP-64) — the parsed, domain-side view
* of the backend's cross-owner `GET /werkvoorraad`. Pure types, no Angular.
*
* The status union is narrower than the SSP's full `AanvraagStatus` (ssp's
* `registratie/domain/aanvraag.ts`): the backend only ever puts a case in the queue
* while it is still open (`Ingediend`/`InBehandeling`), so a queue item literally
* cannot be `Concept`/`Goedgekeurd`/`Afgewezen` — illegal states unrepresentable.
*/
export type AanvraagType = 'registratie' | 'herregistratie' | 'intake';
export type WerkvoorraadStatus =
| { tag: 'Ingediend'; referentie: string }
| { tag: 'InBehandeling'; referentie: string; manual: boolean };
export interface WerkvoorraadItem {
id: string;
type: AanvraagType;
status: WerkvoorraadStatus;
/** The BSN of the citizen the aanvraag belongs to — always populated (cross-owner list). */
owner: string;
submittedAt?: string;
}