From b4fb0be769bf9b58d9658a01947bb27600a51aad Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 2 Jul 2026 17:08:04 +0200 Subject: [PATCH] feat(dashboard): render a lopende aanvraag as a CIBG melding A Concept status now renders as a CIBG "melding" (warning) with its own verwijderen/openen actions, matching the real CIBG pattern for an in-progress application, instead of sharing the keuzelijst card shape used by resolved statuses. Alert atom switches from a hand-rolled surface to the vendored `.feedback` classes with a visually-hidden icon label per CIBG's a11y requirement. --- .../aanvraag-block.component.ts | 62 ++++++++++++------- .../aanvraag-block/aanvraag-block.stories.ts | 6 +- src/app/registratie/ui/dashboard.page.ts | 19 ++++-- src/app/shared/ui/alert/alert.component.ts | 45 +++++++------- 4 files changed, 83 insertions(+), 49 deletions(-) diff --git a/src/app/registratie/ui/aanvraag-block/aanvraag-block.component.ts b/src/app/registratie/ui/aanvraag-block/aanvraag-block.component.ts index 0f0f222..0a840a8 100644 --- a/src/app/registratie/ui/aanvraag-block/aanvraag-block.component.ts +++ b/src/app/registratie/ui/aanvraag-block/aanvraag-block.component.ts @@ -1,5 +1,6 @@ import { Component, computed, input, output } from '@angular/core'; import { ButtonComponent } from '@shared/ui/button/button.component'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; import { ChoiceLinkComponent } from '@shared/ui/choice-link/choice-link.component'; import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag'; import { blockActions } from '@registratie/domain/block-actions'; @@ -10,33 +11,37 @@ const TYPE_LABELS: Record = { intake: $localize`:@@aanvraagBlock.type.intake:Herregistratie-intake`, }; -/** Organism: one application on the dashboard's "Mijn aanvragen" list, rendered as - a CIBG Huisstijl "keuzelijst" choice (choice-link). Which text/actions it shows - is driven by the pure `blockActions(status)` and the status tag; the block only - renders. Only a resumable Concept is clickable (stretched-link over the whole - card) — a resolved status has nothing to navigate to, so it renders as a plain - (non-interactive) card. */ +/** Organism: one application on the dashboard. A resumable Concept ("lopende + aanvraag") renders as a CIBG "melding" (warning) with its actions — verwijderen + as a link, aanvraag openen as a button; a submitted/resolved status renders as a + plain keuzelijst card. Which actions show is driven by the pure + `blockActions(status)`; the block only renders. NOTE: the caller places the two + shapes differently — a Concept melding is a block element, the rest are `
  • `s + that must sit inside a keuzelijst `
      ` (see dashboard.page.ts). */ @Component({ selector: 'app-aanvraag-block', - imports: [ButtonComponent, ChoiceLinkComponent], + imports: [ButtonComponent, AlertComponent, ChoiceLinkComponent], styles: [` :host{display:contents} /* see choice-link.component.ts (keeps
    • a direct
        child) */ - /* Sits inside the same card as the stretched-link title — needs its own - stacking context above the stretched-link overlay (z-index:1) to stay clickable. */ - .actions{position:relative;z-index:2;margin-block-start:var(--rhc-space-max-sm)} + .actions{display:flex;align-items:center;gap:var(--rhc-space-max-md);margin-block-start:var(--rhc-space-max-sm)} `], template: ` - - @if (actions().includes('cancel')) { -
        - Annuleren + @if (aanvraag().status.tag === 'Concept') { + +

        {{ typeLabel() }}

        +

        {{ conceptText() }}

        +
        + @if (actions().includes('cancel')) { + Verwijderen + } + @if (actions().includes('resume')) { + Aanvraag openen + }
        - } - +
        + } @else { + + } `, }) export class AanvraagBlockComponent { @@ -48,11 +53,26 @@ export class AanvraagBlockComponent { protected typeLabel = computed(() => TYPE_LABELS[this.aanvraag().type]); protected actions = computed(() => blockActions(this.aanvraag().status)); + // ponytail: display-only deadline derived as createdAt + 30 dagen; move to a + // server-sent `completeBefore` on the DTO when the expiry rule becomes real. + private deadline = computed(() => { + const d = new Date(this.aanvraag().createdAt); + d.setDate(d.getDate() + 30); + return d.toISOString(); + }); + + /** The melding body for a Concept: wizard not finished + complete-before date. */ + protected conceptText = computed(() => { + const s = this.aanvraag().status; + if (s.tag !== 'Concept') return ''; + return $localize`:@@aanvraagBlock.conceptMelding:Deze aanvraag is nog niet volledig afgerond — u bent gebleven bij stap ${s.stepIndex + 1}:stap: van ${s.stepCount}:totaal:. Rond de aanvraag af vóór ${formatNL(this.deadline())}:datum:.`; + }); + // Status-tag → the choice's description line (the UI's mapping, not a domain rule). private statusText = computed(() => { const s = this.aanvraag().status; switch (s.tag) { - case 'Concept': return $localize`:@@aanvraagBlock.stap:Stap ${s.stepIndex + 1}:index: van ${s.stepCount}:count:`; + case 'Concept': return ''; case 'InBehandeling': return $localize`:@@aanvraagBlock.inBehandeling:Referentie ${s.referentie}:ref: · ingediend op ${formatNL(this.aanvraag().submittedAt)}:datum:`; case 'Goedgekeurd': return $localize`:@@aanvraagBlock.goedgekeurd:Referentie ${s.referentie}:ref:`; case 'Afgewezen': return $localize`:@@aanvraagBlock.afgewezen:Referentie ${s.referentie}:ref:`; diff --git a/src/app/registratie/ui/aanvraag-block/aanvraag-block.stories.ts b/src/app/registratie/ui/aanvraag-block/aanvraag-block.stories.ts index 1186cd9..0a63236 100644 --- a/src/app/registratie/ui/aanvraag-block/aanvraag-block.stories.ts +++ b/src/app/registratie/ui/aanvraag-block/aanvraag-block.stories.ts @@ -27,7 +27,11 @@ 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 } } } }; +// A Concept renders as a CIBG melding (block element), not a keuzelijst
      • — no
          wrapper. +export const Concept: Story = { + args: { aanvraag: { ...base, status: { tag: 'Concept', stepIndex: 1, stepCount: 3 } } }, + render: (args) => ({ props: args, template: `` }), +}; 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' } } } }; diff --git a/src/app/registratie/ui/dashboard.page.ts b/src/app/registratie/ui/dashboard.page.ts index a201b85..da09009 100644 --- a/src/app/registratie/ui/dashboard.page.ts +++ b/src/app/registratie/ui/dashboard.page.ts @@ -35,11 +35,16 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
          @if (aanvragen().length) {
          - - @for (a of aanvragen(); track a.id) { - - } - + @for (a of concepten(); track a.id) { + + } + @if (ingediend().length) { + + @for (a of ingediend(); track a.id) { + + } + + }
          } @@ -127,6 +132,10 @@ export class DashboardPage { 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]); }); + /** A Concept ("lopende aanvraag") renders as a melding above the list; the rest + as keuzelijst items — the two shapes need different HTML contexts. */ + protected concepten = computed(() => this.aanvragen().filter((a) => a.status.tag === 'Concept')); + protected ingediend = computed(() => this.aanvragen().filter((a) => a.status.tag !== 'Concept')); private readonly resumeRoutes: Record = { registratie: '/registreren', diff --git a/src/app/shared/ui/alert/alert.component.ts b/src/app/shared/ui/alert/alert.component.ts index 4e42267..18cbba1 100644 --- a/src/app/shared/ui/alert/alert.component.ts +++ b/src/app/shared/ui/alert/alert.component.ts @@ -2,35 +2,36 @@ import { Component, input } from '@angular/core'; type AlertType = 'info' | 'ok' | 'warning' | 'error'; -/** Atom: alert/message banner. CIBG's Bootstrap build drops the stock `.alert`, so this - is a hand-rolled surface styled from the (CIBG-valued) token bridge — no raw hex. */ +// visually-hidden alternative for the status icon (CIBG a11y requirement). +const ICON_LABELS: Record = { + info: $localize`:@@alert.icon.info:Informatie`, + ok: $localize`:@@alert.icon.ok:Gelukt`, + warning: $localize`:@@alert.icon.warning:Waarschuwing`, + error: $localize`:@@alert.icon.error:Foutmelding`, +}; + +/** Atom: alert/message banner — the CIBG Huisstijl "melding" + (designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored + `.feedback feedback-*` classes: the design system owns surface + icon; we add + only the icon's a11y label and a content wrapper (`.feedback` is a flex row). */ @Component({ selector: 'app-alert', - styles: [` - .alert-box{ - border:var(--rhc-border-width-sm) solid; - border-inline-start-width:var(--rhc-border-width-md); - border-radius:var(--rhc-border-radius-md); - padding:var(--rhc-space-max-md) var(--rhc-space-max-lg); - color:var(--rhc-color-foreground-default); - } - .alert-info{background:var(--rhc-color-hemelblauw-100);border-color:var(--rhc-color-foreground-link)} - .alert-ok{background:var(--rhc-color-groen-300);border-color:var(--rhc-color-groen-500)} - .alert-warning{background:var(--rhc-color-geel-100);border-color:var(--rhc-color-geel-600)} - .alert-error{background:var(--rhc-color-rood-100);border-color:var(--rhc-color-rood-500)} - `], + styles: [`.feedback>div{flex:1 1 auto;min-width:0}`], template: `
          - + class="feedback" + [class.feedback-info]="type() === 'info'" + [class.feedback-success]="type() === 'ok'" + [class.feedback-warning]="type() === 'warning'" + [class.feedback-error]="type() === 'error'" + role="status" + aria-atomic="true"> + {{ iconLabels[type()] }} +
          `, }) export class AlertComponent { type = input('info'); + protected readonly iconLabels = ICON_LABELS; }