feat(aanvragen): richer rows (purpose + status) linking to a case-detail page

- Aanvragen rows now show what the aanvraag is for (purpose subtitle) and an
  explicit status label (In behandeling / Goedgekeurd / Afgewezen) alongside the
  reference + submit date, via an expanded aanvraag-view (purposeLabel,
  statusLabel, referentie, detailRows) + spec.
- Rows link to a new /aanvraag/:id case-detail page, so the CIBG chevron shows
  and each aanvraag opens as a (stub) case — it lists soort/waarvoor/status/
  referentie/ingediend in a Datablock, with a note that full handling is future.

GREEN: lint, tokens, 183 tests, build, 137 axe stories. Verified visually
(dashboard aanvragen rows, upload drop-zone, datablock) via Storybook screenshots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:55:21 +02:00
parent b5c5d30a65
commit ccc0184342
7 changed files with 629 additions and 130 deletions

File diff suppressed because one or more lines are too long

View File

@@ -11,6 +11,7 @@ export const routes: Routes = [
{ path: 'login', loadComponent: () => import('@auth/ui/login.page').then(m => m.LoginPage) }, { path: 'login', loadComponent: () => import('@auth/ui/login.page').then(m => m.LoginPage) },
{ path: 'dashboard', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/dashboard.page').then(m => m.DashboardPage) }, { path: 'dashboard', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/dashboard.page').then(m => m.DashboardPage) },
{ path: 'registratie', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registration-detail.page').then(m => m.RegistrationDetailPage) }, { path: 'registratie', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registration-detail.page').then(m => m.RegistrationDetailPage) },
{ path: 'aanvraag/:id', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/aanvraag-detail.page').then(m => m.AanvraagDetailPage) },
{ path: 'registreren', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registratie.page').then(m => m.RegistratiePage) }, { path: 'registreren', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registratie.page').then(m => m.RegistratiePage) },
{ path: 'herregistratie', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage) }, { path: 'herregistratie', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage) },
{ path: 'intake', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/intake.page').then(m => m.IntakePage) }, { path: 'intake', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/intake.page').then(m => m.IntakePage) },

View File

@@ -1,30 +1,45 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { submittedRow, TYPE_LABELS } from './aanvraag-view'; import { submittedRow, detailRows, purposeLabel, statusLabel, TYPE_LABELS } from './aanvraag-view';
import { Aanvraag } from './aanvraag'; import { Aanvraag } from './aanvraag';
const base = { id: '1', type: 'herregistratie' as const, documentIds: [], createdAt: '', updatedAt: '', submittedAt: '2024-05-12' }; const base = { id: '1', type: 'herregistratie' as const, documentIds: [], createdAt: '', updatedAt: '', submittedAt: '2024-05-12' };
describe('submittedRow', () => { describe('submittedRow', () => {
it('InBehandeling: reference + submit date; manual note only when manual', () => { it('heading is the type, subtitle is the purpose', () => {
const auto = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: false } } as Aanvraag); const row = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: false } } as Aanvraag);
expect(auto.heading).toBe(TYPE_LABELS.herregistratie); expect(row.heading).toBe(TYPE_LABELS.herregistratie);
expect(auto.status).toContain('R1'); expect(row.subtitle).toBe(purposeLabel('herregistratie'));
expect(auto.status).toContain('12 mei 2024'); });
expect(auto.subtitle).toBe('');
it('status line carries the status label, reference and submit date', () => {
const row = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: false } } as Aanvraag);
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 adds a note; rejection adds its reason', () => {
const manual = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: true } } as Aanvraag); const manual = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: true } } as Aanvraag);
expect(manual.subtitle).not.toBe(''); expect(manual.status).toContain('handmatig');
const rejected = submittedRow({ ...base, status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' } } as Aanvraag);
expect(rejected.status).toContain('Onvoldoende uren');
});
}); });
it('Afgewezen: reason becomes the subtitle', () => { describe('detailRows', () => {
const row = submittedRow({ ...base, status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' } } as Aanvraag); it('lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected', () => {
expect(row.status).toContain('R2'); const rows = detailRows({ ...base, status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' } } as Aanvraag);
expect(row.subtitle).toBe('Onvoldoende uren'); const values = rows.map((r) => r.value);
expect(values).toContain(TYPE_LABELS.herregistratie);
expect(values).toContain('R2');
expect(values).toContain('Onvoldoende uren');
expect(rows.length).toBe(6);
}); });
it('Goedgekeurd: reference only', () => { it('reference falls back to em dash for a Concept', () => {
const row = submittedRow({ ...base, status: { tag: 'Goedgekeurd', referentie: 'R3' } } as Aanvraag); const rows = detailRows({ ...base, submittedAt: undefined, status: { tag: 'Concept', stepIndex: 0, stepCount: 3 } } as Aanvraag);
expect(row.status).toContain('R3'); const ref = rows.find((r) => r.value === '—');
expect(row.subtitle).toBe(''); expect(ref).toBeTruthy();
expect(rows.length).toBe(5);
}); });
}); });

View File

@@ -1,8 +1,8 @@
import { Aanvraag, AanvraagType } from './aanvraag'; import { Aanvraag, AanvraagStatus, AanvraagType } from './aanvraag';
/** View-model mapping for an aanvraag: type → label, and a submitted aanvraag → /** View-model mapping for an aanvraag: type → labels, status → label, and the fields
the CIBG "aanvragen" row fields (heading/status/subtitle). Pure, no Angular — for a CIBG "aanvragen" row / the case-detail page. Pure, no Angular — the UI
the UI renders these, it does not derive them. */ renders these, it does not derive them. */
export const TYPE_LABELS: Record<AanvraagType, string> = { export const TYPE_LABELS: Record<AanvraagType, string> = {
registratie: $localize`:@@aanvraagBlock.type.registratie:Inschrijving`, registratie: $localize`:@@aanvraagBlock.type.registratie:Inschrijving`,
@@ -10,35 +10,66 @@ export const TYPE_LABELS: Record<AanvraagType, string> = {
intake: $localize`:@@aanvraagBlock.type.intake:Herregistratie-intake`, intake: $localize`:@@aanvraagBlock.type.intake:Herregistratie-intake`,
}; };
/** What the aanvraag is for (shown under the title). */
export function purposeLabel(type: AanvraagType): string {
switch (type) {
case 'registratie': return $localize`:@@aanvraag.purpose.registratie:Inschrijving in het BIG-register`;
case 'herregistratie': return $localize`:@@aanvraag.purpose.herregistratie:Verlenging van uw BIG-registratie`;
case 'intake': return $localize`:@@aanvraag.purpose.intake:Intake-vragenlijst voor uw herregistratie`;
}
}
/** The status as a plain label (what state the aanvraag is in). */
export function statusLabel(status: AanvraagStatus): string {
switch (status.tag) {
case 'Concept': return $localize`:@@aanvraag.status.concept:Concept (nog niet ingediend)`;
case 'InBehandeling': return $localize`:@@aanvraag.status.inBehandeling:In behandeling`;
case 'Goedgekeurd': return $localize`:@@aanvraag.status.goedgekeurd:Goedgekeurd`;
case 'Afgewezen': return $localize`:@@aanvraag.status.afgewezen:Afgewezen`;
}
}
/** The reference number, or '' for a Concept (which has none yet). */
export function referentie(status: AanvraagStatus): string {
return status.tag === 'Concept' ? '' : status.referentie;
}
export interface AanvraagRow { export interface AanvraagRow {
heading: string; heading: string;
/** Reference + submit-date line (the `.status` line of an aanvragen row). */ /** What the aanvraag is for (the `.subtitle` line). */
status: string;
/** Secondary note: manual-review notice or rejection reason. */
subtitle: string; subtitle: string;
/** The status: label + reference + submit date (+ any note) — the `.status` line. */
status: string;
} }
function formatNL(iso?: string): string { function formatNL(iso?: string): string {
return iso ? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' }) : ''; return iso ? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
} }
/** Fields for a submitted aanvraag's row (Concept has no row — it renders as a /** Fields for a submitted aanvraag's row in the dashboard "aanvragen" list (Concept
resumable melding, see aanvraag-block). */ has no row — it renders as a resumable melding, see aanvraag-block). */
export function submittedRow(a: Aanvraag): AanvraagRow { export function submittedRow(a: Aanvraag): AanvraagRow {
const s = a.status; const s = a.status;
const heading = TYPE_LABELS[a.type]; const parts = [statusLabel(s)];
switch (s.tag) { const ref = referentie(s);
case 'Concept': if (ref) parts.push($localize`:@@aanvraag.row.ref:Referentie ${ref}:ref:`);
return { heading, status: '', subtitle: '' }; if (a.submittedAt) parts.push($localize`:@@aanvraag.row.ingediend:ingediend op ${formatNL(a.submittedAt)}:datum:`);
case 'InBehandeling': if (s.tag === 'InBehandeling' && s.manual) parts.push($localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`);
return { if (s.tag === 'Afgewezen') parts.push(s.reden);
heading, return { heading: TYPE_LABELS[a.type], subtitle: purposeLabel(a.type), status: parts.join(' · ') };
status: $localize`:@@aanvraagBlock.inBehandeling:Referentie ${s.referentie}:ref: · ingediend op ${formatNL(a.submittedAt)}:datum:`,
subtitle: s.manual ? $localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.` : '',
};
case 'Goedgekeurd':
return { heading, status: $localize`:@@aanvraagBlock.goedgekeurd:Referentie ${s.referentie}:ref:`, subtitle: '' };
case 'Afgewezen':
return { heading, status: $localize`:@@aanvraagBlock.afgewezen:Referentie ${s.referentie}:ref:`, subtitle: s.reden };
} }
/** Key/value rows for the case-detail page (CIBG Datablock). */
export function detailRows(a: Aanvraag): { key: string; value: string }[] {
const rows = [
{ key: $localize`:@@aanvraag.detail.soort:Soort aanvraag`, value: TYPE_LABELS[a.type] },
{ key: $localize`:@@aanvraag.detail.waarvoor:Waarvoor`, value: purposeLabel(a.type) },
{ key: $localize`:@@aanvraag.detail.status:Status`, value: statusLabel(a.status) },
{ key: $localize`:@@aanvraag.detail.referentie:Referentie`, value: referentie(a.status) || '—' },
{ key: $localize`:@@aanvraag.detail.ingediend:Ingediend op`, value: a.submittedAt ? formatNL(a.submittedAt) : '—' },
];
if (a.status.tag === 'Afgewezen') {
rows.push({ key: $localize`:@@aanvraag.detail.reden:Reden van afwijzing`, value: a.status.reden });
}
return rows;
} }

View File

@@ -0,0 +1,50 @@
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { AlertComponent } from '@shared/ui/alert/alert.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 { ApplicationsStore } from '@registratie/application/applications.store';
import { Aanvraag } from '@registratie/domain/aanvraag';
import { detailRows } from '@registratie/domain/aanvraag-view';
/** Page: a single aanvraag ("case"). Stub — it renders the aanvraag's known fields
(soort, waarvoor, status, referentie, ingediend) in a CIBG Datablock; full case
handling is future work. The dashboard "Mijn aanvragen" rows link here. */
@Component({
selector: 'app-aanvraag-detail-page',
imports: [PageShellComponent, SkeletonComponent, AlertComponent, DataBlockComponent, DataRowComponent, ...ASYNC],
template: `
<app-page-shell i18n-heading="@@aanvraagDetail.heading" heading="Aanvraag" backLink="/dashboard">
<app-async [data]="store.applications()">
<ng-template appAsyncLoaded let-list>
@let a = find($any(list));
@if (a) {
<app-data-block i18n-ariaLabel="@@aanvraagDetail.ariaLabel" ariaLabel="Aanvraaggegevens">
@for (row of rows(a); track row.key) {
<div app-data-row [key]="row.key" [value]="row.value"></div>
}
</app-data-block>
<app-alert class="app-section" type="info" i18n="@@aanvraagDetail.stub">
De volledige afhandeling van deze aanvraag is nog niet beschikbaar in deze POC.
</app-alert>
} @else {
<app-alert type="warning" i18n="@@aanvraagDetail.nietGevonden">Deze aanvraag is niet gevonden.</app-alert>
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="5" />
</ng-template>
</app-async>
</app-page-shell>
`,
})
export class AanvraagDetailPage {
protected store = inject(ApplicationsStore);
private id = inject(ActivatedRoute).snapshot.paramMap.get('id') ?? '';
protected find = (list: Aanvraag[]): Aanvraag | undefined => list.find((a) => a.id === this.id);
protected rows = detailRows;
}

View File

@@ -43,7 +43,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
<app-application-list> <app-application-list>
@for (a of ingediend(); track a.id) { @for (a of ingediend(); track a.id) {
@let row = submittedRow(a); @let row = submittedRow(a);
<li app-application-link animate.enter="app-item-enter" animate.leave="app-item-leave" [heading]="row.heading" [status]="row.status" [subtitle]="row.subtitle"></li> <li app-application-link animate.enter="app-item-enter" animate.leave="app-item-leave" [heading]="row.heading" [subtitle]="row.subtitle" [status]="row.status" [to]="'/aanvraag/' + a.id"></li>
} }
</app-application-list> </app-application-list>
} }

View File

@@ -14,9 +14,9 @@ const meta: Meta<ApplicationListComponent> = {
render: () => ({ render: () => ({
template: ` template: `
<app-application-list> <app-application-list>
<li app-application-link heading="Inschrijving" status="Stap 2 van 3" cta="Verder gaan" clickable="true"></li> <li app-application-link heading="Herregistratie" subtitle="Verlenging van uw BIG-registratie" status="In behandeling · Referentie 2024-00123 · ingediend op 12 mei 2024" to="/aanvraag/1"></li>
<li app-application-link heading="Herregistratie" status="Referentie 2024-00123 · ingediend op 12 mei 2024"></li> <li app-application-link heading="Inschrijving" subtitle="Inschrijving in het BIG-register" status="Goedgekeurd · Referentie 2024-00088" to="/aanvraag/2"></li>
<li app-application-link heading="Inschrijven" subtitle="Schrijf u in in het BIG-register." to="/registreren"></li> <li app-application-link heading="Inschrijven" subtitle="Schrijf u in in het BIG-register." cta="Start inschrijving" to="/registreren"></li>
</app-application-list>`, </app-application-list>`,
}), }),
}; };