Catalog declared in code (Domain/Features/FeatureFlags.cs, build-validated), on/off state
persisted in SQLite (FeatureFlagStore + migration). GET /flags (drives FE gating) + admin
PUT /admin/flags/{key} (new flags:manage capability + FlagsAdmin gate). Enforced end-to-end:
the `inschrijving-open` flag hides the Inschrijven nav item + dashboard action (FE) AND makes
POST /applications for a registratie 403 when off (backend). FE FeatureFlagStore mirrors
AccessStore (enabled() deny-by-default); admin toggle page at /beheer/functies in ADMIN_LINKS.
+4 backend tests, /me cap-list updated, client regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
334 lines
14 KiB
TypeScript
334 lines
14 KiB
TypeScript
import { Component, computed, inject } from '@angular/core';
|
|
import { Router } from '@angular/router';
|
|
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 '@shared/layout/admin-links';
|
|
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 { ApplicationsStore } from '@registratie/application/applications.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
|
|
"Mijn omgeving" pattern (side nav +"Wat moet ik regelen" +"Mijn zaken"). */
|
|
@Component({
|
|
selector: 'app-dashboard-page',
|
|
imports: [
|
|
PageShellComponent,
|
|
HeadingComponent,
|
|
AlertComponent,
|
|
SkeletonComponent,
|
|
DataRowComponent,
|
|
DataBlockComponent,
|
|
TaskListComponent,
|
|
ApplicationListComponent,
|
|
ApplicationLinkComponent,
|
|
...ASYNC,
|
|
RegistrationSummaryComponent,
|
|
RegistrationTableComponent,
|
|
AanvraagBlockComponent,
|
|
],
|
|
template: `
|
|
<app-page-shell
|
|
i18n-heading="@@dashboard.heading"
|
|
heading="Mijn overzicht"
|
|
i18n-intro="@@dashboard.intro"
|
|
intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken."
|
|
>
|
|
<div class="app-stack">
|
|
@if (aanvragen().length) {
|
|
<section>
|
|
@for (a of concepten(); track a.id) {
|
|
<app-aanvraag-block
|
|
animate.enter="app-item-enter"
|
|
animate.leave="app-item-leave"
|
|
[aanvraag]="a"
|
|
(resume)="resume(a)"
|
|
(cancel)="cancelAanvraag(a)"
|
|
/>
|
|
}
|
|
@if (ingediend().length) {
|
|
<app-heading [level]="2" class="app-section" i18n="@@dashboard.mijnAanvragen"
|
|
>Mijn aanvragen</app-heading
|
|
>
|
|
<app-application-list>
|
|
@for (a of ingediend(); track a.id) {
|
|
@let row = submittedRow(a);
|
|
<li
|
|
app-application-link
|
|
animate.enter="app-item-enter"
|
|
animate.leave="app-item-leave"
|
|
[heading]="row.heading"
|
|
[subtitle]="row.subtitle"
|
|
[status]="row.status"
|
|
[to]="'/aanvraag/' + a.id"
|
|
></li>
|
|
}
|
|
</app-application-list>
|
|
}
|
|
</section>
|
|
}
|
|
|
|
@if (store.pendingHerregistratie()) {
|
|
<app-alert type="info" i18n="@@dashboard.pendingHerregistratie"
|
|
>Uw herregistratie-aanvraag is in behandeling.</app-alert
|
|
>
|
|
}
|
|
|
|
<app-async [data]="store.profile()" (retryClicked)="store.reloadProfile()">
|
|
<ng-template appAsyncLoaded>
|
|
@if (profile(); as p) {
|
|
@let tasks = tasksFor(p.registration);
|
|
|
|
<section>
|
|
@if (tasks.length) {
|
|
<app-task-list
|
|
class="app-section"
|
|
i18n-listHeading="@@dashboard.watMoetIkRegelen"
|
|
listHeading="Wat moet ik regelen"
|
|
[tasks]="tasks"
|
|
/>
|
|
} @else {
|
|
<app-heading [level]="2" i18n="@@dashboard.watMoetIkRegelen"
|
|
>Wat moet ik regelen</app-heading
|
|
>
|
|
<p class="app-text-subtle" i18n="@@dashboard.nietsOpenstaan">
|
|
U heeft op dit moment niets openstaan.
|
|
</p>
|
|
}
|
|
</section>
|
|
|
|
<section>
|
|
<app-heading [level]="2" i18n="@@dashboard.mijnRegistratie"
|
|
>Mijn registratie</app-heading
|
|
>
|
|
<div class="app-section">
|
|
<app-registration-summary [reg]="p.registration" />
|
|
</div>
|
|
<app-data-block
|
|
class="app-section"
|
|
i18n-heading="@@dashboard.persoonsgegevens"
|
|
heading="Persoonsgegevens (BRP)"
|
|
>
|
|
<div
|
|
app-data-row
|
|
i18n-key="@@dashboard.straat"
|
|
key="Straat"
|
|
[value]="p.person.adres.straat"
|
|
></div>
|
|
<div
|
|
app-data-row
|
|
i18n-key="@@dashboard.postcode"
|
|
key="Postcode"
|
|
[value]="p.person.adres.postcode"
|
|
></div>
|
|
<div
|
|
app-data-row
|
|
i18n-key="@@dashboard.woonplaats"
|
|
key="Woonplaats"
|
|
[value]="p.person.adres.woonplaats"
|
|
></div>
|
|
</app-data-block>
|
|
</section>
|
|
}
|
|
</ng-template>
|
|
<ng-template appAsyncLoading>
|
|
<app-skeleton height="2.5rem" [count]="6" />
|
|
</ng-template>
|
|
</app-async>
|
|
|
|
<section>
|
|
<app-heading [level]="2" i18n="@@dashboard.specialismen"
|
|
>Specialismen en aantekeningen</app-heading
|
|
>
|
|
<div class="app-section">
|
|
<app-async [data]="store.aantekeningen()" (retryClicked)="store.reloadAantekeningen()">
|
|
<ng-template appAsyncLoaded>
|
|
@if (aantekeningen(); as r) {
|
|
<app-registration-table [rows]="r" />
|
|
}
|
|
</ng-template>
|
|
<ng-template appAsyncLoading>
|
|
<app-skeleton height="2.5rem" [count]="3" />
|
|
</ng-template>
|
|
<ng-template appAsyncEmpty>
|
|
<p class="app-text-subtle" i18n="@@dashboard.geenSpecialismen">
|
|
U heeft nog geen specialismen of aantekeningen.
|
|
</p>
|
|
</ng-template>
|
|
</app-async>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<app-heading [level]="2" i18n="@@dashboard.watWiltUDoen">Wat wilt u doen?</app-heading>
|
|
<app-application-list class="app-section">
|
|
@for (a of acties(); track a.to) {
|
|
<li
|
|
app-application-link
|
|
[heading]="a.titel"
|
|
[subtitle]="a.tekst"
|
|
[cta]="a.actie"
|
|
[to]="a.to"
|
|
></li>
|
|
}
|
|
</app-application-list>
|
|
</section>
|
|
|
|
@if (adminLinks().length) {
|
|
<section>
|
|
<app-heading [level]="2" i18n="@@dashboard.beheer">Beheer</app-heading>
|
|
<app-application-list class="app-section">
|
|
@for (link of adminLinks(); track link.to) {
|
|
<li
|
|
app-application-link
|
|
[heading]="link.label"
|
|
[subtitle]="link.description"
|
|
[to]="link.to"
|
|
></li>
|
|
}
|
|
</app-application-list>
|
|
</section>
|
|
}
|
|
</div>
|
|
</app-page-shell>
|
|
`,
|
|
})
|
|
export class DashboardPage {
|
|
protected store = inject(BigProfileStore);
|
|
private apps = inject(ApplicationsStore);
|
|
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 applications, sorted Concept → In behandeling → resolved. Empty →
|
|
the"Mijn aanvragen" section is hidden (see template). */
|
|
protected aanvragen = computed<Aanvraag[]>(() => {
|
|
const rd = this.apps.applications();
|
|
if (rd.tag !== 'Success') return [];
|
|
const order: Record<Aanvraag['status']['tag'], number> = {
|
|
Concept: 0,
|
|
InBehandeling: 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);
|
|
}
|
|
|
|
/** 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),
|
|
),
|
|
);
|
|
}
|