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.
This commit is contained in:
2026-07-02 17:08:04 +02:00
parent f769242f76
commit b4fb0be769
4 changed files with 83 additions and 49 deletions

View File

@@ -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<AanvraagType, string> = {
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 `<li>`s
that must sit inside a keuzelijst `<ul>` (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 <li> a direct <ul> 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: `
<app-choice-link
[heading]="typeLabel()"
[instructions]="instructions()"
[clickable]="actions().includes('resume')"
(activate)="resume.emit()">
@if (aanvraag().status.tag === 'Concept') {
<app-alert type="warning">
<h3 class="h5">{{ typeLabel() }}</h3>
<p>{{ conceptText() }}</p>
<div class="actions">
@if (actions().includes('cancel')) {
<div choiceActions class="actions">
<app-button variant="subtle" (click)="cancel.emit()" i18n="@@aanvraagBlock.annuleren">Annuleren</app-button>
</div>
<app-button variant="subtle" (click)="cancel.emit()" i18n="@@aanvraagBlock.verwijderen">Verwijderen</app-button>
}
@if (actions().includes('resume')) {
<app-button (click)="resume.emit()" i18n="@@aanvraagBlock.openen">Aanvraag openen</app-button>
}
</div>
</app-alert>
} @else {
<app-choice-link [heading]="typeLabel()" [instructions]="instructions()" />
}
</app-choice-link>
`,
})
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:`;

View File

@@ -27,7 +27,11 @@ 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 } } } };
// A Concept renders as a CIBG melding (block element), not a keuzelijst <li> — no <ul> wrapper.
export const Concept: Story = {
args: { aanvraag: { ...base, status: { tag: 'Concept', stepIndex: 1, stepCount: 3 } } },
render: (args) => ({ props: args, template: `<app-aanvraag-block [aanvraag]="aanvraag" />` }),
};
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' } } } };

View File

@@ -35,11 +35,16 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
<div class="app-stack">
@if (aanvragen().length) {
<section>
<app-choice-list i18n-heading="@@dashboard.mijnAanvragen" heading="Mijn aanvragen" class="app-section">
@for (a of aanvragen(); track a.id) {
@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-choice-list i18n-heading="@@dashboard.mijnAanvragen" heading="Mijn aanvragen" class="app-section">
@for (a of ingediend(); track a.id) {
<app-aanvraag-block animate.enter="app-item-enter" animate.leave="app-item-leave" [aanvraag]="a" />
}
</app-choice-list>
}
</section>
}
@@ -127,6 +132,10 @@ export class DashboardPage {
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',

View File

@@ -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<AlertType, string> = {
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: `
<div
class="alert-box"
[class.alert-info]="type() === 'info'"
[class.alert-ok]="type() === 'ok'"
[class.alert-warning]="type() === 'warning'"
[class.alert-error]="type() === 'error'"
role="status">
<ng-content />
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">
<span class="icon"><span class="visually-hidden">{{ iconLabels[type()] }}</span></span>
<div><ng-content /></div>
</div>
`,
})
export class AlertComponent {
type = input<AlertType>('info');
protected readonly iconLabels = ICON_LABELS;
}