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:
@@ -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 (actions().includes('cancel')) {
|
||||
<div choiceActions class="actions">
|
||||
<app-button variant="subtle" (click)="cancel.emit()" i18n="@@aanvraagBlock.annuleren">Annuleren</app-button>
|
||||
@if (aanvraag().status.tag === 'Concept') {
|
||||
<app-alert type="warning">
|
||||
<h3 class="h5">{{ typeLabel() }}</h3>
|
||||
<p>{{ conceptText() }}</p>
|
||||
<div class="actions">
|
||||
@if (actions().includes('cancel')) {
|
||||
<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-choice-link>
|
||||
</app-alert>
|
||||
} @else {
|
||||
<app-choice-link [heading]="typeLabel()" [instructions]="instructions()" />
|
||||
}
|
||||
`,
|
||||
})
|
||||
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:`;
|
||||
|
||||
@@ -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' } } } };
|
||||
|
||||
@@ -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) {
|
||||
<app-aanvraag-block animate.enter="app-item-enter" animate.leave="app-item-leave" [aanvraag]="a" (resume)="resume(a)" (cancel)="cancelAanvraag(a)" />
|
||||
}
|
||||
</app-choice-list>
|
||||
@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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user