From 0bef08e5b31e5a04731b3edc947f7d23db0bf0a4 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 1 Jul 2026 14:43:36 +0200 Subject: [PATCH] Mijn aanvragen (F1): ApplicationsStore + dashboard blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=), 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 --- .../application/applications.store.ts | 51 ++++++++ .../aanvraag-block.component.ts | 109 ++++++++++++++++++ .../aanvraag-block/aanvraag-block.stories.ts | 26 +++++ src/app/registratie/ui/dashboard.page.ts | 46 +++++++- 4 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 src/app/registratie/application/applications.store.ts create mode 100644 src/app/registratie/ui/aanvraag-block/aanvraag-block.component.ts create mode 100644 src/app/registratie/ui/aanvraag-block/aanvraag-block.stories.ts diff --git a/src/app/registratie/application/applications.store.ts b/src/app/registratie/application/applications.store.ts new file mode 100644 index 0000000..b2f689d --- /dev/null +++ b/src/app/registratie/application/applications.store.ts @@ -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>(new Set()); + + readonly applications = computed>(() => { + 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 + }); + } + } +} diff --git a/src/app/registratie/ui/aanvraag-block/aanvraag-block.component.ts b/src/app/registratie/ui/aanvraag-block/aanvraag-block.component.ts new file mode 100644 index 0000000..0264b18 --- /dev/null +++ b/src/app/registratie/ui/aanvraag-block/aanvraag-block.component.ts @@ -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 = { + 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: ` + +
+ {{ typeLabel() }} + +
+ + @switch (aanvraag().status.tag) { + @case ('Concept') { +

Stap {{ stap().index }} van {{ stap().count }}

+ } + @case ('InBehandeling') { +

Referentie {{ ref() }} · ingediend op {{ aanvraag().submittedAt | date: 'longDate' }}

+ @if (manual()) { +

Uw aanvraag wordt handmatig beoordeeld in de backoffice.

+ } + } + @case ('Goedgekeurd') { +

Referentie {{ ref() }}

+ } + @case ('Afgewezen') { +

Referentie {{ ref() }}

+

{{ reden() }}

+ } + } + + @if (actions().length) { +
+ @if (actions().includes('resume')) { + Verder gaan + } + @if (actions().includes('cancel')) { + Annuleren + } +
+ } +
+ `, +}) +export class AanvraagBlockComponent { + aanvraag = input.required(); + + resume = output(); + cancel = output(); + + 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 : ''; + }); +} diff --git a/src/app/registratie/ui/aanvraag-block/aanvraag-block.stories.ts b/src/app/registratie/ui/aanvraag-block/aanvraag-block.stories.ts new file mode 100644 index 0000000..77246d7 --- /dev/null +++ b/src/app/registratie/ui/aanvraag-block/aanvraag-block.stories.ts @@ -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; + +const meta: Meta = { + title: 'Organisms/Aanvraag Block', + component: AanvraagBlockComponent, +}; +export default meta; +type Story = StoryObj; + +// 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.' } } } }; diff --git a/src/app/registratie/ui/dashboard.page.ts b/src/app/registratie/ui/dashboard.page.ts index 4b6f1fb..01a13d1 100644 --- a/src/app/registratie/ui/dashboard.page.ts +++ b/src/app/registratie/ui/dashboard.page.ts @@ -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: ` @@ -30,6 +34,17 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
+ @if (aanvragen().length) { +
+ Mijn aanvragen +
+ @for (a of aanvragen(); track a.id) { + + } +
+
+ } + @if (store.pendingHerregistratie()) { Uw herregistratie-aanvraag is in behandeling. } @@ -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(() => { + const rd = this.apps.applications(); + if (rd.tag !== 'Success') return []; + const order: Record = { 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 = { + 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(() => {