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
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:
@@ -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,
|
||||
});
|
||||
}
|
||||
+30
@@ -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[]>();
|
||||
}
|
||||
+22
@@ -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();
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user