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:
@@ -1,5 +1,14 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
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';
|
import { Aanvraag } from './aanvraag';
|
||||||
|
|
||||||
const base: Omit<Aanvraag, 'status'> = {
|
const base: Omit<Aanvraag, 'status'> = {
|
||||||
@@ -114,3 +123,40 @@ describe('detailRows', () => {
|
|||||||
expect(rows.length).toBe(5);
|
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;
|
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 { Component, computed, inject } from '@angular/core';
|
||||||
|
import { successOf } from '@shared/application/remote-data';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.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 find = (list: Aanvraag[]): Aanvraag | undefined => list.find((a) => a.id === this.id);
|
||||||
protected rows = detailRows;
|
protected rows = detailRows;
|
||||||
|
|
||||||
/** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */
|
/** `successOf`: `<ng-template>` can't inherit a generic from a sibling host input,
|
||||||
protected readonly aanvragen = computed(() => {
|
so the Success value is unwrapped here instead of through `let-`. */
|
||||||
const rd = this.store.aanvragen();
|
protected readonly aanvragen = computed(() => successOf(this.store.aanvragen()));
|
||||||
return rd.tag === 'Success' ? rd.value : undefined;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,25 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { MijnAanvragenSection } from './dashboard/mijn-aanvragen.section';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { WatMoetIkRegelenSection } from './dashboard/wat-moet-ik-regelen.section';
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
import { MijnRegistratieSection } from './dashboard/mijn-registratie.section';
|
||||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
import { SpecialismenSection } from './dashboard/specialismen.section';
|
||||||
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
|
import { WatWiltUDoenSection } from './dashboard/wat-wilt-u-doen.section';
|
||||||
import { TaskListComponent } from '@shared/ui/task-list/task-list.component';
|
import { BeheerLinksSection } from './dashboard/beheer-links.section';
|
||||||
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';
|
|
||||||
|
|
||||||
/** Page:"Mijn overzicht" — the portal home, following the NL Design System
|
/** Page: "Mijn overzicht" — the portal home, following the NL Design System "Mijn
|
||||||
"Mijn omgeving" pattern (side nav +"Wat moet ik regelen" +"Mijn zaken"). */
|
omgeving" pattern. Composition only: each section below answers its own data
|
||||||
|
question (own store, own async state) — see `ui/dashboard/*.section.ts`. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-dashboard-page',
|
selector: 'app-dashboard-page',
|
||||||
imports: [
|
imports: [
|
||||||
PageShellComponent,
|
PageShellComponent,
|
||||||
HeadingComponent,
|
MijnAanvragenSection,
|
||||||
AlertComponent,
|
WatMoetIkRegelenSection,
|
||||||
SkeletonComponent,
|
MijnRegistratieSection,
|
||||||
DataRowComponent,
|
SpecialismenSection,
|
||||||
DataBlockComponent,
|
WatWiltUDoenSection,
|
||||||
TaskListComponent,
|
BeheerLinksSection,
|
||||||
ApplicationListComponent,
|
|
||||||
ApplicationLinkComponent,
|
|
||||||
...ASYNC,
|
|
||||||
RegistrationSummaryComponent,
|
|
||||||
RegistrationTableComponent,
|
|
||||||
AanvraagBlockComponent,
|
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<app-page-shell
|
<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."
|
intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken."
|
||||||
>
|
>
|
||||||
<div class="app-stack">
|
<div class="app-stack">
|
||||||
@if (cancelError(); as err) {
|
<app-mijn-aanvragen-section />
|
||||||
<app-alert type="error">{{ err }}</app-alert>
|
<app-wat-moet-ik-regelen-section />
|
||||||
}
|
<app-mijn-registratie-section />
|
||||||
@if (aanvragen().length) {
|
<app-specialismen-section />
|
||||||
<section>
|
<app-wat-wilt-u-doen-section />
|
||||||
@for (a of concepten(); track a.id) {
|
<app-beheer-links-section />
|
||||||
<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>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</app-page-shell>
|
</app-page-shell>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class DashboardPage {
|
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 { Component, computed, inject } from '@angular/core';
|
||||||
|
import { successOf } from '@shared/application/remote-data';
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/async/async.component';
|
||||||
@@ -41,9 +42,7 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
|
|||||||
export class RegistrationDetailPage {
|
export class RegistrationDetailPage {
|
||||||
protected store = inject(BigProfileStore);
|
protected store = inject(BigProfileStore);
|
||||||
|
|
||||||
/** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */
|
/** `successOf`: `<ng-template>` can't inherit a generic from a sibling host input,
|
||||||
protected readonly profile = computed(() => {
|
so the Success value is unwrapped here instead of through `let-`. */
|
||||||
const rd = this.store.profile();
|
protected readonly profile = computed(() => successOf(this.store.profile()));
|
||||||
return rd.tag === 'Success' ? rd.value : undefined;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { RemoteData, map2, map } from './remote-data';
|
import { RemoteData, map2, map, successOf } from './remote-data';
|
||||||
import { loading, failure, success } from '../testing/remote-data';
|
import { loading, failure, empty, success } from '../testing/remote-data';
|
||||||
|
|
||||||
const loadingRd: RemoteData<string, number> = loading();
|
const loadingRd: RemoteData<string, number> = loading();
|
||||||
const failureRd: RemoteData<string, number> = failure('x');
|
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 });
|
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> {
|
): RemoteData<E, B> {
|
||||||
return rd.tag === 'Success' ? f(rd.value) : rd;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 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 failure = <E, T = never>(error: E): RemoteData<E, T> => ({ tag: 'Failure', error });
|
||||||
|
|
||||||
|
export const empty = <E = never, T = never>(): RemoteData<E, T> => ({ tag: 'Empty' });
|
||||||
|
|||||||
Reference in New Issue
Block a user