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 { Component, computed, input, output } from '@angular/core';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
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 { ChoiceLinkComponent } from '@shared/ui/choice-link/choice-link.component';
|
||||||
import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag';
|
import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag';
|
||||||
import { blockActions } from '@registratie/domain/block-actions';
|
import { blockActions } from '@registratie/domain/block-actions';
|
||||||
@@ -10,33 +11,37 @@ const TYPE_LABELS: Record<AanvraagType, string> = {
|
|||||||
intake: $localize`:@@aanvraagBlock.type.intake:Herregistratie-intake`,
|
intake: $localize`:@@aanvraagBlock.type.intake:Herregistratie-intake`,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Organism: one application on the dashboard's "Mijn aanvragen" list, rendered as
|
/** Organism: one application on the dashboard. A resumable Concept ("lopende
|
||||||
a CIBG Huisstijl "keuzelijst" choice (choice-link). Which text/actions it shows
|
aanvraag") renders as a CIBG "melding" (warning) with its actions — verwijderen
|
||||||
is driven by the pure `blockActions(status)` and the status tag; the block only
|
as a link, aanvraag openen as a button; a submitted/resolved status renders as a
|
||||||
renders. Only a resumable Concept is clickable (stretched-link over the whole
|
plain keuzelijst card. Which actions show is driven by the pure
|
||||||
card) — a resolved status has nothing to navigate to, so it renders as a plain
|
`blockActions(status)`; the block only renders. NOTE: the caller places the two
|
||||||
(non-interactive) card. */
|
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({
|
@Component({
|
||||||
selector: 'app-aanvraag-block',
|
selector: 'app-aanvraag-block',
|
||||||
imports: [ButtonComponent, ChoiceLinkComponent],
|
imports: [ButtonComponent, AlertComponent, ChoiceLinkComponent],
|
||||||
styles: [`
|
styles: [`
|
||||||
:host{display:contents} /* see choice-link.component.ts (keeps <li> a direct <ul> child) */
|
: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
|
.actions{display:flex;align-items:center;gap:var(--rhc-space-max-md);margin-block-start:var(--rhc-space-max-sm)}
|
||||||
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)}
|
|
||||||
`],
|
`],
|
||||||
template: `
|
template: `
|
||||||
<app-choice-link
|
@if (aanvraag().status.tag === 'Concept') {
|
||||||
[heading]="typeLabel()"
|
<app-alert type="warning">
|
||||||
[instructions]="instructions()"
|
<h3 class="h5">{{ typeLabel() }}</h3>
|
||||||
[clickable]="actions().includes('resume')"
|
<p>{{ conceptText() }}</p>
|
||||||
(activate)="resume.emit()">
|
<div class="actions">
|
||||||
@if (actions().includes('cancel')) {
|
@if (actions().includes('cancel')) {
|
||||||
<div choiceActions class="actions">
|
<app-button variant="subtle" (click)="cancel.emit()" i18n="@@aanvraagBlock.verwijderen">Verwijderen</app-button>
|
||||||
<app-button variant="subtle" (click)="cancel.emit()" i18n="@@aanvraagBlock.annuleren">Annuleren</app-button>
|
}
|
||||||
</div>
|
@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 {
|
export class AanvraagBlockComponent {
|
||||||
@@ -48,11 +53,26 @@ export class AanvraagBlockComponent {
|
|||||||
protected typeLabel = computed(() => TYPE_LABELS[this.aanvraag().type]);
|
protected typeLabel = computed(() => TYPE_LABELS[this.aanvraag().type]);
|
||||||
protected actions = computed(() => blockActions(this.aanvraag().status));
|
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).
|
// Status-tag → the choice's description line (the UI's mapping, not a domain rule).
|
||||||
private statusText = computed(() => {
|
private statusText = computed(() => {
|
||||||
const s = this.aanvraag().status;
|
const s = this.aanvraag().status;
|
||||||
switch (s.tag) {
|
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 '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 'Goedgekeurd': return $localize`:@@aanvraagBlock.goedgekeurd:Referentie ${s.referentie}:ref:`;
|
||||||
case 'Afgewezen': return $localize`:@@aanvraagBlock.afgewezen: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>;
|
type Story = StoryObj<AanvraagBlockComponent>;
|
||||||
|
|
||||||
// One story per status variant; the block renders its own body + actions.
|
// 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 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 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 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">
|
<div class="app-stack">
|
||||||
@if (aanvragen().length) {
|
@if (aanvragen().length) {
|
||||||
<section>
|
<section>
|
||||||
<app-choice-list i18n-heading="@@dashboard.mijnAanvragen" heading="Mijn aanvragen" class="app-section">
|
@for (a of concepten(); track a.id) {
|
||||||
@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-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>
|
</app-choice-list>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +132,10 @@ export class DashboardPage {
|
|||||||
const order: Record<Aanvraag['status']['tag'], number> = { Concept: 0, InBehandeling: 1, Goedgekeurd: 2, Afgewezen: 2 };
|
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]);
|
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> = {
|
private readonly resumeRoutes: Record<AanvraagType, string> = {
|
||||||
registratie: '/registreren',
|
registratie: '/registreren',
|
||||||
|
|||||||
@@ -2,35 +2,36 @@ import { Component, input } from '@angular/core';
|
|||||||
|
|
||||||
type AlertType = 'info' | 'ok' | 'warning' | 'error';
|
type AlertType = 'info' | 'ok' | 'warning' | 'error';
|
||||||
|
|
||||||
/** Atom: alert/message banner. CIBG's Bootstrap build drops the stock `.alert`, so this
|
// visually-hidden alternative for the status icon (CIBG a11y requirement).
|
||||||
is a hand-rolled surface styled from the (CIBG-valued) token bridge — no raw hex. */
|
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({
|
@Component({
|
||||||
selector: 'app-alert',
|
selector: 'app-alert',
|
||||||
styles: [`
|
styles: [`.feedback>div{flex:1 1 auto;min-width:0}`],
|
||||||
.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)}
|
|
||||||
`],
|
|
||||||
template: `
|
template: `
|
||||||
<div
|
<div
|
||||||
class="alert-box"
|
class="feedback"
|
||||||
[class.alert-info]="type() === 'info'"
|
[class.feedback-info]="type() === 'info'"
|
||||||
[class.alert-ok]="type() === 'ok'"
|
[class.feedback-success]="type() === 'ok'"
|
||||||
[class.alert-warning]="type() === 'warning'"
|
[class.feedback-warning]="type() === 'warning'"
|
||||||
[class.alert-error]="type() === 'error'"
|
[class.feedback-error]="type() === 'error'"
|
||||||
role="status">
|
role="status"
|
||||||
<ng-content />
|
aria-atomic="true">
|
||||||
|
<span class="icon"><span class="visually-hidden">{{ iconLabels[type()] }}</span></span>
|
||||||
|
<div><ng-content /></div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class AlertComponent {
|
export class AlertComponent {
|
||||||
type = input<AlertType>('info');
|
type = input<AlertType>('info');
|
||||||
|
protected readonly iconLabels = ICON_LABELS;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user