refactor: ui/dashboard becomes ui/overzicht-secties (RD-36)

RD-03 moved the dashboard page to overzicht/ui/overzicht.page.ts and left four
sections in registratie/ui/dashboard/. The folder was named after a page that
lives in another context. A reader who opened it found four sections that are
not the dashboard.

The folder is now overzicht-secties/ — registratie's sections for the overzicht
page. The alias does not change, because the sections stay in the registratie
context. The story titles do not change, because they name the context.

Five documents cited registratie/ui/dashboard.page.ts, a file that RD-03
renamed. They now name overzicht.page.ts, or the section that owns the
behaviour they describe.

The /dashboard route keeps its path. It is a user-visible URL.

npm run ci --full passes: 67 and 45 storybook suites, 306 axe tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-09 16:21:15 +02:00
co-authored by Claude Opus 5
parent d9aef9541f
commit 44dcc69811
15 changed files with 127 additions and 13 deletions
@@ -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/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/atoms/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { successOr } from '@shared/application/remote-data';
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[]>(() =>
sortForDashboard(successOr(this.store.aanvragen(), [])),
);
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/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/atoms/heading/heading.component';
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/molecules/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/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/atoms/heading/heading.component';
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/molecules/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,101 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { WatMoetIkRegelenSection } from './wat-moet-ik-regelen.section';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { BigProfile } from '@registratie/domain/big-profile';
import { HerregistratieDecisions } from '@registratie/domain/registration';
import { RemoteData } from '@shared/application/remote-data';
import { loading, success } 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 and class read. */
function storeStub(
profileRd: RemoteData<Error | undefined, BigProfile>,
decisionsRd: RemoteData<Error | undefined, HerregistratieDecisions>,
pendingHerregistratie: boolean,
) {
return {
profile: () => profileRd,
decisions: () => decisionsRd,
pendingHerregistratie: () => pendingHerregistratie,
reloadProfile: () => {},
};
}
const meta: Meta<WatMoetIkRegelenSection> = {
title: 'Domein/Registratie/Wat Moet Ik Regelen',
component: WatMoetIkRegelenSection,
};
export default meta;
type Story = StoryObj<WatMoetIkRegelenSection>;
export const Loading: Story = {
decorators: [
applicationConfig({
providers: [{ provide: BigProfileStore, useValue: storeStub(loading(), loading(), false) }],
}),
],
};
export const MetTaken: Story = {
decorators: [
applicationConfig({
providers: [
{
provide: BigProfileStore,
useValue: storeStub(
success(profile),
success({ eligibleForHerregistratie: true }),
false,
),
},
],
}),
],
};
export const NietsOpenstaand: Story = {
decorators: [
applicationConfig({
providers: [
{
provide: BigProfileStore,
useValue: storeStub(
success(profile),
success({ eligibleForHerregistratie: false }),
false,
),
},
],
}),
],
};
export const InBehandeling: Story = {
decorators: [
applicationConfig({
providers: [
{
provide: BigProfileStore,
useValue: storeStub(
success(profile),
success({ eligibleForHerregistratie: false }),
true,
),
},
],
}),
],
};
@@ -0,0 +1,63 @@
import { Component, computed, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { TaskListComponent } from '@shared/ui/molecules/task-list/task-list.component';
import { ASYNC } from '@shared/ui/molecules/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;
});
}