Model Registration status as a discriminated union

Each status variant now owns its own data: only Geregistreerd carries a
herregistratieDatum; Geschorst/Doorgehaald carry their own dates + reason.
A struck-off registration can no longer hold a future herregistratie date —
that impossible combination is gone from the type.

- status-badge keys color off the tag via a switch + assertNever
- registration-summary renders only the rows a variant's data supports
- registration.json nests the status; stories cover all three variants

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 16:55:49 +02:00
parent 0920063553
commit 57b9f3f804
5 changed files with 67 additions and 26 deletions

View File

@@ -2,8 +2,7 @@
"bigNummer": "19012345601", "bigNummer": "19012345601",
"naam": "Dr. A. (Anna) de Vries", "naam": "Dr. A. (Anna) de Vries",
"beroep": "Arts", "beroep": "Arts",
"status": "Geregistreerd",
"registratiedatum": "2012-09-01", "registratiedatum": "2012-09-01",
"herregistratieDatum": "2027-09-01", "geboortedatum": "1985-03-14",
"geboortedatum": "1985-03-14" "status": { "tag": "Geregistreerd", "herregistratieDatum": "2027-09-01" }
} }

View File

@@ -1,8 +1,10 @@
import { Component, computed, input } from '@angular/core'; import { Component, computed, input } from '@angular/core';
import { RegistrationStatus } from '../../core/models'; import { StatusTag } from '../../core/models';
import { assertNever } from '../../core/fp';
/** Atom: status badge = colored RHC dot-badge + label. Hand-built to show how /** Atom: status badge = colored RHC dot-badge + label. The color is a total
you compose a new atom from design tokens (dot color driven per status). */ function of the status tag — assertNever makes a new status fail to compile
until a color is chosen for it. */
@Component({ @Component({
selector: 'app-status-badge', selector: 'app-status-badge',
template: ` template: `
@@ -13,10 +15,18 @@ import { RegistrationStatus } from '../../core/models';
`, `,
}) })
export class StatusBadgeComponent { export class StatusBadgeComponent {
status = input.required<RegistrationStatus>(); status = input.required<StatusTag>();
color = computed(() => ({ color = computed(() => {
Geregistreerd: 'var(--rhc-color-groen-500)', const tag = this.status();
Doorgehaald: 'var(--rhc-color-rood-500)', switch (tag) {
Geschorst: 'var(--rhc-color-oranje-500)', case 'Geregistreerd':
}[this.status()])); return 'var(--rhc-color-groen-500)';
case 'Doorgehaald':
return 'var(--rhc-color-rood-500)';
case 'Geschorst':
return 'var(--rhc-color-oranje-500)';
default:
return assertNever(tag);
}
});
} }

View File

@@ -1,17 +1,29 @@
export type RegistrationStatus = 'Geregistreerd' | 'Doorgehaald' | 'Geschorst'; /**
* Registration status as a discriminated union: each variant owns exactly the
* data that makes sense for it. Only an active (Geregistreerd) registration has
* a herregistratie date; a struck-off (Doorgehaald) one cannot carry one. The
* old flat interface allowed that impossible combination — this makes it
* unrepresentable.
*/
export type RegistrationStatus =
| { tag: 'Geregistreerd'; herregistratieDatum: string } // ISO date
| { tag: 'Geschorst'; geschorstTot: string; reden: string }
| { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };
/** Just the discriminant — for atoms that only need the label/color. */
export type StatusTag = RegistrationStatus['tag'];
export interface Registration { export interface Registration {
bigNummer: string; bigNummer: string;
naam: string; naam: string;
beroep: string; // arts, verpleegkundige, apotheker, ... beroep: string; // arts, verpleegkundige, apotheker, ...
status: RegistrationStatus; registratiedatum: string; // ISO date
registratiedatum: string; // ISO date
herregistratieDatum: string;
geboortedatum: string; geboortedatum: string;
status: RegistrationStatus;
} }
export interface Aantekening { export interface Aantekening {
type: string; // specialisme of aantekening type: string; // specialisme of aantekening
omschrijving: string; omschrijving: string;
datum: string; datum: string;
} }

View File

@@ -15,10 +15,23 @@ import { StatusBadgeComponent } from '../../atoms/status-badge/status-badge.comp
<app-data-row key="Naam" [value]="reg().naam" /> <app-data-row key="Naam" [value]="reg().naam" />
<app-data-row key="Beroep" [value]="reg().beroep" /> <app-data-row key="Beroep" [value]="reg().beroep" />
<app-data-row key="Status"> <app-data-row key="Status">
<app-status-badge [status]="reg().status" /> <app-status-badge [status]="reg().status.tag" />
</app-data-row> </app-data-row>
<app-data-row key="Registratiedatum" [value]="reg().registratiedatum | date:'longDate'" /> <app-data-row key="Registratiedatum" [value]="reg().registratiedatum | date:'longDate'" />
<app-data-row key="Uiterste herregistratie" [value]="reg().herregistratieDatum | date:'longDate'" /> <!-- Each status variant renders only the row its own data supports. -->
@switch (reg().status.tag) {
@case ('Geregistreerd') {
<app-data-row key="Uiterste herregistratie" [value]="$any(reg().status).herregistratieDatum | date:'longDate'" />
}
@case ('Geschorst') {
<app-data-row key="Geschorst tot" [value]="$any(reg().status).geschorstTot | date:'longDate'" />
<app-data-row key="Reden" [value]="$any(reg().status).reden" />
}
@case ('Doorgehaald') {
<app-data-row key="Doorgehaald op" [value]="$any(reg().status).doorgehaaldOp | date:'longDate'" />
<app-data-row key="Reden" [value]="$any(reg().status).reden" />
}
}
</dl> </dl>
</div> </div>
`, `,

View File

@@ -2,15 +2,13 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { RegistrationSummaryComponent } from './registration-summary.component'; import { RegistrationSummaryComponent } from './registration-summary.component';
import { Registration } from '../../core/models'; import { Registration } from '../../core/models';
const sample: Registration = { const base = {
bigNummer: '19012345601', bigNummer: '19012345601',
naam: 'Dr. A. (Anna) de Vries', naam: 'Dr. A. (Anna) de Vries',
beroep: 'Arts', beroep: 'Arts',
status: 'Geregistreerd',
registratiedatum: '2012-09-01', registratiedatum: '2012-09-01',
herregistratieDatum: '2027-09-01',
geboortedatum: '1985-03-14', geboortedatum: '1985-03-14',
}; } satisfies Omit<Registration, 'status'>;
const meta: Meta<RegistrationSummaryComponent> = { const meta: Meta<RegistrationSummaryComponent> = {
title: 'Organisms/Registration Summary', title: 'Organisms/Registration Summary',
@@ -19,5 +17,14 @@ const meta: Meta<RegistrationSummaryComponent> = {
export default meta; export default meta;
type Story = StoryObj<RegistrationSummaryComponent>; type Story = StoryObj<RegistrationSummaryComponent>;
export const Default: Story = { args: { reg: sample } }; // Each story feeds a different union variant; the card renders only the rows
export const Geschorst: Story = { args: { reg: { ...sample, status: 'Geschorst' } } }; // that variant's data supports (note Doorgehaald has no herregistratie date).
export const Geregistreerd: Story = {
args: { reg: { ...base, status: { tag: 'Geregistreerd', herregistratieDatum: '2027-09-01' } } },
};
export const Geschorst: Story = {
args: { reg: { ...base, status: { tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'Lopend tuchtonderzoek' } } },
};
export const Doorgehaald: Story = {
args: { reg: { ...base, status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' } } },
};