From 5977efe0449c230040af93687d198f22f29bdf9d Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 15:17:02 +0200 Subject: [PATCH] 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 --- .../registratie/domain/aanvraag-view.spec.ts | 48 ++- .../app/registratie/domain/aanvraag-view.ts | 27 ++ .../registratie/ui/aanvraag-detail.page.ts | 9 +- .../src/app/registratie/ui/dashboard.page.ts | 344 ++---------------- .../ui/dashboard/beheer-links.section.ts | 35 ++ .../mijn-aanvragen.section.stories.ts | 84 +++++ .../ui/dashboard/mijn-aanvragen.section.ts | 112 ++++++ .../mijn-registratie.section.stories.ts | 57 +++ .../ui/dashboard/mijn-registratie.section.ts | 71 ++++ .../dashboard/specialismen.section.stories.ts | 51 +++ .../ui/dashboard/specialismen.section.ts | 43 +++ .../dashboard/wat-moet-ik-regelen.section.ts | 63 ++++ .../ui/dashboard/wat-wilt-u-doen.section.ts | 79 ++++ .../ui/registration-detail.page.ts | 9 +- .../src/application/remote-data.spec.ts | 16 +- libs/shared/src/application/remote-data.ts | 9 + libs/shared/src/testing/remote-data.ts | 2 + 17 files changed, 725 insertions(+), 334 deletions(-) create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/beheer-links.section.ts create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.stories.ts create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.ts create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.stories.ts create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.ts create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.stories.ts create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.ts create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/wat-moet-ik-regelen.section.ts create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/wat-wilt-u-doen.section.ts diff --git a/apps/ssp/src/app/registratie/domain/aanvraag-view.spec.ts b/apps/ssp/src/app/registratie/domain/aanvraag-view.spec.ts index 17a3234..03eb010 100644 --- a/apps/ssp/src/app/registratie/domain/aanvraag-view.spec.ts +++ b/apps/ssp/src/app/registratie/domain/aanvraag-view.spec.ts @@ -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 = { @@ -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]); + }); +}); diff --git a/apps/ssp/src/app/registratie/domain/aanvraag-view.ts b/apps/ssp/src/app/registratie/domain/aanvraag-view.ts index bcf26bc..ab734d3 100644 --- a/apps/ssp/src/app/registratie/domain/aanvraag-view.ts +++ b/apps/ssp/src/app/registratie/domain/aanvraag-view.ts @@ -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 = { + 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'); +} diff --git a/apps/ssp/src/app/registratie/ui/aanvraag-detail.page.ts b/apps/ssp/src/app/registratie/ui/aanvraag-detail.page.ts index fdf7d00..c23353e 100644 --- a/apps/ssp/src/app/registratie/ui/aanvraag-detail.page.ts +++ b/apps/ssp/src/app/registratie/ui/aanvraag-detail.page.ts @@ -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`: `` 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())); } diff --git a/apps/ssp/src/app/registratie/ui/dashboard.page.ts b/apps/ssp/src/app/registratie/ui/dashboard.page.ts index 21a3426..0d4f177 100644 --- a/apps/ssp/src/app/registratie/ui/dashboard.page.ts +++ b/apps/ssp/src/app/registratie/ui/dashboard.page.ts @@ -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: `
- @if (cancelError(); as err) { - {{ err }} - } - @if (aanvragen().length) { -
- @for (a of concepten(); track a.id) { - - } - @if (ingediend().length) { - Mijn aanvragen - - @for (a of ingediend(); track a.id) { - @let row = submittedRow(a); -
  • - } -
    - } -
    - } - - @if (store.pendingHerregistratie()) { - Uw herregistratie-aanvraag is in behandeling. - } - - - - @if (profile(); as p) { - @let tasks = tasksFor(p.registration); - -
    - @if (tasks.length) { - - } @else { - Wat moet ik regelen -

    - U heeft op dit moment niets openstaan. -

    - } -
    - -
    - Mijn registratie -
    - -
    - -
    -
    -
    -
    -
    - } -
    - - - -
    - -
    - Specialismen en aantekeningen -
    - - - @if (aantekeningen(); as r) { - - } - - - - - -

    - U heeft nog geen specialismen of aantekeningen. -

    -
    -
    -
    -
    - -
    - Wat wilt u doen? - - @for (a of acties(); track a.to) { -
  • - } -
    -
    - - @if (adminLinks().length) { -
    - Beheer - - @for (link of adminLinks(); track link.to) { -
  • - } -
    -
    - } + + + + + +
    `, }) -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(() => { - const rd = this.apps.aanvragen(); - if (rd.tag !== 'Success') return []; - const order: Record = { - 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 = { - 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 `` loaded slot — ``'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 {} diff --git a/apps/ssp/src/app/registratie/ui/dashboard/beheer-links.section.ts b/apps/ssp/src/app/registratie/ui/dashboard/beheer-links.section.ts new file mode 100644 index 0000000..cbfacfc --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/beheer-links.section.ts @@ -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) { +
    + Beheer + + @for (link of adminLinks(); track link.to) { +
  • + } +
    +
    + } + `, +}) +export class BeheerLinksSection { + private access = inject(AccessStore); + protected adminLinks = computed(() => ADMIN_LINKS.filter((l) => this.access.can(l.cap))); +} diff --git a/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.stories.ts b/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.stories.ts new file mode 100644 index 0000000..ae81b5e --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.stories.ts @@ -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; + +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, lastError = '') { + return { + aanvragen: () => aanvragen, + reload: () => {}, + cancel: async () => {}, + lastError: () => lastError, + }; +} + +const meta: Meta = { + title: 'Domein/Registratie/Dashboard/Mijn Aanvragen', + component: MijnAanvragenSection, + decorators: [applicationConfig({ providers: [provideRouter([])] })], +}; +export default meta; +type Story = StoryObj; + +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'))) }], + }), + ], +}; diff --git a/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.ts b/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.ts new file mode 100644 index 0000000..7fce4b6 --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.ts @@ -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) { + {{ err }} + } + + + @if (aanvragen().length) { +
    + @for (a of concepten_(); track a.id) { + + } + @if (ingediend_().length) { + Mijn aanvragen + + @for (a of ingediend_(); track a.id) { + @let row = submittedRow(a); +
  • + } +
    + } +
    + } +
    + + + +
    + `, +}) +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(() => { + 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 = { + 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()); +} diff --git a/apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.stories.ts b/apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.stories.ts new file mode 100644 index 0000000..58ec2f1 --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.stories.ts @@ -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) { + return { profile: () => profileRd, reloadProfile: () => {} }; +} + +const meta: Meta = { + title: 'Domein/Registratie/Dashboard/Mijn Registratie', + component: MijnRegistratieSection, +}; +export default meta; +type Story = StoryObj; + +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'))) }], + }), + ], +}; diff --git a/apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.ts b/apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.ts new file mode 100644 index 0000000..56a5793 --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.ts @@ -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: ` + + + @if (profile(); as p) { +
    + Mijn registratie +
    + +
    + +
    +
    +
    +
    +
    + } +
    + + + +
    + `, +}) +export class MijnRegistratieSection { + protected store = inject(BigProfileStore); + protected profile = () => successOf(this.store.profile()); +} diff --git a/apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.stories.ts b/apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.stories.ts new file mode 100644 index 0000000..9912e39 --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.stories.ts @@ -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) { + return { aantekeningen: () => aantekeningen, reloadAantekeningen: () => {} }; +} + +const meta: Meta = { + title: 'Domein/Registratie/Dashboard/Specialismen', + component: SpecialismenSection, +}; +export default meta; +type Story = StoryObj; + +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'))) }], + }), + ], +}; diff --git a/apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.ts b/apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.ts new file mode 100644 index 0000000..01a30f8 --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.ts @@ -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: ` +
    + Specialismen en aantekeningen +
    + + + @if (aantekeningen(); as r) { + + } + + + + + +

    + U heeft nog geen specialismen of aantekeningen. +

    +
    +
    +
    +
    + `, +}) +export class SpecialismenSection { + protected store = inject(BigProfileStore); + protected aantekeningen = () => successOf(this.store.aantekeningen()); +} diff --git a/apps/ssp/src/app/registratie/ui/dashboard/wat-moet-ik-regelen.section.ts b/apps/ssp/src/app/registratie/ui/dashboard/wat-moet-ik-regelen.section.ts new file mode 100644 index 0000000..a2d0716 --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/wat-moet-ik-regelen.section.ts @@ -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()) { + Uw herregistratie-aanvraag is in behandeling. + } + + + @if (tasks(); as t) { +
    + @if (t.length) { + + } @else { + Wat moet ik regelen +

    + U heeft op dit moment niets openstaan. +

    + } +
    + } +
    + + + +
    + `, +}) +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; + }); +} diff --git a/apps/ssp/src/app/registratie/ui/dashboard/wat-wilt-u-doen.section.ts b/apps/ssp/src/app/registratie/ui/dashboard/wat-wilt-u-doen.section.ts new file mode 100644 index 0000000..7f39f57 --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/wat-wilt-u-doen.section.ts @@ -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: ` +
    + Wat wilt u doen? + + @for (a of acties(); track a.to) { +
  • + } +
    +
    + `, +}) +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), + ), + ); +} diff --git a/apps/ssp/src/app/registratie/ui/registration-detail.page.ts b/apps/ssp/src/app/registratie/ui/registration-detail.page.ts index 25425f4..01f0063 100644 --- a/apps/ssp/src/app/registratie/ui/registration-detail.page.ts +++ b/apps/ssp/src/app/registratie/ui/registration-detail.page.ts @@ -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`: `` 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())); } diff --git a/libs/shared/src/application/remote-data.spec.ts b/libs/shared/src/application/remote-data.spec.ts index ee7bd64..45e2d05 100644 --- a/libs/shared/src/application/remote-data.spec.ts +++ b/libs/shared/src/application/remote-data.spec.ts @@ -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 = loading(); const failureRd: RemoteData = 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(); + }); +}); diff --git a/libs/shared/src/application/remote-data.ts b/libs/shared/src/application/remote-data.ts index 45828d8..2b9b4c1 100644 --- a/libs/shared/src/application/remote-data.ts +++ b/libs/shared/src/application/remote-data.ts @@ -80,3 +80,12 @@ export function andThen( ): RemoteData { return rd.tag === 'Success' ? f(rd.value) : rd; } + +/** Unwrap a Success value, or `undefined` for every other state. Used to narrow + an `` loaded slot: `` 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(rd: RemoteData): T | undefined { + return rd.tag === 'Success' ? rd.value : undefined; +} diff --git a/libs/shared/src/testing/remote-data.ts b/libs/shared/src/testing/remote-data.ts index 1ed22f2..5889dd2 100644 --- a/libs/shared/src/testing/remote-data.ts +++ b/libs/shared/src/testing/remote-data.ts @@ -11,3 +11,5 @@ export const loading = (): RemoteData => ({ tag: 'Lo export const success = (value: T): RemoteData => ({ tag: 'Success', value }); export const failure = (error: E): RemoteData => ({ tag: 'Failure', error }); + +export const empty = (): RemoteData => ({ tag: 'Empty' });