refactor: split dashboard.page.ts into per-concern sections

Each dashboard section (Mijn aanvragen, Wat moet ik regelen, Mijn
registratie, Specialismen, Wat wilt u doen, Beheer) now owns its own
store access, async state, and template. DashboardPage becomes pure
composition.

Extract the repeated RemoteData Success-narrowing pattern into
successOf() and the dashboard sort/split logic into
sortForDashboard/concepten/ingediend, both with tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-04 15:17:02 +02:00
co-authored by Claude Sonnet 5
parent 42e7a1e927
commit 5977efe044
17 changed files with 725 additions and 334 deletions
@@ -1,5 +1,14 @@
import { describe, it, expect } from 'vitest';
import { submittedRow, detailRows, purposeLabel, statusLabel, TYPE_LABELS } from './aanvraag-view';
import {
submittedRow,
detailRows,
purposeLabel,
statusLabel,
TYPE_LABELS,
sortForDashboard,
concepten,
ingediend,
} from './aanvraag-view';
import { Aanvraag } from './aanvraag';
const base: Omit<Aanvraag, 'status'> = {
@@ -114,3 +123,40 @@ describe('detailRows', () => {
expect(rows.length).toBe(5);
});
});
describe('sortForDashboard / concepten / ingediend', () => {
const withStatus = (id: string, tag: Aanvraag['status']['tag']): Aanvraag => ({
...base,
id,
status:
tag === 'Concept'
? { tag, stepIndex: 0, stepCount: 1 }
: tag === 'Afgewezen' || tag === 'MeerInfoGevraagd'
? { tag, referentie: 'R', reden: 'x' }
: tag === 'InBehandeling'
? { tag, referentie: 'R', manual: false }
: { tag, referentie: 'R' },
});
it('sorts Concept, then still-open, then resolved last', () => {
const goedgekeurd = withStatus('1', 'Goedgekeurd');
const concept = withStatus('2', 'Concept');
const inBehandeling = withStatus('3', 'InBehandeling');
const sorted = sortForDashboard([goedgekeurd, concept, inBehandeling]);
expect(sorted.map((a) => a.id)).toEqual(['2', '3', '1']);
});
it('does not mutate the input array', () => {
const list = [withStatus('1', 'Goedgekeurd'), withStatus('2', 'Concept')];
const copy = [...list];
sortForDashboard(list);
expect(list).toEqual(copy);
});
it('concepten/ingediend split on the Concept tag', () => {
const concept = withStatus('1', 'Concept');
const ingediendItem = withStatus('2', 'Ingediend');
expect(concepten([concept, ingediendItem])).toEqual([concept]);
expect(ingediend([concept, ingediendItem])).toEqual([ingediendItem]);
});
});
@@ -106,3 +106,30 @@ export function detailRows(a: Aanvraag): { key: string; value: string }[] {
}
return rows;
}
/** Dashboard sort order: still-open work first (Concept, then submitted-and-pending),
resolved aanvragen (Goedgekeurd/Afgewezen) last. Within a group, order is stable
(the sort is by rank only). */
const SORT_RANK: Record<AanvraagStatus['tag'], number> = {
Concept: 0,
Ingediend: 1,
InBehandeling: 1,
MeerInfoGevraagd: 1,
Goedgekeurd: 2,
Afgewezen: 2,
};
/** The dashboard's "Mijn aanvragen" ordering: open work before resolved cases. */
export function sortForDashboard(aanvragen: Aanvraag[]): Aanvraag[] {
return aanvragen.slice().sort((a, b) => SORT_RANK[a.status.tag] - SORT_RANK[b.status.tag]);
}
/** A Concept ("lopende aanvraag") renders as a melding above the list; a submitted
aanvraag as a keuzelijst item — the two shapes need different HTML contexts. */
export function concepten(aanvragen: Aanvraag[]): Aanvraag[] {
return aanvragen.filter((a) => a.status.tag === 'Concept');
}
export function ingediend(aanvragen: Aanvraag[]): Aanvraag[] {
return aanvragen.filter((a) => a.status.tag !== 'Concept');
}
@@ -1,4 +1,5 @@
import { Component, computed, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data';
import { ActivatedRoute } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
@@ -66,9 +67,7 @@ export class AanvraagDetailPage {
protected find = (list: Aanvraag[]): Aanvraag | undefined => list.find((a) => a.id === this.id);
protected rows = detailRows;
/** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */
protected readonly aanvragen = computed(() => {
const rd = this.store.aanvragen();
return rd.tag === 'Success' ? rd.value : undefined;
});
/** `successOf`: `<ng-template>` can't inherit a generic from a sibling host input,
so the Success value is unwrapped here instead of through `let-`. */
protected readonly aanvragen = computed(() => successOf(this.store.aanvragen()));
}
+23 -321
View File
@@ -1,47 +1,25 @@
import { Component, computed, inject } from '@angular/core';
import { Router } from '@angular/router';
import { Component } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { TaskListComponent } from '@shared/ui/task-list/task-list.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag';
import { ADMIN_LINKS } from '../../shell/nav.config';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';
import { AanvraagBlockComponent } from '@registratie/ui/aanvraag-block/aanvraag-block.component';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { AanvragenStore } from '@registratie/application/aanvragen.store';
import { Registration } from '@registratie/domain/registration';
import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag';
import { submittedRow } from '@registratie/domain/aanvraag-view';
import { tasksFromProfile } from '@registratie/domain/tasks';
import { MijnAanvragenSection } from './dashboard/mijn-aanvragen.section';
import { WatMoetIkRegelenSection } from './dashboard/wat-moet-ik-regelen.section';
import { MijnRegistratieSection } from './dashboard/mijn-registratie.section';
import { SpecialismenSection } from './dashboard/specialismen.section';
import { WatWiltUDoenSection } from './dashboard/wat-wilt-u-doen.section';
import { BeheerLinksSection } from './dashboard/beheer-links.section';
/** Page:"Mijn overzicht" — the portal home, following the NL Design System
"Mijn omgeving" pattern (side nav +"Wat moet ik regelen" +"Mijn zaken"). */
/** Page: "Mijn overzicht" — the portal home, following the NL Design System "Mijn
omgeving" pattern. Composition only: each section below answers its own data
question (own store, own async state) — see `ui/dashboard/*.section.ts`. */
@Component({
selector: 'app-dashboard-page',
imports: [
PageShellComponent,
HeadingComponent,
AlertComponent,
SkeletonComponent,
DataRowComponent,
DataBlockComponent,
TaskListComponent,
ApplicationListComponent,
ApplicationLinkComponent,
...ASYNC,
RegistrationSummaryComponent,
RegistrationTableComponent,
AanvraagBlockComponent,
MijnAanvragenSection,
WatMoetIkRegelenSection,
MijnRegistratieSection,
SpecialismenSection,
WatWiltUDoenSection,
BeheerLinksSection,
],
template: `
<app-page-shell
@@ -51,290 +29,14 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken."
>
<div class="app-stack">
@if (cancelError(); as err) {
<app-alert type="error">{{ err }}</app-alert>
}
@if (aanvragen().length) {
<section>
@for (a of concepten(); track a.id) {
<app-aanvraag-block
animate.enter="app-item-enter"
animate.leave="app-item-leave"
[aanvraag]="a"
(resume)="resume(a)"
(cancel)="cancelAanvraag(a)"
/>
}
@if (ingediend().length) {
<app-heading [level]="2" class="app-section" i18n="@@dashboard.mijnAanvragen"
>Mijn aanvragen</app-heading
>
<app-application-list>
@for (a of ingediend(); track a.id) {
@let row = submittedRow(a);
<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>
}
</section>
}
@if (store.pendingHerregistratie()) {
<app-alert type="info" i18n="@@dashboard.pendingHerregistratie"
>Uw herregistratie-aanvraag is in behandeling.</app-alert
>
}
<app-async [data]="store.profile()" (retryClicked)="store.reloadProfile()">
<ng-template appAsyncLoaded>
@if (profile(); as p) {
@let tasks = tasksFor(p.registration);
<section>
@if (tasks.length) {
<app-task-list
class="app-section"
i18n-listHeading="@@dashboard.watMoetIkRegelen"
listHeading="Wat moet ik regelen"
[tasks]="tasks"
/>
} @else {
<app-heading [level]="2" i18n="@@dashboard.watMoetIkRegelen"
>Wat moet ik regelen</app-heading
>
<p class="app-text-subtle" i18n="@@dashboard.nietsOpenstaan">
U heeft op dit moment niets openstaan.
</p>
}
</section>
<section>
<app-heading [level]="2" i18n="@@dashboard.mijnRegistratie"
>Mijn registratie</app-heading
>
<div class="app-section">
<app-registration-summary [reg]="p.registration" />
</div>
<app-data-block
class="app-section"
i18n-heading="@@dashboard.persoonsgegevens"
heading="Persoonsgegevens (BRP)"
>
<div
app-data-row
i18n-key="@@dashboard.straat"
key="Straat"
[value]="p.person.adres.straat"
></div>
<div
app-data-row
i18n-key="@@dashboard.postcode"
key="Postcode"
[value]="p.person.adres.postcode"
></div>
<div
app-data-row
i18n-key="@@dashboard.woonplaats"
key="Woonplaats"
[value]="p.person.adres.woonplaats"
></div>
</app-data-block>
</section>
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="6" />
</ng-template>
</app-async>
<section>
<app-heading [level]="2" i18n="@@dashboard.specialismen"
>Specialismen en aantekeningen</app-heading
>
<div class="app-section">
<app-async [data]="store.aantekeningen()" (retryClicked)="store.reloadAantekeningen()">
<ng-template appAsyncLoaded>
@if (aantekeningen(); as r) {
<app-registration-table [rows]="r" />
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
<ng-template appAsyncEmpty>
<p class="app-text-subtle" i18n="@@dashboard.geenSpecialismen">
U heeft nog geen specialismen of aantekeningen.
</p>
</ng-template>
</app-async>
</div>
</section>
<section>
<app-heading [level]="2" i18n="@@dashboard.watWiltUDoen">Wat wilt u doen?</app-heading>
<app-application-list class="app-section">
@for (a of acties(); track a.to) {
<li
app-application-link
[heading]="a.titel"
[subtitle]="a.tekst"
[cta]="a.actie"
[to]="a.to"
></li>
}
</app-application-list>
</section>
@if (adminLinks().length) {
<section>
<app-heading [level]="2" i18n="@@dashboard.beheer">Beheer</app-heading>
<app-application-list class="app-section">
@for (link of adminLinks(); track link.to) {
<li
app-application-link
[heading]="link.label"
[subtitle]="link.description"
[to]="link.to"
></li>
}
</app-application-list>
</section>
}
<app-mijn-aanvragen-section />
<app-wat-moet-ik-regelen-section />
<app-mijn-registratie-section />
<app-specialismen-section />
<app-wat-wilt-u-doen-section />
<app-beheer-links-section />
</div>
</app-page-shell>
`,
})
export class DashboardPage {
protected store = inject(BigProfileStore);
private apps = inject(AanvragenStore);
private access = inject(AccessStore);
private flags = inject(FeatureFlagStore);
private router = inject(Router);
/** Admin pages the current principal may reach — capability-gated (never role-derived),
the same source + filter the site header uses. Empty for a non-admin → section hidden. */
protected adminLinks = computed(() => ADMIN_LINKS.filter((l) => this.access.can(l.cap)));
/** Pure view mapping for a submitted aanvraag → CIBG aanvragen-row fields. */
protected submittedRow = submittedRow;
constructor() {
// Re-fetch on each visit so server-computed auto-approval transitions show up
// (Concept → In behandeling → Goedgekeurd after the processing window).
this.apps.reload();
}
/** The user's aanvragen, sorted Concept → In behandeling → resolved. Empty →
the"Mijn aanvragen" section is hidden (see template). */
protected aanvragen = computed<Aanvraag[]>(() => {
const rd = this.apps.aanvragen();
if (rd.tag !== 'Success') return [];
const order: Record<Aanvraag['status']['tag'], number> = {
Concept: 0,
Ingediend: 1,
InBehandeling: 1,
MeerInfoGevraagd: 1,
Goedgekeurd: 2,
Afgewezen: 2,
};
return rd.value.slice().sort((a, b) => order[a.status.tag] - order[b.status.tag]);
});
/** A Concept ("lopende aanvraag") renders as a melding above the list; the rest
as keuzelijst items — the two shapes need different HTML contexts. */
protected concepten = computed(() => this.aanvragen().filter((a) => a.status.tag === 'Concept'));
protected ingediend = computed(() => this.aanvragen().filter((a) => a.status.tag !== 'Concept'));
private readonly resumeRoutes: Record<AanvraagType, string> = {
registratie: '/registreren',
herregistratie: '/herregistratie',
intake: '/intake',
};
protected resume(a: Aanvraag) {
void this.router.navigate([this.resumeRoutes[a.type]], { queryParams: { aanvraag: a.id } });
}
protected cancelAanvraag(a: Aanvraag) {
void this.apps.cancel(a.id);
}
/** RB-20: the message from a failed cancel, rendered above the list. */
protected cancelError = computed(() => this.apps.lastError());
/** Server-computed eligibility (rendered, not recomputed). */
private readonly eligible = computed(() => {
const d = this.store.decisions();
return d.tag === 'Success' && d.value.eligibleForHerregistratie;
});
protected tasksFor(reg: Registration) {
return tasksFromProfile(reg, this.eligible());
}
/** Typed narrowing for the `<app-async>` loaded slot — `<ng-template>`'s own
context can't inherit a generic from a sibling host input (Angular only infers
a structural directive's type parameter from an input on that same node), so
the Success value is unwrapped here instead of through `let-`. */
protected readonly profile = computed(() => {
const rd = this.store.profile();
return rd.tag === 'Success' ? rd.value : undefined;
});
protected readonly aantekeningen = computed(() => {
const rd = this.store.aantekeningen();
return rd.tag === 'Success' ? rd.value : undefined;
});
/** Primary transactional actions, as an "aanvragen" list (see CIBG's
componenten/aanvragen). The core portal sections live in the header nav now;
the teaching pages (concepts/brief) are only reachable from here. */
private readonly allActies = [
{
to: '/registreren',
titel: $localize`:@@dashboard.actie.inschrijven.titel:Inschrijven`,
tekst: $localize`:@@dashboard.actie.inschrijven.tekst:Schrijf u in in het BIG-register via de registratiewizard.`,
actie: $localize`:@@dashboard.actie.inschrijven.actie:Start inschrijving`,
},
{
to: '/herregistratie',
titel: $localize`:@@dashboard.actie.herregistratie.titel:Herregistratie aanvragen`,
tekst: $localize`:@@dashboard.actie.herregistratie.tekst:Verleng uw registratie voor de komende periode.`,
actie: $localize`:@@dashboard.actie.herregistratie.actie:Vraag aan`,
},
{
to: '/intake',
titel: $localize`:@@dashboard.actie.intake.titel:Herregistratie-intake`,
tekst: $localize`:@@dashboard.actie.intake.tekst:Vragenlijst met vertakkingen.`,
actie: $localize`:@@dashboard.actie.intake.actie:Start intake`,
},
{
to: '/registratie',
titel: $localize`:@@dashboard.actie.wijzigen.titel:Gegevens wijzigen`,
tekst: $localize`:@@dashboard.actie.wijzigen.tekst:Bekijk uw gegevens of geef een wijziging door.`,
actie: $localize`:@@dashboard.actie.wijzigen.actie:Bekijk gegevens`,
},
{
to: '/concepts',
titel: $localize`:@@dashboard.actie.concepten.titel:Functionele patronen`,
tekst: $localize`:@@dashboard.actie.concepten.tekst:Bekijk de FP/TEA-bouwstenen van deze POC.`,
actie: $localize`:@@dashboard.actie.concepten.actie:Bekijk patronen`,
},
{
to: '/brief',
titel: $localize`:@@dashboard.actie.brief.titel:Brief opstellen`,
tekst: $localize`:@@dashboard.actie.brief.tekst:Stel een brief samen uit vaste en vrije onderdelen.`,
actie: $localize`:@@dashboard.actie.brief.actie:Start brief`,
},
];
/** Hide the "Inschrijven" action when self-service registration is flagged off (WP-47). */
protected readonly acties = computed(() =>
this.allActies.filter(
(a) => a.to !== '/registreren' || this.flags.enabled(FLAG_INSCHRIJVING_OPEN),
),
);
}
export class DashboardPage {}
@@ -0,0 +1,35 @@
import { Component, computed, inject } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
import { AccessStore } from '@shared/application/access.store';
import { ADMIN_LINKS } from '../../../shell/nav.config';
/** Section: "Beheer" — the admin pages the current principal may reach, capability-
gated (never role-derived), the same source + filter the site header uses.
Empty for a non-admin → the section renders nothing (see the page). */
@Component({
selector: 'app-beheer-links-section',
imports: [HeadingComponent, ApplicationListComponent, ApplicationLinkComponent],
template: `
@if (adminLinks().length) {
<section>
<app-heading [level]="2" i18n="@@dashboard.beheer">Beheer</app-heading>
<app-application-list class="app-section">
@for (link of adminLinks(); track link.to) {
<li
app-application-link
[heading]="link.label"
[subtitle]="link.description"
[to]="link.to"
></li>
}
</app-application-list>
</section>
}
`,
})
export class BeheerLinksSection {
private access = inject(AccessStore);
protected adminLinks = computed(() => ADMIN_LINKS.filter((l) => this.access.can(l.cap)));
}
@@ -0,0 +1,84 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { MijnAanvragenSection } from './mijn-aanvragen.section';
import { AanvragenStore } from '@registratie/application/aanvragen.store';
import { Aanvraag } from '@registratie/domain/aanvraag';
import { RemoteData } from '@shared/application/remote-data';
import { loading, success, failure } from '@shared/testing/remote-data';
const base = {
id: 'a1',
type: 'herregistratie',
documentIds: [],
createdAt: '2026-06-28T10:00:00Z',
updatedAt: '2026-06-28T10:05:00Z',
submittedAt: '2026-06-28T10:05:00Z',
} satisfies Omit<Aanvraag, 'status'>;
const concept: Aanvraag = { ...base, status: { tag: 'Concept', stepIndex: 1, stepCount: 3 } };
const ingediend: Aanvraag = {
...base,
id: 'a2',
status: { tag: 'InBehandeling', referentie: 'BIG-2026-456789', manual: false },
};
/** Minimal store stand-in — only the members the section's template reads. */
function storeStub(aanvragen: RemoteData<Error | undefined, Aanvraag[]>, lastError = '') {
return {
aanvragen: () => aanvragen,
reload: () => {},
cancel: async () => {},
lastError: () => lastError,
};
}
const meta: Meta<MijnAanvragenSection> = {
title: 'Domein/Registratie/Dashboard/Mijn Aanvragen',
component: MijnAanvragenSection,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
};
export default meta;
type Story = StoryObj<MijnAanvragenSection>;
export const Loading: Story = {
decorators: [
applicationConfig({ providers: [{ provide: AanvragenStore, useValue: storeStub(loading()) }] }),
],
};
export const WithConceptAndSubmitted: Story = {
decorators: [
applicationConfig({
providers: [{ provide: AanvragenStore, useValue: storeStub(success([concept, ingediend])) }],
}),
],
};
export const Empty: Story = {
decorators: [
applicationConfig({
providers: [{ provide: AanvragenStore, useValue: storeStub(success([])) }],
}),
],
};
export const CancelFailed: Story = {
decorators: [
applicationConfig({
providers: [
{
provide: AanvragenStore,
useValue: storeStub(
success([concept]),
$localize`:@@dashboard.cancel.failed:Verwijderen is niet gelukt.`,
),
},
],
}),
],
};
export const Failed: Story = {
decorators: [
applicationConfig({
providers: [{ provide: AanvragenStore, useValue: storeStub(failure(new Error('offline'))) }],
}),
],
};
@@ -0,0 +1,112 @@
import { Component, computed, inject } from '@angular/core';
import { Router } from '@angular/router';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AanvragenStore } from '@registratie/application/aanvragen.store';
import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag';
import {
submittedRow,
sortForDashboard,
concepten,
ingediend,
} from '@registratie/domain/aanvraag-view';
import { AanvraagBlockComponent } from '@registratie/ui/aanvraag-block/aanvraag-block.component';
/** Section: "Mijn aanvragen" — the user's own aanvragen (concepten as resumable
meldingen, submitted ones as keuzelijst rows), owning its own fetch, sort and
the resume/cancel actions. Empty → renders nothing, same as the aanvraag-block
convention (see AanvraagBlockComponent). */
@Component({
selector: 'app-mijn-aanvragen-section',
imports: [
AlertComponent,
SkeletonComponent,
HeadingComponent,
ApplicationListComponent,
ApplicationLinkComponent,
AanvraagBlockComponent,
...ASYNC,
],
template: `
@if (cancelError(); as err) {
<app-alert type="error">{{ err }}</app-alert>
}
<app-async [data]="store.aanvragen()" (retryClicked)="store.reload()">
<ng-template appAsyncLoaded>
@if (aanvragen().length) {
<section>
@for (a of concepten_(); track a.id) {
<app-aanvraag-block
animate.enter="app-item-enter"
animate.leave="app-item-leave"
[aanvraag]="a"
(resume)="resume(a)"
(cancel)="cancel(a)"
/>
}
@if (ingediend_().length) {
<app-heading [level]="2" class="app-section" i18n="@@dashboard.mijnAanvragen"
>Mijn aanvragen</app-heading
>
<app-application-list>
@for (a of ingediend_(); track a.id) {
@let row = submittedRow(a);
<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>
}
</section>
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="2" />
</ng-template>
</app-async>
`,
})
export class MijnAanvragenSection {
protected store = inject(AanvragenStore);
private router = inject(Router);
constructor() {
// Re-fetch on each visit so server-computed auto-approval transitions show up
// (Concept → In behandeling → Goedgekeurd after the processing window).
this.store.reload();
}
protected submittedRow = submittedRow;
protected aanvragen = computed<Aanvraag[]>(() => {
const rd = this.store.aanvragen();
return rd.tag === 'Success' ? sortForDashboard(rd.value) : [];
});
protected concepten_ = computed(() => concepten(this.aanvragen()));
protected ingediend_ = computed(() => ingediend(this.aanvragen()));
private readonly resumeRoutes: Record<AanvraagType, string> = {
registratie: '/registreren',
herregistratie: '/herregistratie',
intake: '/intake',
};
protected resume(a: Aanvraag) {
void this.router.navigate([this.resumeRoutes[a.type]], { queryParams: { aanvraag: a.id } });
}
protected cancel(a: Aanvraag) {
void this.store.cancel(a.id);
}
/** The message from a failed cancel, rendered above the list. */
protected cancelError = computed(() => this.store.lastError());
}
@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { MijnRegistratieSection } from './mijn-registratie.section';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { BigProfile } from '@registratie/domain/big-profile';
import { RemoteData } from '@shared/application/remote-data';
import { loading, success, failure } from '@shared/testing/remote-data';
const profile: BigProfile = {
registration: {
bigNummer: '19012345601',
naam: 'Dr. A. (Anna) de Vries',
beroep: 'Arts',
registratiedatum: '2012-09-01',
geboortedatum: '1985-03-14',
status: { tag: 'Geregistreerd', herregistratieDatum: '2027-09-01' },
},
person: {
naam: 'Dr. A. (Anna) de Vries',
geboortedatum: '1985-03-14',
adres: { straat: 'Rijksweg 1', postcode: '2514 EA', woonplaats: 'Den Haag' },
},
};
/** Minimal store stand-in — only the members the section's template reads. */
function storeStub(profileRd: RemoteData<Error | undefined, BigProfile>) {
return { profile: () => profileRd, reloadProfile: () => {} };
}
const meta: Meta<MijnRegistratieSection> = {
title: 'Domein/Registratie/Dashboard/Mijn Registratie',
component: MijnRegistratieSection,
};
export default meta;
type Story = StoryObj<MijnRegistratieSection>;
export const Loading: Story = {
decorators: [
applicationConfig({
providers: [{ provide: BigProfileStore, useValue: storeStub(loading()) }],
}),
],
};
export const Loaded: Story = {
decorators: [
applicationConfig({
providers: [{ provide: BigProfileStore, useValue: storeStub(success(profile)) }],
}),
],
};
export const Failed: Story = {
decorators: [
applicationConfig({
providers: [{ provide: BigProfileStore, useValue: storeStub(failure(new Error('offline'))) }],
}),
],
};
@@ -0,0 +1,71 @@
import { Component, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data';
import { HeadingComponent } from '@shared/ui/heading/heading.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 { BigProfileStore } from '@registratie/application/big-profile.store';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
/** Section: "Mijn registratie" — the BIG-register summary plus the BRP
persoonsgegevens, both from the one screen-shaped dashboard call
(BigProfileStore). */
@Component({
selector: 'app-mijn-registratie-section',
imports: [
HeadingComponent,
SkeletonComponent,
DataBlockComponent,
DataRowComponent,
RegistrationSummaryComponent,
...ASYNC,
],
template: `
<app-async [data]="store.profile()" (retryClicked)="store.reloadProfile()">
<ng-template appAsyncLoaded>
@if (profile(); as p) {
<section>
<app-heading [level]="2" i18n="@@dashboard.mijnRegistratie"
>Mijn registratie</app-heading
>
<div class="app-section">
<app-registration-summary [reg]="p.registration" />
</div>
<app-data-block
class="app-section"
i18n-heading="@@dashboard.persoonsgegevens"
heading="Persoonsgegevens (BRP)"
>
<div
app-data-row
i18n-key="@@dashboard.straat"
key="Straat"
[value]="p.person.adres.straat"
></div>
<div
app-data-row
i18n-key="@@dashboard.postcode"
key="Postcode"
[value]="p.person.adres.postcode"
></div>
<div
app-data-row
i18n-key="@@dashboard.woonplaats"
key="Woonplaats"
[value]="p.person.adres.woonplaats"
></div>
</app-data-block>
</section>
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="4" />
</ng-template>
</app-async>
`,
})
export class MijnRegistratieSection {
protected store = inject(BigProfileStore);
protected profile = () => successOf(this.store.profile());
}
@@ -0,0 +1,51 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { SpecialismenSection } from './specialismen.section';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { Aantekening } from '@registratie/domain/registration';
import { RemoteData } from '@shared/application/remote-data';
import { loading, success, empty, failure } from '@shared/testing/remote-data';
const rows: Aantekening[] = [
{ type: 'Specialisme', omschrijving: 'Huisartsgeneeskunde', datum: '2016-04-12' },
{ type: 'Aantekening', omschrijving: 'Erkend opleider huisartsgeneeskunde', datum: '2019-01-08' },
];
/** Minimal store stand-in — only the members the section's template reads. */
function storeStub(aantekeningen: RemoteData<Error | undefined, Aantekening[]>) {
return { aantekeningen: () => aantekeningen, reloadAantekeningen: () => {} };
}
const meta: Meta<SpecialismenSection> = {
title: 'Domein/Registratie/Dashboard/Specialismen',
component: SpecialismenSection,
};
export default meta;
type Story = StoryObj<SpecialismenSection>;
export const Loading: Story = {
decorators: [
applicationConfig({
providers: [{ provide: BigProfileStore, useValue: storeStub(loading()) }],
}),
],
};
export const Loaded: Story = {
decorators: [
applicationConfig({
providers: [{ provide: BigProfileStore, useValue: storeStub(success(rows)) }],
}),
],
};
export const Empty: Story = {
decorators: [
applicationConfig({ providers: [{ provide: BigProfileStore, useValue: storeStub(empty()) }] }),
],
};
export const Failed: Story = {
decorators: [
applicationConfig({
providers: [{ provide: BigProfileStore, useValue: storeStub(failure(new Error('offline'))) }],
}),
],
};
@@ -0,0 +1,43 @@
import { Component, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';
/** Section: "Specialismen en aantekeningen" — a separate resource from the rest of
the dashboard (own load/empty/error state), because it is genuinely a second
endpoint (`big-register.adapter.ts`), not part of the BFF-lite view call. */
@Component({
selector: 'app-specialismen-section',
imports: [HeadingComponent, SkeletonComponent, RegistrationTableComponent, ...ASYNC],
template: `
<section>
<app-heading [level]="2" i18n="@@dashboard.specialismen"
>Specialismen en aantekeningen</app-heading
>
<div class="app-section">
<app-async [data]="store.aantekeningen()" (retryClicked)="store.reloadAantekeningen()">
<ng-template appAsyncLoaded>
@if (aantekeningen(); as r) {
<app-registration-table [rows]="r" />
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
<ng-template appAsyncEmpty>
<p class="app-text-subtle" i18n="@@dashboard.geenSpecialismen">
U heeft nog geen specialismen of aantekeningen.
</p>
</ng-template>
</app-async>
</div>
</section>
`,
})
export class SpecialismenSection {
protected store = inject(BigProfileStore);
protected aantekeningen = () => successOf(this.store.aantekeningen());
}
@@ -0,0 +1,63 @@
import { Component, computed, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { TaskListComponent } from '@shared/ui/task-list/task-list.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { tasksFromProfile } from '@registratie/domain/tasks';
/** Section: "Wat moet ik regelen" — the open tasks derived from the registration
+ the server-computed herregistratie eligibility (rendered, never recomputed;
ADR-0001). Empty task list → a plain "niets openstaan" message, not an empty
async state (the registration itself did load). */
@Component({
selector: 'app-wat-moet-ik-regelen-section',
imports: [HeadingComponent, AlertComponent, SkeletonComponent, TaskListComponent, ...ASYNC],
template: `
@if (store.pendingHerregistratie()) {
<app-alert type="info" i18n="@@dashboard.pendingHerregistratie"
>Uw herregistratie-aanvraag is in behandeling.</app-alert
>
}
<app-async [data]="store.profile()" (retryClicked)="store.reloadProfile()">
<ng-template appAsyncLoaded>
@if (tasks(); as t) {
<section>
@if (t.length) {
<app-task-list
i18n-listHeading="@@dashboard.watMoetIkRegelen"
listHeading="Wat moet ik regelen"
[tasks]="t"
/>
} @else {
<app-heading [level]="2" i18n="@@dashboard.watMoetIkRegelen"
>Wat moet ik regelen</app-heading
>
<p class="app-text-subtle" i18n="@@dashboard.nietsOpenstaan">
U heeft op dit moment niets openstaan.
</p>
}
</section>
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="2" />
</ng-template>
</app-async>
`,
})
export class WatMoetIkRegelenSection {
protected store = inject(BigProfileStore);
private eligible = computed(() => {
const d = successOf(this.store.decisions());
return d?.eligibleForHerregistratie ?? false;
});
protected tasks = computed(() => {
const p = successOf(this.store.profile());
return p ? tasksFromProfile(p.registration, this.eligible()) : undefined;
});
}
@@ -0,0 +1,79 @@
import { Component, computed, inject } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag';
/** Section: "Wat wilt u doen?" — the portal's primary transactional actions (see
CIBG's componenten/aanvragen). The core pages live in the header nav now; the
teaching pages (concepts/brief) are only reachable from here. */
@Component({
selector: 'app-wat-wilt-u-doen-section',
imports: [HeadingComponent, ApplicationListComponent, ApplicationLinkComponent],
template: `
<section>
<app-heading [level]="2" i18n="@@dashboard.watWiltUDoen">Wat wilt u doen?</app-heading>
<app-application-list class="app-section">
@for (a of acties(); track a.to) {
<li
app-application-link
[heading]="a.titel"
[subtitle]="a.tekst"
[cta]="a.actie"
[to]="a.to"
></li>
}
</app-application-list>
</section>
`,
})
export class WatWiltUDoenSection {
private flags = inject(FeatureFlagStore);
private readonly allActies = [
{
to: '/registreren',
titel: $localize`:@@dashboard.actie.inschrijven.titel:Inschrijven`,
tekst: $localize`:@@dashboard.actie.inschrijven.tekst:Schrijf u in in het BIG-register via de registratiewizard.`,
actie: $localize`:@@dashboard.actie.inschrijven.actie:Start inschrijving`,
},
{
to: '/herregistratie',
titel: $localize`:@@dashboard.actie.herregistratie.titel:Herregistratie aanvragen`,
tekst: $localize`:@@dashboard.actie.herregistratie.tekst:Verleng uw registratie voor de komende periode.`,
actie: $localize`:@@dashboard.actie.herregistratie.actie:Vraag aan`,
},
{
to: '/intake',
titel: $localize`:@@dashboard.actie.intake.titel:Herregistratie-intake`,
tekst: $localize`:@@dashboard.actie.intake.tekst:Vragenlijst met vertakkingen.`,
actie: $localize`:@@dashboard.actie.intake.actie:Start intake`,
},
{
to: '/registratie',
titel: $localize`:@@dashboard.actie.wijzigen.titel:Gegevens wijzigen`,
tekst: $localize`:@@dashboard.actie.wijzigen.tekst:Bekijk uw gegevens of geef een wijziging door.`,
actie: $localize`:@@dashboard.actie.wijzigen.actie:Bekijk gegevens`,
},
{
to: '/concepts',
titel: $localize`:@@dashboard.actie.concepten.titel:Functionele patronen`,
tekst: $localize`:@@dashboard.actie.concepten.tekst:Bekijk de FP/TEA-bouwstenen van deze POC.`,
actie: $localize`:@@dashboard.actie.concepten.actie:Bekijk patronen`,
},
{
to: '/brief',
titel: $localize`:@@dashboard.actie.brief.titel:Brief opstellen`,
tekst: $localize`:@@dashboard.actie.brief.tekst:Stel een brief samen uit vaste en vrije onderdelen.`,
actie: $localize`:@@dashboard.actie.brief.actie:Start brief`,
},
];
/** Hide "Inschrijven" when self-service registration is flagged off. */
protected readonly acties = computed(() =>
this.allActies.filter(
(a) => a.to !== '/registreren' || this.flags.enabled(FLAG_INSCHRIJVING_OPEN),
),
);
}
@@ -1,4 +1,5 @@
import { Component, computed, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component';
@@ -41,9 +42,7 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
export class RegistrationDetailPage {
protected store = inject(BigProfileStore);
/** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */
protected readonly profile = computed(() => {
const rd = this.store.profile();
return rd.tag === 'Success' ? rd.value : undefined;
});
/** `successOf`: `<ng-template>` can't inherit a generic from a sibling host input,
so the Success value is unwrapped here instead of through `let-`. */
protected readonly profile = computed(() => successOf(this.store.profile()));
}
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { RemoteData, map2, map } from './remote-data';
import { loading, failure, success } from '../testing/remote-data';
import { RemoteData, map2, map, successOf } from './remote-data';
import { loading, failure, empty, success } from '../testing/remote-data';
const loadingRd: RemoteData<string, number> = loading();
const failureRd: RemoteData<string, number> = failure('x');
@@ -21,3 +21,15 @@ describe('RemoteData combinators', () => {
expect(map2(ok(2), ok(3), add)).toEqual({ tag: 'Success', value: 5 });
});
});
describe('successOf', () => {
it('unwraps a Success value', () => {
expect(successOf(ok(2))).toBe(2);
});
it('is undefined for every other state', () => {
expect(successOf(loadingRd)).toBeUndefined();
expect(successOf(failureRd)).toBeUndefined();
expect(successOf(empty())).toBeUndefined();
});
});
@@ -80,3 +80,12 @@ export function andThen<E, A, B>(
): RemoteData<E, B> {
return rd.tag === 'Success' ? f(rd.value) : rd;
}
/** Unwrap a Success value, or `undefined` for every other state. Used to narrow
an `<app-async>` loaded slot: `<ng-template>` can't inherit a generic from a
sibling host input (Angular only infers a structural directive's type
parameter from an input on that same node), so the caller unwraps here
instead of through `let-`. */
export function successOf<E, T>(rd: RemoteData<E, T>): T | undefined {
return rd.tag === 'Success' ? rd.value : undefined;
}
+2
View File
@@ -11,3 +11,5 @@ export const loading = <E = never, T = never>(): RemoteData<E, T> => ({ tag: 'Lo
export const success = <T, E = never>(value: T): RemoteData<E, T> => ({ tag: 'Success', value });
export const failure = <E, T = never>(error: E): RemoteData<E, T> => ({ tag: 'Failure', error });
export const empty = <E = never, T = never>(): RemoteData<E, T> => ({ tag: 'Empty' });