feat(behandelportal): WP-65a beoordeling detail (read) + fix unreachable medewerker login
CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 54s
CI / frontend (pull_request) Successful in 2m38s
CI / storybook-a11y (pull_request) Failing after 3m28s
CI / backend (pull_request) Successful in 2m1s
CI / semgrep (pull_request) Successful in 1m9s
CI / e2e (pull_request) Successful in 2m55s
CI / api-client-drift (pull_request) Successful in 2m1s

New GET /beoordeling/{id} shows one aanvraag's status, linked documents, and a
canBesluiten decision flag, gated by the same CanBeoordelen capability as the
werkvoorraad list. Reads through IZaakSource.ListCases rather than a new seam
method (WP-66 needs one anyway for the real write); owner BSN is masked.

Fixes a real gap found while wiring this up: the behandelportal's login was still
WP-61's copied citizen/BSN DigiD flow, so nothing ever sent X-Medewerker and the
werkvoorraad screen (WP-64) always denied in a real browser. A dev-only
medewerkerInterceptor (mirrors the existing ?role= stand-in as ?rollen=) fixes that.

WP-65's own Risks note authorized splitting read from write across sessions given
its size; this is the read half. The decision-recording mutation is next (65b).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-03 09:01:09 +02:00
co-authored by Claude Sonnet 5
parent fe69caee63
commit 4133b30e5d
29 changed files with 1195 additions and 58 deletions
+6 -1
View File
@@ -14,6 +14,7 @@ import localeEn from '@angular/common/locales/en';
import { routes } from './app.routes';
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
import { medewerkerInterceptor } from '@auth/infrastructure/medewerker.interceptor';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { SESSION_PORT } from '@shared/application/session.port';
import { SessionStore } from '@auth/application/session.store';
@@ -52,7 +53,11 @@ export const appConfig: ApplicationConfig = {
),
// Dev-only: the ?scenario= toggle must never reach a production build, where
// a query param could otherwise force errors on the live app.
provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor, roleInterceptor] : [])),
provideHttpClient(
withInterceptors(
isDevMode() ? [scenarioInterceptor, roleInterceptor, medewerkerInterceptor] : [],
),
),
provideApiClient(),
{ provide: SESSION_PORT, useExisting: SessionStore },
// Per-bundle locale: the localize build sets `$localize.locale` ('nl'/'en'); the
@@ -18,6 +18,14 @@ export const routes: Routes = [
loadComponent: () =>
import('@behandeling/ui/werkvoorraad.page').then((m) => m.WerkvoorraadPage),
},
{
path: 'aanvraag/:id',
// Same capability the werkvoorraad list itself is gated by (WP-64/65) — the
// detail page is reachable only from a row already filtered to that capability.
canActivate: [capabilityGuard('aanvraag:beoordelen')],
loadComponent: () =>
import('@behandeling/ui/beoordeling.page').then((m) => m.BeoordelingPage),
},
{
path: 'beheer/stamdata',
// Admin-only stamdata maintenance editor (ADR-0004): capabilityGuard denies-by-default
@@ -0,0 +1,19 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { MEDEWERKER_ID, currentRollen } from './medewerker';
/**
* Dev-only: stamps every API request as the fixed stand-in medewerker (`X-Medewerker`/
* `X-Rollen`), so `StubIdentityProvider` resolves a `MedewerkerCaller` instead of falling
* through to its zorgverlener default. Unlike `roleInterceptor`'s allow-listed endpoints,
* this is the app's whole identity — every request needs it, since this app has no
* citizen-scoped screens to keep separate (see `CallerIdentity.Zorgverlener()`'s guard: a
* medewerker hitting a citizen-scoped SSP endpoint would 500, but no such endpoint exists
* here). Real employee-SSO login is out of scope for this POC (ADR-0002 §3 — the two
* apps' login flows are expected to diverge; this stand-in is that flow's placeholder).
*/
export const medewerkerInterceptor: HttpInterceptorFn = (req, next) =>
req.url.includes('/api/v1/')
? next(
req.clone({ setHeaders: { 'X-Medewerker': MEDEWERKER_ID, 'X-Rollen': currentRollen() } }),
)
: next(req);
@@ -0,0 +1,23 @@
/**
* Dev-only medewerker rollen stand-in (the reading MECHANISM — mirrors
* `@shared/infrastructure/role.ts`'s `?role=` idiom, but app-local: `auth` is
* deliberately not shared between ssp and behandelportal, ADR-0002 §3). Until a real
* employee-SSO login exists, every request from this app identifies as one fixed
* medewerker; `?rollen=` lets a dev exercise the deny path (`?rollen=geen`) the same
* way `?role=` exercises ssp's role-gated pages.
*
* **Sticky within the tab (sessionStorage)**, same reasoning as `currentRole()`: a
* plain in-app navigation drops the query param, which would silently revert to the
* default and mask a deliberately-chosen `?rollen=geen`.
*/
const STORAGE_KEY = 'dev-rollen';
export const MEDEWERKER_ID = 'medewerker-1';
export function currentRollen(): string {
const fromUrl = new URLSearchParams(window.location.search).get('rollen');
if (fromUrl !== null) {
sessionStorage.setItem(STORAGE_KEY, fromUrl);
return fromUrl;
}
return sessionStorage.getItem(STORAGE_KEY) ?? 'behandelaar';
}
@@ -0,0 +1,42 @@
import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { BeoordelingView } from '@behandeling/domain/beoordeling';
import {
BeoordelingAdapter,
parseBeoordelingView,
} from '@behandeling/infrastructure/beoordeling.adapter';
type Err = Error | undefined;
/** One aanvraag's beoordeling detail (WP-65) — a root singleton like `WerkvoorraadStore`.
Keyed by id: navigating to a different case resets to Loading. */
@Injectable({ providedIn: 'root' })
export class BeoordelingStore {
private adapter = inject(BeoordelingAdapter);
private id: string | undefined;
private state = signal<RemoteData<Err, BeoordelingView>>({ tag: 'Loading' });
readonly view = this.state.asReadonly();
async load(id: string) {
if (this.id !== id) this.state.set({ tag: 'Loading' });
this.id = id;
try {
const parsed = parseBeoordelingView(await this.adapter.get(id));
// A navigation to a different case may have started while this one was in flight.
if (this.id !== id) return;
this.state.set(
parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) },
);
} catch (e) {
if (this.id !== id) return;
this.state.set({ tag: 'Failure', error: e as Error });
}
}
reload() {
if (this.id) void this.load(this.id);
}
}
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { statusLabel, detailRows, TYPE_LABELS } from './beoordeling-view';
import { BeoordelingView } from './beoordeling';
const base: Omit<BeoordelingView, 'status'> = {
id: '1',
type: 'herregistratie',
owner: '*****2333',
submittedAt: '2024-05-12',
documenten: [],
canBesluiten: true,
};
describe('statusLabel', () => {
it('labels every tag distinctly', () => {
const labels = [
statusLabel({ tag: 'Ingediend', referentie: 'R1' }),
statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }),
statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: true }),
statusLabel({ tag: 'MeerInfoGevraagd', referentie: 'R1', reden: 'x' }),
statusLabel({ tag: 'Goedgekeurd', referentie: 'R1' }),
statusLabel({ tag: 'Afgewezen', referentie: 'R1', reden: 'x' }),
];
expect(new Set(labels).size).toBe(labels.length);
});
});
describe('detailRows', () => {
it('lists soort/status/referentie/eigenaar/ingediend', () => {
const rows = detailRows({
...base,
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
});
const values = rows.map((r) => r.value);
expect(values).toContain(TYPE_LABELS.herregistratie);
expect(values).toContain('R1');
expect(values).toContain(base.owner);
expect(rows.length).toBe(5);
});
it('adds a reden row for Afgewezen and MeerInfoGevraagd only', () => {
const afgewezen = detailRows({
...base,
status: { tag: 'Afgewezen', referentie: 'R1', reden: 'Onvoldoende uren' },
});
expect(afgewezen.length).toBe(6);
expect(afgewezen.map((r) => r.value)).toContain('Onvoldoende uren');
const meerInfo = detailRows({
...base,
status: { tag: 'MeerInfoGevraagd', referentie: 'R1', reden: 'Diploma ontbreekt' },
});
expect(meerInfo.length).toBe(6);
const goedgekeurd = detailRows({ ...base, status: { tag: 'Goedgekeurd', referentie: 'R1' } });
expect(goedgekeurd.length).toBe(5);
});
});
@@ -0,0 +1,50 @@
import { formatDatumNl } from '@shared/kernel/datum';
import { AanvraagType } from './werkvoorraad-item';
import { BeoordelingStatus, BeoordelingView } from './beoordeling';
/** View-model mapping shared by the werkvoorraad list (WP-64) and the beoordeling
detail screen (WP-65): type/status → labels. Pure, no Angular. Lives here (not in
`werkvoorraad-item-view.ts`) because `BeoordelingStatus` is the wider of the two
status unions — `werkvoorraad-item-view.ts` re-exports these for its own use. */
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: BeoordelingStatus): 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`;
case 'MeerInfoGevraagd':
return $localize`:@@beoordeling.status.meerInfoGevraagd:Meer informatie gevraagd`;
case 'Goedgekeurd':
return $localize`:@@beoordeling.status.goedgekeurd:Goedgekeurd`;
case 'Afgewezen':
return $localize`:@@beoordeling.status.afgewezen:Afgewezen`;
}
}
/** Key/value rows for the beoordeling detail page (CIBG Datablock). */
export function detailRows(view: BeoordelingView): { key: string; value: string }[] {
const s = view.status;
const rows = [
{ key: $localize`:@@beoordeling.detail.soort:Soort aanvraag`, value: TYPE_LABELS[view.type] },
{ key: $localize`:@@beoordeling.detail.status:Status`, value: statusLabel(s) },
{ key: $localize`:@@beoordeling.detail.referentie:Referentie`, value: s.referentie },
{ key: $localize`:@@beoordeling.detail.eigenaar:Eigenaar (BSN)`, value: view.owner },
{
key: $localize`:@@beoordeling.detail.ingediend:Ingediend op`,
value: view.submittedAt ? formatDatumNl(view.submittedAt) : '—',
},
];
if (s.tag === 'Afgewezen' || s.tag === 'MeerInfoGevraagd') {
rows.push({ key: $localize`:@@beoordeling.detail.reden:Reden`, value: s.reden });
}
return rows;
}
@@ -0,0 +1,33 @@
import { AanvraagType } from './werkvoorraad-item';
/**
* A case's full status lifecycle as the beoordeling detail screen sees it (WP-65) —
* wider than `WerkvoorraadStatus` (WP-64), which only ever sees the two "still open"
* tags. This is the same five-tag union ssp's `AanvraagStatus` models (minus `Concept`
* — the detail endpoint 404s a Concept, it isn't a case a behandelaar can treat yet).
*/
export type BeoordelingStatus =
| { tag: 'Ingediend'; referentie: string }
| { tag: 'InBehandeling'; referentie: string; manual: boolean }
| { tag: 'MeerInfoGevraagd'; referentie: string; reden: string }
| { tag: 'Goedgekeurd'; referentie: string }
| { tag: 'Afgewezen'; referentie: string; reden: string };
export interface BeoordelingDocument {
documentId: string;
categoryId: string;
fileName: string;
}
export interface BeoordelingView {
id: string;
type: AanvraagType;
status: BeoordelingStatus;
/** The BSN of the citizen the aanvraag belongs to — masked by the server. */
owner: string;
submittedAt?: string;
documenten: BeoordelingDocument[];
/** Decision flag (ADR-0001): the server computes whether a decision may be recorded;
the FE renders it, it never recomputes the lifecycle. */
canBesluiten: boolean;
}
@@ -1,25 +1,12 @@
import { formatDatumNl } from '@shared/kernel/datum';
import { WerkvoorraadItem, WerkvoorraadStatus, AanvraagType } from './werkvoorraad-item';
import { WerkvoorraadItem } from './werkvoorraad-item';
import { TYPE_LABELS, statusLabel } from './beoordeling-view';
/** 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`;
}
}
"aanvragen" row. Pure, no Angular — the UI renders these, it does not derive them.
`TYPE_LABELS`/`statusLabel` live in `./beoordeling-view` (the wider status union) and
are re-exported here so existing imports of this file keep working. */
export { TYPE_LABELS, statusLabel };
export interface WerkvoorraadRow {
heading: string;
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { parseBeoordelingStatus, parseBeoordelingView } from './beoordeling.adapter';
const view = {
aanvraag: {
id: 'a1',
type: 'registratie',
status: { tag: 'InBehandeling', referentie: 'BIG-1', manual: true },
documentIds: ['d1'],
createdAt: '2026-07-01T10:00:00Z',
updatedAt: '2026-07-01T10:05:00Z',
submittedAt: '2026-07-01T10:05:00Z',
owner: '*****2333',
},
documenten: [{ documentId: 'd1', categoryId: 'diploma', fileName: 'diploma.pdf' }],
decisions: { canBesluiten: true },
};
describe('parseBeoordelingStatus', () => {
it('parses each tag with its required fields', () => {
expect(parseBeoordelingStatus({ tag: 'Ingediend', referentie: 'BIG-1' }).ok).toBe(true);
expect(
parseBeoordelingStatus({ tag: 'InBehandeling', referentie: 'BIG-1', manual: false }).ok,
).toBe(true);
expect(
parseBeoordelingStatus({ tag: 'MeerInfoGevraagd', referentie: 'BIG-1', reden: 'x' }).ok,
).toBe(true);
expect(parseBeoordelingStatus({ tag: 'Goedgekeurd', referentie: 'BIG-1' }).ok).toBe(true);
expect(parseBeoordelingStatus({ tag: 'Afgewezen', referentie: 'BIG-1', reden: 'x' }).ok).toBe(
true,
);
});
it('rejects a missing status, unknown tag, and wrong-typed fields', () => {
expect(parseBeoordelingStatus(undefined).ok).toBe(false);
expect(parseBeoordelingStatus({ tag: 'Concept' } as never).ok).toBe(false);
expect(parseBeoordelingStatus({ tag: 'InBehandeling', referentie: 'BIG-1' }).ok).toBe(false);
});
});
describe('parseBeoordelingView', () => {
it('maps a valid DTO to domain', () => {
const r = parseBeoordelingView(view);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.type).toBe('registratie');
expect(r.value.documenten).toEqual([
{ documentId: 'd1', categoryId: 'diploma', fileName: 'diploma.pdf' },
]);
expect(r.value.canBesluiten).toBe(true);
});
it('rejects a missing owner, bad type, missing decisions, and non-objects', () => {
expect(parseBeoordelingView(null).ok).toBe(false);
expect(
parseBeoordelingView({ ...view, aanvraag: { ...view.aanvraag, owner: undefined } }).ok,
).toBe(false);
expect(
parseBeoordelingView({ ...view, aanvraag: { ...view.aanvraag, type: 'onbekend' } }).ok,
).toBe(false);
expect(parseBeoordelingView({ ...view, decisions: {} }).ok).toBe(false);
});
it('defaults an absent documenten list to empty', () => {
const r = parseBeoordelingView({ ...view, documenten: undefined });
expect(r.ok && r.value.documenten).toEqual([]);
});
});
@@ -0,0 +1,99 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import {
ApiClient,
BeoordelingViewDto,
AanvraagStatusDto,
} from '@shared/infrastructure/api-client';
import {
BeoordelingView,
BeoordelingStatus,
BeoordelingDocument,
} from '@behandeling/domain/beoordeling';
import { AanvraagType } from '@behandeling/domain/werkvoorraad-item';
/**
* Infrastructure adapter for the beoordeling detail read (WP-65) — the only place its
* HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response is validated +
* mapped to domain by the parse* boundary below.
*/
@Injectable({ providedIn: 'root' })
export class BeoordelingAdapter {
private client = inject(ApiClient);
get(id: string): Promise<BeoordelingViewDto> {
return this.client.beoordeling(id);
}
}
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
export function parseBeoordelingStatus(
s: AanvraagStatusDto | undefined,
): Result<string, BeoordelingStatus> {
if (!s || typeof s.tag !== 'string') return err('beoordeling: missing status');
switch (s.tag) {
case 'Ingediend':
if (typeof s.referentie !== 'string') return err('beoordeling: bad Ingediend status');
return ok({ tag: 'Ingediend', referentie: s.referentie });
case 'InBehandeling':
if (typeof s.referentie !== 'string' || typeof s.manual !== 'boolean')
return err('beoordeling: bad InBehandeling status');
return ok({ tag: 'InBehandeling', referentie: s.referentie, manual: s.manual });
case 'MeerInfoGevraagd':
if (typeof s.referentie !== 'string' || typeof s.reden !== 'string')
return err('beoordeling: bad MeerInfoGevraagd status');
return ok({ tag: 'MeerInfoGevraagd', referentie: s.referentie, reden: s.reden });
case 'Goedgekeurd':
if (typeof s.referentie !== 'string') return err('beoordeling: bad Goedgekeurd status');
return ok({ tag: 'Goedgekeurd', referentie: s.referentie });
case 'Afgewezen':
if (typeof s.referentie !== 'string' || typeof s.reden !== 'string')
return err('beoordeling: bad Afgewezen status');
return ok({ tag: 'Afgewezen', referentie: s.referentie, reden: s.reden });
default:
return err(`beoordeling: unknown status tag ${s.tag}`);
}
}
function parseDocument(json: unknown): Result<string, BeoordelingDocument> {
if (typeof json !== 'object' || json === null) return err('beoordeling: document not an object');
const d = json as { documentId?: unknown; categoryId?: unknown; fileName?: unknown };
if (typeof d.documentId !== 'string') return err('beoordeling: document missing documentId');
if (typeof d.categoryId !== 'string') return err('beoordeling: document missing categoryId');
if (typeof d.fileName !== 'string') return err('beoordeling: document missing fileName');
return ok({ documentId: d.documentId, categoryId: d.categoryId, fileName: d.fileName });
}
export function parseBeoordelingView(json: unknown): Result<string, BeoordelingView> {
if (typeof json !== 'object' || json === null) return err('beoordeling: not an object');
const dto = json as BeoordelingViewDto;
const a = dto.aanvraag;
if (!a || typeof a.id !== 'string') return err('beoordeling: missing aanvraag.id');
if (typeof a.type !== 'string' || !AANVRAAG_TYPES.includes(a.type))
return err(`beoordeling: bad type ${a.type}`);
if (typeof a.owner !== 'string' || !a.owner) return err('beoordeling: missing owner');
const status = parseBeoordelingStatus(a.status);
if (!status.ok) return status;
const documenten: BeoordelingDocument[] = [];
for (const item of dto.documenten ?? []) {
const parsed = parseDocument(item);
if (!parsed.ok) return parsed;
documenten.push(parsed.value);
}
if (typeof dto.decisions?.canBesluiten !== 'boolean')
return err('beoordeling: missing decisions.canBesluiten');
return ok({
id: a.id,
type: a.type as AanvraagType,
status: status.value,
owner: a.owner,
submittedAt: a.submittedAt,
documenten,
canBesluiten: dto.decisions.canBesluiten,
});
}
@@ -0,0 +1,30 @@
import { Component, input } from '@angular/core';
import { BeoordelingDocument } from '@behandeling/domain/beoordeling';
/** Organism: the documents linked to an aanvraag (WP-65) — plain links to the existing
(pre-existing, unauthenticated — same as ssp's own document previews) content
endpoint. No new shared atom: a context-local list, not a reusable building block. */
@Component({
selector: 'app-beoordeling-documenten',
template: `
@if (documenten().length === 0) {
<p class="app-text-subtle" i18n="@@beoordeling.documenten.leeg">Geen documenten.</p>
} @else {
<ul class="list-unstyled">
@for (doc of documenten(); track doc.documentId) {
<li>
<a
[href]="'/api/v1/uploads/' + doc.documentId + '/content'"
target="_blank"
rel="noopener"
>{{ doc.fileName }}</a
>
</li>
}
</ul>
}
`,
})
export class BeoordelingDocumentenComponent {
documenten = input.required<BeoordelingDocument[]>();
}
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { BeoordelingDocumentenComponent } from './beoordeling-documenten.component';
const meta: Meta<BeoordelingDocumentenComponent> = {
title: 'Domein/Behandeling/Beoordeling Documenten',
component: BeoordelingDocumentenComponent,
};
export default meta;
type Story = StoryObj<BeoordelingDocumentenComponent>;
export const Default: Story = {
args: {
documenten: [
{ documentId: 'd1', categoryId: 'diploma', fileName: 'diploma.pdf' },
{ documentId: 'd2', categoryId: 'identiteit', fileName: 'paspoort.pdf' },
],
},
};
export const Empty: Story = {
args: { documenten: [] },
};
@@ -0,0 +1,81 @@
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
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';
/**
* 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.
*/
@Component({
selector: 'app-beoordeling-page',
imports: [
PageShellComponent,
AlertComponent,
ButtonComponent,
SkeletonComponent,
DataBlockComponent,
DataRowComponent,
BeoordelingDocumentenComponent,
...ASYNC,
],
template: `
<app-page-shell [heading]="heading" backLink="/dashboard">
<app-async [data]="store.view()" (retryClicked)="reload()">
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="5" />
</ng-template>
<ng-template appAsyncError>
<app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
</ng-template>
<ng-template appAsyncLoaded>
@if (view(); as v) {
<app-data-block [heading]="detailHeading" class="app-section">
@for (row of rows(v); track row.key) {
<div app-data-row [key]="row.key" [value]="row.value"></div>
}
</app-data-block>
<app-data-block [heading]="documentenHeading" class="app-section">
<app-beoordeling-documenten [documenten]="v.documenten" />
</app-data-block>
}
</ng-template>
</app-async>
</app-page-shell>
`,
})
export class BeoordelingPage {
protected store = inject(BeoordelingStore);
private id = inject(ActivatedRoute).snapshot.paramMap.get('id') ?? '';
protected heading = $localize`:@@beoordeling.heading:Aanvraag`;
protected detailHeading = $localize`:@@beoordeling.detail.heading:Aanvraaggegevens`;
protected documentenHeading = $localize`:@@beoordeling.documenten.heading:Documenten`;
protected failedText = $localize`:@@beoordeling.failed:De aanvraag kon niet worden geladen.`;
protected retryText = $localize`:@@beoordeling.retry:Opnieuw proberen`;
protected rows = detailRows;
protected readonly view = computed(() => {
const rd = this.store.view();
return rd.tag === 'Success' ? rd.value : undefined;
});
constructor() {
void this.store.load(this.id);
}
protected reload() {
this.store.reload();
}
}
@@ -5,8 +5,8 @@ import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view';
/** Organism: the behandelaar's queue as CIBG "aanvragen" rows (WP-64) — composition
of the two existing shared/ui molecules, no new atom. Rows are informational only
(no `to`): opening a case's detail is WP-65. */
of the two existing shared/ui molecules, no new atom. Each row links to the
beoordeling detail page (WP-65). */
@Component({
selector: 'app-werkvoorraad-list',
imports: [ApplicationListComponent, ApplicationLinkComponent],
@@ -19,6 +19,7 @@ import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view';
[heading]="row.heading"
[subtitle]="row.subtitle"
[status]="row.status"
[to]="'/aanvraag/' + item.id"
></li>
}
</app-application-list>