Mijn aanvragen (F1): ApplicationsStore + dashboard blocks

The payoff — A–E become visible. The dashboard now shows a "Mijn aanvragen"
section at the top with a block per application.

- ApplicationsStore (registratie/application, root): the list as a RemoteData
  signal, parsed at the trust boundary; reload() (dashboard revisit reflects
  server-computed auto-approval); optimistic cancel (hide → reload / un-hide on fail).
- aanvraag-block (organism): badge (tag → colour/label) + per-status body
  ("Stap X van Y" / referentie + ingediend-datum / manual note / reden) + actions
  from the pure blockActions. Composes card + status-badge + button. Stories per status.
- dashboard: "Mijn aanvragen" section (hidden when empty), sorted Concept → In
  behandeling → resolved; Verder gaan deep-links the wizard (?aanvraag=<id>),
  Annuleren cancels via the store.

Deferred to F2: document-chip preview/download affordance.
Gates green: vitest 128, lint, build, check:tokens; backend dotnet 56.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 14:43:36 +02:00
parent 9f217abe19
commit 0bef08e5b3
4 changed files with 231 additions and 1 deletions

View File

@@ -0,0 +1,51 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { RemoteData, fromResource } from '@shared/application/remote-data';
import { Aanvraag } from '@registratie/domain/aanvraag';
import { ApplicationsAdapter, parseApplications } from '@registratie/infrastructure/applications.adapter';
type Err = Error | undefined;
/**
* The dashboard's view of the user's applications (aanvragen) — the backend is the
* system of record (PRD 0001). One root singleton owns the list resource and exposes
* it as a RemoteData signal, validated at the trust boundary (DTO → domain). Cancel
* is optimistic (hide immediately, reload to confirm, un-hide on failure); `reload`
* re-fetches so a page revisit reflects auto-approval (Concept → In behandeling →
* Goedgekeurd is computed server-side on read).
*/
@Injectable({ providedIn: 'root' })
export class ApplicationsStore {
private adapter = inject(ApplicationsAdapter);
private res = this.adapter.applicationsResource();
/** Ids optimistically hidden while their cancel is in flight (or done). */
private cancelling = signal<ReadonlySet<string>>(new Set());
readonly applications = computed<RemoteData<Err, Aanvraag[]>>(() => {
const rd = fromResource(this.res);
if (rd.tag !== 'Success') return rd;
const parsed = parseApplications(rd.value ?? []);
if (!parsed.ok) return { tag: 'Failure', error: new Error(parsed.error) };
const hidden = this.cancelling();
return { tag: 'Success', value: parsed.value.filter((a) => !hidden.has(a.id)) };
});
/** Re-fetch (e.g. on dashboard revisit) so auto-approval transitions show up. */
reload() {
this.res.reload();
}
/** Cancel a Concept: hide it optimistically, then confirm (reload) or roll back. */
async cancel(id: string) {
this.cancelling.update((s) => new Set(s).add(id));
try {
await this.adapter.cancel(id);
this.res.reload(); // the reloaded list no longer contains it; the hide is now a no-op
} catch {
this.cancelling.update((s) => {
const next = new Set(s);
next.delete(id);
return next; // roll back: the block reappears
});
}
}
}

View File

@@ -0,0 +1,109 @@
import { Component, computed, input, output } from '@angular/core';
import { DatePipe } from '@angular/common';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { CardComponent } from '@shared/ui/card/card.component';
import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag';
import { blockActions } from '@registratie/domain/block-actions';
const TYPE_LABELS: Record<AanvraagType, string> = {
registratie: $localize`:@@aanvraagBlock.type.registratie:Inschrijving`,
herregistratie: $localize`:@@aanvraagBlock.type.herregistratie:Herregistratie`,
intake: $localize`:@@aanvraagBlock.type.intake:Herregistratie-intake`,
};
/** Organism: one application on the dashboard's "Mijn aanvragen" list. Which badge
+ actions it shows is driven by the pure `blockActions(status)` and the status
tag; the block only renders. Composes card + status-badge + button. */
@Component({
selector: 'app-aanvraag-block',
imports: [DatePipe, HeadingComponent, StatusBadgeComponent, ButtonComponent, CardComponent],
styles: [`
.head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--rhc-space-max-md); flex-wrap: wrap; }
.actions { display: flex; gap: var(--rhc-space-max-md); margin-block-start: var(--rhc-space-max-md); }
`],
template: `
<app-card>
<div class="head">
<app-heading [level]="3">{{ typeLabel() }}</app-heading>
<app-status-badge [label]="badgeLabel()" [color]="badgeColor()" />
</div>
@switch (aanvraag().status.tag) {
@case ('Concept') {
<p class="rhc-paragraph" i18n="@@aanvraagBlock.stap">Stap {{ stap().index }} van {{ stap().count }}</p>
}
@case ('InBehandeling') {
<p class="rhc-paragraph" i18n="@@aanvraagBlock.inBehandeling">Referentie {{ ref() }} · ingediend op {{ aanvraag().submittedAt | date: 'longDate' }}</p>
@if (manual()) {
<p class="rhc-paragraph app-text-subtle" i18n="@@aanvraagBlock.manual">Uw aanvraag wordt handmatig beoordeeld in de backoffice.</p>
}
}
@case ('Goedgekeurd') {
<p class="rhc-paragraph" i18n="@@aanvraagBlock.goedgekeurd">Referentie {{ ref() }}</p>
}
@case ('Afgewezen') {
<p class="rhc-paragraph" i18n="@@aanvraagBlock.afgewezen">Referentie {{ ref() }}</p>
<p class="rhc-paragraph app-text-subtle">{{ reden() }}</p>
}
}
@if (actions().length) {
<div class="actions">
@if (actions().includes('resume')) {
<app-button (click)="resume.emit()" i18n="@@aanvraagBlock.verderGaan">Verder gaan</app-button>
}
@if (actions().includes('cancel')) {
<app-button variant="subtle" (click)="cancel.emit()" i18n="@@aanvraagBlock.annuleren">Annuleren</app-button>
}
</div>
}
</app-card>
`,
})
export class AanvraagBlockComponent {
aanvraag = input.required<Aanvraag>();
resume = output<void>();
cancel = output<void>();
protected typeLabel = computed(() => TYPE_LABELS[this.aanvraag().type]);
protected actions = computed(() => blockActions(this.aanvraag().status));
// Status-tag → badge presentation (the UI's mapping, not a domain rule).
protected badgeLabel = computed(() => {
switch (this.aanvraag().status.tag) {
case 'Concept': return $localize`:@@aanvraagBlock.badge.concept:Concept`;
case 'InBehandeling': return $localize`:@@aanvraagBlock.badge.inBehandeling:In behandeling`;
case 'Goedgekeurd': return $localize`:@@aanvraagBlock.badge.goedgekeurd:Goedgekeurd`;
case 'Afgewezen': return $localize`:@@aanvraagBlock.badge.afgewezen:Afgewezen`;
}
});
protected badgeColor = computed(() => {
switch (this.aanvraag().status.tag) {
case 'Concept': return 'var(--rhc-color-cool-grey-300)';
case 'InBehandeling': return 'var(--rhc-color-oranje-500)';
case 'Goedgekeurd': return 'var(--rhc-color-groen-500)';
case 'Afgewezen': return 'var(--rhc-color-rood-500)';
}
});
// Field accessors per tag (the template @switch guarantees the right shape).
protected stap = computed(() => {
const s = this.aanvraag().status;
return s.tag === 'Concept' ? { index: s.stepIndex + 1, count: s.stepCount } : { index: 0, count: 0 };
});
protected ref = computed(() => {
const s = this.aanvraag().status;
return s.tag !== 'Concept' ? s.referentie : '';
});
protected manual = computed(() => {
const s = this.aanvraag().status;
return s.tag === 'InBehandeling' && s.manual;
});
protected reden = computed(() => {
const s = this.aanvraag().status;
return s.tag === 'Afgewezen' ? s.reden : '';
});
}

View File

@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { AanvraagBlockComponent } from './aanvraag-block.component';
import { Aanvraag } from '@registratie/domain/aanvraag';
const base = {
id: 'a1',
type: 'registratie',
documentIds: [],
createdAt: '2026-06-28T10:00:00Z',
updatedAt: '2026-06-28T10:05:00Z',
submittedAt: '2026-06-28T10:05:00Z',
} satisfies Omit<Aanvraag, 'status'>;
const meta: Meta<AanvraagBlockComponent> = {
title: 'Organisms/Aanvraag Block',
component: AanvraagBlockComponent,
};
export default meta;
type Story = StoryObj<AanvraagBlockComponent>;
// One story per status variant; the block renders its own body + actions.
export const Concept: Story = { args: { aanvraag: { ...base, status: { tag: 'Concept', stepIndex: 1, stepCount: 3 } } } };
export const InBehandelingAuto: Story = { args: { aanvraag: { ...base, status: { tag: 'InBehandeling', referentie: 'BIG-2026-456789', manual: false } } } };
export const InBehandelingManual: Story = { args: { aanvraag: { ...base, type: 'registratie', status: { tag: 'InBehandeling', referentie: 'BIG-2026-456789', manual: true } } } };
export const Goedgekeurd: Story = { args: { aanvraag: { ...base, status: { tag: 'Goedgekeurd', referentie: 'BIG-2026-456789' } } } };
export const Afgewezen: Story = { args: { aanvraag: { ...base, type: 'herregistratie', status: { tag: 'Afgewezen', referentie: 'BIG-2026-456789', reden: 'Aanvraag afgewezen: geen gewerkte uren geregistreerd.' } } } };

View File

@@ -1,4 +1,5 @@
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 { LinkComponent } from '@shared/ui/link/link.component';
@@ -11,8 +12,11 @@ import { SideNavComponent, NavItem } from '@shared/layout/side-nav/side-nav.comp
import { ASYNC } from '@shared/ui/async/async.component';
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 { tasksFromProfile } from '@registratie/domain/tasks';
/** Page: "Mijn overzicht" — the portal home, following the NL Design System
@@ -22,7 +26,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
imports: [
PageShellComponent, HeadingComponent, LinkComponent, AlertComponent, SkeletonComponent,
DataRowComponent, CardComponent, TaskListComponent, SideNavComponent, ...ASYNC,
RegistrationSummaryComponent, RegistrationTableComponent,
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.">
@@ -30,6 +34,17 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
<app-side-nav [items]="nav" />
<div class="app-stack">
@if (aanvragen().length) {
<section>
<app-heading [level]="2" i18n="@@dashboard.mijnAanvragen">Mijn aanvragen</app-heading>
<div class="app-stack app-section">
@for (a of aanvragen(); track a.id) {
<app-aanvraag-block [aanvraag]="a" (resume)="resume(a)" (cancel)="cancelAanvraag(a)" />
}
</div>
</section>
}
@if (store.pendingHerregistratie()) {
<app-alert type="info" i18n="@@dashboard.pendingHerregistratie">Uw herregistratie-aanvraag is in behandeling.</app-alert>
}
@@ -103,6 +118,35 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
})
export class DashboardPage {
protected store = inject(BigProfileStore);
private apps = inject(ApplicationsStore);
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.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]);
});
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(() => {