style: format frontend, docs and skills with prettier; add .prettierignore

One-time prettier --write so the new format:check CI gate starts green.
.prettierignore excludes generated (api-client.ts, documentation.json),
vendored (public/cibg-huisstijl), and backend (dotnet format owns it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-03 13:39:31 +02:00
co-authored by Claude Opus 4.8
parent 546097434d
commit e82309786d
176 changed files with 5067 additions and 1469 deletions
@@ -1,7 +1,10 @@
import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { Aanvraag } from '@registratie/domain/aanvraag';
import { ApplicationsAdapter, parseApplications } from '@registratie/infrastructure/applications.adapter';
import {
ApplicationsAdapter,
parseApplications,
} from '@registratie/infrastructure/applications.adapter';
type Err = Error | undefined;
@@ -31,7 +34,11 @@ export class ApplicationsStore {
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
try {
const parsed = parseApplications(await this.adapter.list());
this.state.set(parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) });
this.state.set(
parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) },
);
} catch (e) {
this.state.set({ tag: 'Failure', error: e as Error });
}
@@ -4,7 +4,11 @@ import { Aantekening } from '../domain/registration';
import { BigProfile } from '../domain/big-profile';
import { HerregistratieDecisions } from '../contracts/dashboard-view.dto';
import { BigRegisterAdapter } from '../infrastructure/big-register.adapter';
import { DashboardView, DashboardViewAdapter, parseDashboardView } from '../infrastructure/dashboard-view.adapter';
import {
DashboardView,
DashboardViewAdapter,
parseDashboardView,
} from '../infrastructure/dashboard-view.adapter';
type Err = Error | undefined;
@@ -32,14 +36,20 @@ export class BigProfileStore {
const rd = fromResource(this.viewRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseDashboardView(rd.value);
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
return parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) };
});
/** Registration + person, from the single aggregated call. */
readonly profile = computed<RemoteData<Err, BigProfile>>(() => map(this.view(), (v) => v.profile));
readonly profile = computed<RemoteData<Err, BigProfile>>(() =>
map(this.view(), (v) => v.profile),
);
/** Server-computed decisions (e.g. herregistratie eligibility) — rendered, not recomputed. */
readonly decisions = computed<RemoteData<Err, HerregistratieDecisions>>(() => map(this.view(), (v) => v.decisions));
readonly decisions = computed<RemoteData<Err, HerregistratieDecisions>>(() =>
map(this.view(), (v) => v.decisions),
);
/** Specialisms/notes stay a separate stream (they have their own empty state). */
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() => {
+36 -7
View File
@@ -2,9 +2,15 @@ import { DestroyRef, effect, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Result } from '@shared/kernel/fp';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import type { SubmitApplicationRequest, SubmitApplicationResponse } from '@shared/infrastructure/api-client';
import type {
SubmitApplicationRequest,
SubmitApplicationResponse,
} from '@shared/infrastructure/api-client';
import { AanvraagType } from '@registratie/domain/aanvraag';
import { ApplicationsAdapter, parseApplications } from '@registratie/infrastructure/applications.adapter';
import {
ApplicationsAdapter,
parseApplications,
} from '@registratie/infrastructure/applications.adapter';
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */
export interface DraftSnapshot {
@@ -59,7 +65,12 @@ export function createDraftSync(deps: DraftSyncDeps) {
ensuring ??= adapter.create(deps.type).then((newId) => {
id = newId;
// Stamp the id into the URL (no navigation) so a reload resumes this Concept.
void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: newId }, queryParamsHandling: 'merge', replaceUrl: true });
void router!.navigate([], {
relativeTo: route!,
queryParams: { aanvraag: newId },
queryParamsHandling: 'merge',
replaceUrl: true,
});
return newId;
});
return ensuring;
@@ -78,7 +89,12 @@ export function createDraftSync(deps: DraftSyncDeps) {
const snap = deps.snapshot();
if (!snap) return;
const theId = await ensureId();
await adapter.syncDraft(theId, { draft: snap.draft, stepIndex: snap.stepIndex, stepCount: snap.stepCount, documentIds: snap.documentIds });
await adapter.syncDraft(theId, {
draft: snap.draft,
stepIndex: snap.stepIndex,
stepCount: snap.stepCount,
documentIds: snap.documentIds,
});
};
// One effect watches the snapshot; each change resets a debounce timer. The timer's
@@ -117,7 +133,9 @@ export function createDraftSync(deps: DraftSyncDeps) {
const findConcept = async (): Promise<string | undefined> => {
try {
const parsed = parseApplications(await adapter.list());
return parsed.ok ? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id : undefined;
return parsed.ok
? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id
: undefined;
} catch {
return undefined;
}
@@ -143,7 +161,12 @@ export function createDraftSync(deps: DraftSyncDeps) {
if (existing) {
await load(existing);
// Stamp the id into the URL so a reload resumes the same Concept.
void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: existing }, queryParamsHandling: 'merge', replaceUrl: true });
void router!.navigate([], {
relativeTo: route!,
queryParams: { aanvraag: existing },
queryParamsHandling: 'merge',
replaceUrl: true,
});
return;
}
applyResume(null);
@@ -169,7 +192,13 @@ export function createDraftSync(deps: DraftSyncDeps) {
id = undefined;
ensuring = undefined;
}
if (active()) void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: null }, queryParamsHandling: 'merge', replaceUrl: true });
if (active())
void router!.navigate([], {
relativeTo: route!,
queryParams: { aanvraag: null },
queryParamsHandling: 'merge',
replaceUrl: true,
});
},
};
}
@@ -36,19 +36,23 @@ export class RegistratieLookupStore {
/** The address to prefill the draft with, once BRP resolves with a found address;
null otherwise (loading, error, no address, malformed). */
readonly prefillAdres = computed<{ straat: string; postcode: string; woonplaats: string } | null>(() => {
const json = this.adresRes.value();
if (json === undefined) return null;
const parsed = parseBrpAddress(json);
return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null;
});
readonly prefillAdres = computed<{ straat: string; postcode: string; woonplaats: string } | null>(
() => {
const json = this.adresRes.value();
if (json === undefined) return null;
const parsed = parseBrpAddress(json);
return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null;
},
);
/** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */
readonly duoLookup = computed<RemoteData<Err, DuoLookupDto>>(() => {
const rd = fromResource(this.diplomasRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseDuoLookup(rd.value);
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
return parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) };
});
/** Reload the BRP lookup (e.g. when the wizard restarts) so the address re-prefills. */
@@ -2,33 +2,57 @@ import { describe, it, expect } from 'vitest';
import { submittedRow, detailRows, purposeLabel, statusLabel, TYPE_LABELS } from './aanvraag-view';
import { Aanvraag } from './aanvraag';
const base = { id: '1', type: 'herregistratie' as const, documentIds: [], createdAt: '', updatedAt: '', submittedAt: '2024-05-12' };
const base = {
id: '1',
type: 'herregistratie' as const,
documentIds: [],
createdAt: '',
updatedAt: '',
submittedAt: '2024-05-12',
};
describe('submittedRow', () => {
it('heading is the type, subtitle is the purpose', () => {
const row = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: false } } as Aanvraag);
const row = submittedRow({
...base,
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
} as Aanvraag);
expect(row.heading).toBe(TYPE_LABELS.herregistratie);
expect(row.subtitle).toBe(purposeLabel('herregistratie'));
});
it('status line carries the status label, reference and submit date', () => {
const row = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: false } } as Aanvraag);
expect(row.status).toContain(statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }));
const row = submittedRow({
...base,
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
} as Aanvraag);
expect(row.status).toContain(
statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }),
);
expect(row.status).toContain('R1');
expect(row.status).toContain('12 mei 2024');
});
it('manual review adds a note; rejection adds its reason', () => {
const manual = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: true } } as Aanvraag);
const manual = submittedRow({
...base,
status: { tag: 'InBehandeling', referentie: 'R1', manual: true },
} as Aanvraag);
expect(manual.status).toContain('handmatig');
const rejected = submittedRow({ ...base, status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' } } as Aanvraag);
const rejected = submittedRow({
...base,
status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' },
} as Aanvraag);
expect(rejected.status).toContain('Onvoldoende uren');
});
});
describe('detailRows', () => {
it('lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected', () => {
const rows = detailRows({ ...base, status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' } } as Aanvraag);
const rows = detailRows({
...base,
status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' },
} as Aanvraag);
const values = rows.map((r) => r.value);
expect(values).toContain(TYPE_LABELS.herregistratie);
expect(values).toContain('R2');
@@ -37,7 +61,11 @@ describe('detailRows', () => {
});
it('reference falls back to em dash for a Concept', () => {
const rows = detailRows({ ...base, submittedAt: undefined, status: { tag: 'Concept', stepIndex: 0, stepCount: 3 } } as Aanvraag);
const rows = detailRows({
...base,
submittedAt: undefined,
status: { tag: 'Concept', stepIndex: 0, stepCount: 3 },
} as Aanvraag);
const ref = rows.find((r) => r.value === '—');
expect(ref).toBeTruthy();
expect(rows.length).toBe(5);
+40 -14
View File
@@ -13,19 +13,26 @@ export const TYPE_LABELS: Record<AanvraagType, string> = {
/** What the aanvraag is for (shown under the title). */
export function purposeLabel(type: AanvraagType): string {
switch (type) {
case 'registratie': return $localize`:@@aanvraag.purpose.registratie:Inschrijving in het BIG-register`;
case 'herregistratie': return $localize`:@@aanvraag.purpose.herregistratie:Verlenging van uw BIG-registratie`;
case 'intake': return $localize`:@@aanvraag.purpose.intake:Intake-vragenlijst voor uw herregistratie`;
case 'registratie':
return $localize`:@@aanvraag.purpose.registratie:Inschrijving in het BIG-register`;
case 'herregistratie':
return $localize`:@@aanvraag.purpose.herregistratie:Verlenging van uw BIG-registratie`;
case 'intake':
return $localize`:@@aanvraag.purpose.intake:Intake-vragenlijst voor uw herregistratie`;
}
}
/** The status as a plain label (what state the aanvraag is in). */
export function statusLabel(status: AanvraagStatus): string {
switch (status.tag) {
case 'Concept': return $localize`:@@aanvraag.status.concept:Concept (nog niet ingediend)`;
case 'InBehandeling': return $localize`:@@aanvraag.status.inBehandeling:In behandeling`;
case 'Goedgekeurd': return $localize`:@@aanvraag.status.goedgekeurd:Goedgekeurd`;
case 'Afgewezen': return $localize`:@@aanvraag.status.afgewezen:Afgewezen`;
case 'Concept':
return $localize`:@@aanvraag.status.concept:Concept (nog niet ingediend)`;
case 'InBehandeling':
return $localize`:@@aanvraag.status.inBehandeling:In behandeling`;
case 'Goedgekeurd':
return $localize`:@@aanvraag.status.goedgekeurd:Goedgekeurd`;
case 'Afgewezen':
return $localize`:@@aanvraag.status.afgewezen:Afgewezen`;
}
}
@@ -43,7 +50,9 @@ export interface AanvraagRow {
}
function formatNL(iso?: string): string {
return iso ? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
return iso
? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })
: '';
}
/** Fields for a submitted aanvraag's row in the dashboard "aanvragen" list (Concept
@@ -53,10 +62,18 @@ export function submittedRow(a: Aanvraag): AanvraagRow {
const parts = [statusLabel(s)];
const ref = referentie(s);
if (ref) parts.push($localize`:@@aanvraag.row.ref:Referentie ${ref}:ref:`);
if (a.submittedAt) parts.push($localize`:@@aanvraag.row.ingediend:ingediend op ${formatNL(a.submittedAt)}:datum:`);
if (s.tag === 'InBehandeling' && s.manual) parts.push($localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`);
if (a.submittedAt)
parts.push($localize`:@@aanvraag.row.ingediend:ingediend op ${formatNL(a.submittedAt)}:datum:`);
if (s.tag === 'InBehandeling' && s.manual)
parts.push(
$localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`,
);
if (s.tag === 'Afgewezen') parts.push(s.reden);
return { heading: TYPE_LABELS[a.type], subtitle: purposeLabel(a.type), status: parts.join(' · ') };
return {
heading: TYPE_LABELS[a.type],
subtitle: purposeLabel(a.type),
status: parts.join(' · '),
};
}
/** Key/value rows for the case-detail page (CIBG Datablock). */
@@ -65,11 +82,20 @@ export function detailRows(a: Aanvraag): { key: string; value: string }[] {
{ key: $localize`:@@aanvraag.detail.soort:Soort aanvraag`, value: TYPE_LABELS[a.type] },
{ key: $localize`:@@aanvraag.detail.waarvoor:Waarvoor`, value: purposeLabel(a.type) },
{ key: $localize`:@@aanvraag.detail.status:Status`, value: statusLabel(a.status) },
{ key: $localize`:@@aanvraag.detail.referentie:Referentie`, value: referentie(a.status) || '—' },
{ key: $localize`:@@aanvraag.detail.ingediend:Ingediend op`, value: a.submittedAt ? formatNL(a.submittedAt) : '—' },
{
key: $localize`:@@aanvraag.detail.referentie:Referentie`,
value: referentie(a.status) || '—',
},
{
key: $localize`:@@aanvraag.detail.ingediend:Ingediend op`,
value: a.submittedAt ? formatNL(a.submittedAt) : '—',
},
];
if (a.status.tag === 'Afgewezen') {
rows.push({ key: $localize`:@@aanvraag.detail.reden:Reden van afwijzing`, value: a.status.reden });
rows.push({
key: $localize`:@@aanvraag.detail.reden:Reden van afwijzing`,
value: a.status.reden,
});
}
return rows;
}
@@ -3,11 +3,16 @@ import { blockActions } from './block-actions';
describe('blockActions', () => {
it('a Concept can be resumed or cancelled', () => {
expect(blockActions({ tag: 'Concept', stepIndex: 1, stepCount: 3 })).toEqual(['resume', 'cancel']);
expect(blockActions({ tag: 'Concept', stepIndex: 1, stepCount: 3 })).toEqual([
'resume',
'cancel',
]);
});
it('an in-behandeling aanvraag only exposes its documents', () => {
expect(blockActions({ tag: 'InBehandeling', referentie: 'BIG-1', manual: true })).toEqual(['viewDocuments']);
expect(blockActions({ tag: 'InBehandeling', referentie: 'BIG-1', manual: true })).toEqual([
'viewDocuments',
]);
});
it('resolved aanvragen have no actions', () => {
@@ -1,7 +1,9 @@
import { describe, it, expect } from 'vitest';
import { State, reduce, initial } from './change-request.machine';
const editingWith = (draft: Partial<{ straat: string; postcode: string; woonplaats: string }>): State => ({
const editingWith = (
draft: Partial<{ straat: string; postcode: string; woonplaats: string }>,
): State => ({
tag: 'Editing',
draft: { straat: '', postcode: '', woonplaats: '', ...draft },
errors: {},
@@ -23,13 +25,17 @@ describe('change-request reduce', () => {
});
it('Submit with a valid draft moves to Submitting with parsed (normalised) data', () => {
const s = reduce(editingWith({ straat: 'Lange Voorhout 9', postcode: '2514ea' }), { tag: 'Submit' });
const s = reduce(editingWith({ straat: 'Lange Voorhout 9', postcode: '2514ea' }), {
tag: 'Submit',
});
expect(s.tag).toBe('Submitting');
expect((s as Extract<State, { tag: 'Submitting' }>).data.postcode).toBe('2514 EA');
});
it('confirms and fails only from Submitting; Retry re-submits a failure', () => {
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), { tag: 'Submit' });
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), {
tag: 'Submit',
});
const ok = reduce(submitting, { tag: 'SubmitConfirmed', referentie: 'BIG-2026-1' });
expect(ok).toMatchObject({ tag: 'Submitted', referentie: 'BIG-2026-1' });
@@ -39,7 +45,9 @@ describe('change-request reduce', () => {
});
it('Reset returns to the initial editing state', () => {
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), { tag: 'Submit' });
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), {
tag: 'Submit',
});
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
});
});
@@ -43,7 +43,10 @@ function validate(draft: Draft): Result<Errors, Valid> {
if (!straat) errors.straat = $localize`:@@validation.straat:Vul straat en huisnummer in.`;
if (!postcode.ok) errors.postcode = postcode.error;
if (straat && postcode.ok) {
return { ok: true, value: { straat, postcode: postcode.value, woonplaats: draft.woonplaats.trim() } };
return {
ok: true,
value: { straat, postcode: postcode.value, woonplaats: draft.woonplaats.trim() },
};
}
return { ok: false, error: errors };
}
@@ -69,7 +72,9 @@ export function reduce(s: State, m: Msg): State {
case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed':
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data, referentie: m.referentie } : s;
return s.tag === 'Submitting'
? { tag: 'Submitted', data: s.data, referentie: m.referentie }
: s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'Reset':
@@ -1,8 +1,10 @@
import { describe, it, expect } from 'vitest';
import { hasProgress, initial, RegistratieState } from './registratie-wizard.machine';
const invullen = (over: Partial<Extract<RegistratieState, { tag: 'Invullen' }>>) =>
({ ...(initial as Extract<RegistratieState, { tag: 'Invullen' }>), ...over });
const invullen = (over: Partial<Extract<RegistratieState, { tag: 'Invullen' }>>) => ({
...(initial as Extract<RegistratieState, { tag: 'Invullen' }>),
...over,
});
describe('hasProgress', () => {
it('is false for a fresh wizard', () => {
@@ -10,13 +12,23 @@ describe('hasProgress', () => {
});
it('ignores an auto-prefilled BRP address at step 0', () => {
const s = invullen({ draft: { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', adresHerkomst: 'brp', antwoorden: {} } });
const s = invullen({
draft: {
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
adresHerkomst: 'brp',
antwoorden: {},
},
});
expect(hasProgress(s)).toBe(false);
});
it('is true once the user advances, picks correspondence/diploma, or is past step 0', () => {
expect(hasProgress(invullen({ cursor: 1 }))).toBe(true);
expect(hasProgress(invullen({ draft: { correspondentie: 'post', antwoorden: {} } }))).toBe(true);
expect(hasProgress(invullen({ draft: { correspondentie: 'post', antwoorden: {} } }))).toBe(
true,
);
expect(hasProgress(invullen({ draft: { diplomaId: 'd1', antwoorden: {} } }))).toBe(true);
});
});
@@ -29,8 +29,19 @@ const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({
upload: initialUpload,
});
const validAdres = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', correspondentie: 'post' as const, adresHerkomst: 'brp' as const };
const validDraft: Partial<Draft> = { ...validAdres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo' };
const validAdres = {
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
correspondentie: 'post' as const,
adresHerkomst: 'brp' as const,
};
const validDraft: Partial<Draft> = {
...validAdres,
diplomaId: 'd1',
beroep: 'Arts',
diplomaHerkomst: 'duo',
};
describe('STEPS (fixed)', () => {
it('always has the same three steps', () => {
@@ -106,7 +117,18 @@ describe('adres origin (BRP vs handmatig)', () => {
});
it('a manually entered address still submits (only manual diploma is gated)', () => {
const s = submit(invullen({ straat: 'Kerkstraat 1', postcode: '1234 AB', woonplaats: 'Utrecht', correspondentie: 'post', adresHerkomst: 'handmatig', diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo' }));
const s = submit(
invullen({
straat: 'Kerkstraat 1',
postcode: '1234 AB',
woonplaats: 'Utrecht',
correspondentie: 'post',
adresHerkomst: 'handmatig',
diplomaId: 'd1',
beroep: 'Arts',
diplomaHerkomst: 'duo',
}),
);
expect(s.tag).toBe('Indienen');
expect((s as any).data.adresHerkomst).toBe('handmatig');
});
@@ -185,7 +207,12 @@ describe('submit', () => {
describe('reduce (message-driven happy path)', () => {
it('drives the full flow via messages', () => {
let s: RegistratieState = initial;
s = reduce(s, { tag: 'PrefillAdres', straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' });
s = reduce(s, {
tag: 'PrefillAdres',
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
});
s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('beroep');
@@ -199,7 +226,10 @@ describe('reduce (message-driven happy path)', () => {
});
it('SubmitFailed then Retry returns to Indienen with the same data', () => {
let s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' });
let s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), {
tag: 'SubmitFailed',
error: 'boom',
});
expect(s.tag).toBe('Mislukt');
s = reduce(s, { tag: 'Retry' });
expect(s.tag).toBe('Indienen');
@@ -208,27 +238,51 @@ describe('reduce (message-driven happy path)', () => {
});
describe('inline document upload (beroep step)', () => {
const cat = { categoryId: 'diploma', label: 'Diploma', description: '', required: true, acceptedTypes: [], maxSizeMb: 10, multiple: false, allowPostDelivery: true };
const cat = {
categoryId: 'diploma',
label: 'Diploma',
description: '',
required: true,
acceptedTypes: [],
maxSizeMb: 10,
multiple: false,
allowPostDelivery: true,
};
it('routes Upload messages through the upload reducer', () => {
const s = reduce(invullen(validDraft), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } });
const s = reduce(invullen(validDraft), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
});
expect((s as any).upload.categories).toHaveLength(1);
});
it('blocks the beroep step until a required category is satisfied', () => {
let s = reduce(invullen(validDraft, 1), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } });
let s = reduce(invullen(validDraft, 1), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
});
s = reduce(s, { tag: 'Next' }); // beroep → controle blocked
expect(currentStep(s as any)).toBe('beroep');
expect((s as any).errors.documenten).toBeTruthy();
// choosing post delivery satisfies the requirement
s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' } });
s = reduce(s, {
tag: 'Upload',
msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' },
});
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('controle');
});
it('includes delivery refs in the submitted data', () => {
let s = reduce(invullen(validDraft), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } });
s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' } });
let s = reduce(invullen(validDraft), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
});
s = reduce(s, {
tag: 'Upload',
msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' },
});
const done = submit(s as any);
expect(done.tag).toBe('Indienen');
expect((done as any).data.documents).toEqual([{ categoryId: 'diploma', channel: 'post' }]);
@@ -84,7 +84,13 @@ export type RegistratieState =
| { tag: 'Mislukt'; data: ValidRegistratie; error: string };
const emptyDraft: Draft = { antwoorden: {} };
export const initial: RegistratieState = { tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {}, upload: initialUpload };
export const initial: RegistratieState = {
tag: 'Invullen',
draft: emptyDraft,
cursor: 0,
errors: {},
upload: initialUpload,
};
/** Which step the cursor currently points at (clamped to the fixed list). */
export function currentStep(s: Extract<RegistratieState, { tag: 'Invullen' }>): StepId {
@@ -112,11 +118,14 @@ function validateStep(step: StepId, d: Draft, upload: UploadState): Result<Error
const errors: Errors = {};
switch (step) {
case 'adres': {
if (!d.straat || d.straat.trim() === '') errors.straat = $localize`:@@validation.straat2:Vul een straat en huisnummer in.`;
if (!d.straat || d.straat.trim() === '')
errors.straat = $localize`:@@validation.straat2:Vul een straat en huisnummer in.`;
const pc = parsePostcode(d.postcode ?? '');
if (!pc.ok) errors.postcode = pc.error;
if (!d.woonplaats || d.woonplaats.trim() === '') errors.woonplaats = $localize`:@@validation.woonplaats:Vul een woonplaats in.`;
if (!d.correspondentie) errors.correspondentie = $localize`:@@validation.maakKeuze:Maak een keuze.`;
if (!d.woonplaats || d.woonplaats.trim() === '')
errors.woonplaats = $localize`:@@validation.woonplaats:Vul een woonplaats in.`;
if (!d.correspondentie)
errors.correspondentie = $localize`:@@validation.maakKeuze:Maak een keuze.`;
// E-mail is only required when 'email' is the chosen channel.
if (d.correspondentie === 'email') {
const e = parseEmail(d.email ?? '');
@@ -135,7 +144,8 @@ function validateStep(step: StepId, d: Draft, upload: UploadState): Result<Error
// they're answered.
const open: Record<string, string> = {};
for (const id of d.vraagIds ?? []) {
if (!(d.antwoorden[id] ?? '').trim()) open[id] = $localize`:@@validation.beantwoordVraag:Beantwoord deze vraag.`;
if (!(d.antwoorden[id] ?? '').trim())
open[id] = $localize`:@@validation.beantwoordVraag:Beantwoord deze vraag.`;
}
if (Object.keys(open).length > 0) errors.antwoorden = open;
// Required documents for this wizard attach to the beroep step (inline upload).
@@ -186,7 +196,8 @@ export function setField(s: RegistratieState, key: DraftField, value: string): R
if (s.tag !== 'Invullen') return s;
const draft: Draft = { ...s.draft, [key]: value };
// Editing an address field means the user owns it now — not the BRP copy.
if (key === 'straat' || key === 'postcode' || key === 'woonplaats') draft.adresHerkomst = 'handmatig';
if (key === 'straat' || key === 'postcode' || key === 'woonplaats')
draft.adresHerkomst = 'handmatig';
return { ...s, draft };
}
@@ -196,16 +207,30 @@ export function setCorrespondentie(s: RegistratieState, value: Correspondentie):
}
/** Prefill the address from a BRP lookup and flag its origin (PRD §7). */
export function prefillAdres(s: RegistratieState, straat: string, postcode: string, woonplaats: string): RegistratieState {
export function prefillAdres(
s: RegistratieState,
straat: string,
postcode: string,
woonplaats: string,
): RegistratieState {
if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, straat, postcode, woonplaats, adresHerkomst: 'brp' } };
}
/** Pick a DUO diploma; the beroep is derived from it and the applicable policy
questions (`vraagIds`) come with it (both server-computed, passed in). */
export function kiesDiploma(s: RegistratieState, diplomaId: string, beroep: string, vraagIds: string[]): RegistratieState {
export function kiesDiploma(
s: RegistratieState,
diplomaId: string,
beroep: string,
vraagIds: string[],
): RegistratieState {
if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, diplomaId, beroep, vraagIds, diplomaHerkomst: 'duo' }, errors: {} };
return {
...s,
draft: { ...s.draft, diplomaId, beroep, vraagIds, diplomaHerkomst: 'duo' },
errors: {},
};
}
/** Switch to manual diploma entry: the diploma isn't in DUO, so the MAXIMAL
@@ -213,7 +238,17 @@ export function kiesDiploma(s: RegistratieState, diplomaId: string, beroep: stri
beroep is declared separately (declareerBeroep). */
export function kiesHandmatig(s: RegistratieState, vraagIds: string[]): RegistratieState {
if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, diplomaId: 'handmatig', beroep: undefined, vraagIds, diplomaHerkomst: 'handmatig' }, errors: {} };
return {
...s,
draft: {
...s.draft,
diplomaId: 'handmatig',
beroep: undefined,
vraagIds,
diplomaHerkomst: 'handmatig',
},
errors: {},
};
}
/** Declare the beroep for a manually-entered diploma (chosen from a fixed list). */
@@ -260,7 +295,9 @@ export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState {
export function resolve(s: RegistratieState, r: Result<string, string>): RegistratieState {
if (s.tag !== 'Indienen') return s;
return r.ok ? { tag: 'Ingediend', data: s.data, referentie: r.value } : { tag: 'Mislukt', data: s.data, error: r.error };
return r.ok
? { tag: 'Ingediend', data: s.data, referentie: r.value }
: { tag: 'Mislukt', data: s.data, error: r.error };
}
export type RegistratieMsg =
@@ -308,7 +345,9 @@ export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState
case 'Retry':
return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s;
case 'SubmitConfirmed':
return s.tag === 'Indienen' ? { tag: 'Ingediend', data: s.data, referentie: m.referentie } : s;
return s.tag === 'Indienen'
? { tag: 'Ingediend', data: s.data, referentie: m.referentie }
: s;
case 'SubmitFailed':
return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s;
case 'Upload':
@@ -3,8 +3,12 @@ import { Registration } from './registration';
import { isHerregistratieEligible, statusColor } from './registration.policy';
const reg = (status: Registration['status']): Registration => ({
bigNummer: '19012345601', naam: 'Test', beroep: 'Arts',
registratiedatum: '2012-09-01', geboortedatum: '1985-03-14', status,
bigNummer: '19012345601',
naam: 'Test',
beroep: 'Arts',
registratiedatum: '2012-09-01',
geboortedatum: '1985-03-14',
status,
});
describe('registration.policy', () => {
@@ -15,8 +19,18 @@ describe('registration.policy', () => {
});
it('struck-off / suspended registrations are never eligible', () => {
expect(isHerregistratieEligible(reg({ tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'x' }), new Date('2027-01-01'))).toBe(false);
expect(isHerregistratieEligible(reg({ tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'x' }), new Date('2027-01-01'))).toBe(false);
expect(
isHerregistratieEligible(
reg({ tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'x' }),
new Date('2027-01-01'),
),
).toBe(false);
expect(
isHerregistratieEligible(
reg({ tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'x' }),
new Date('2027-01-01'),
),
).toBe(false);
});
it('statusColor is total over the union', () => {
@@ -38,7 +38,11 @@ export function herregistratieDeadline(reg: Registration): Date | null {
SERVER-OWNED RULE: this now runs on the backend (BFF), which ships the result
as `decisions.eligibleForHerregistratie` in the dashboard view. Kept here as
the reference implementation + unit test; the frontend no longer calls it. */
export function isHerregistratieEligible(reg: Registration, today: Date, windowMonths = 12): boolean {
export function isHerregistratieEligible(
reg: Registration,
today: Date,
windowMonths = 12,
): boolean {
const deadline = herregistratieDeadline(reg);
if (!deadline) return false;
const windowStart = new Date(deadline);
+8 -2
View File
@@ -24,7 +24,10 @@ describe('tasksFromProfile', () => {
});
it('surfaces a notice for a suspended registration (independent of eligibility)', () => {
const reg: Registration = { ...base, status: { tag: 'Geschorst', geschorstTot: '2027-01-01', reden: 'Onderzoek' } };
const reg: Registration = {
...base,
status: { tag: 'Geschorst', geschorstTot: '2027-01-01', reden: 'Onderzoek' },
};
const tasks = tasksFromProfile(reg, false);
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toContain('geschorst');
@@ -32,7 +35,10 @@ describe('tasksFromProfile', () => {
});
it('surfaces a notice for a struck-off registration', () => {
const reg: Registration = { ...base, status: { tag: 'Doorgehaald', doorgehaaldOp: '2025-01-01', reden: 'Op eigen verzoek' } };
const reg: Registration = {
...base,
status: { tag: 'Doorgehaald', doorgehaaldOp: '2025-01-01', reden: 'Op eigen verzoek' },
};
const tasks = tasksFromProfile(reg, false);
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toContain('doorgehaald');
+4 -1
View File
@@ -22,7 +22,10 @@ function formatNL(d: Date): string {
* it does not recompute the rule (ADR-0001). The deadline is still formatted
* client-side for the task copy (presentation, not a rule).
*/
export function tasksFromProfile(reg: Registration, eligibleForHerregistratie: boolean): PortalTask[] {
export function tasksFromProfile(
reg: Registration,
eligibleForHerregistratie: boolean,
): PortalTask[] {
const tasks: PortalTask[] = [];
if (eligibleForHerregistratie) {
@@ -5,5 +5,7 @@ export type BigNummer = Brand<string, 'BigNummer'>;
export function parseBigNummer(raw: string): Result<string, BigNummer> {
const t = raw.trim();
return /^\d{11}$/.test(t) ? ok(t as BigNummer) : err($localize`:@@validation.bigNummer:Een BIG-nummer bestaat uit 11 cijfers.`);
return /^\d{11}$/.test(t)
? ok(t as BigNummer)
: err($localize`:@@validation.bigNummer:Een BIG-nummer bestaat uit 11 cijfers.`);
}
@@ -13,7 +13,9 @@ export function parseEmail(raw: string): Result<string, Email> {
// Deliberately lax: a single @ with non-empty, dot-bearing parts. Good enough
// for instant feedback; the server re-validates.
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) {
return err($localize`:@@validation.email:Voer een geldig e-mailadres in, bijv. naam@voorbeeld.nl.`);
return err(
$localize`:@@validation.email:Voer een geldig e-mailadres in, bijv. naam@voorbeeld.nl.`,
);
}
return ok(t as Email);
}
@@ -3,7 +3,11 @@ import { parseUren } from './uren';
describe('parseUren', () => {
it('accepts non-negative whole numbers, including 0', () => {
for (const [raw, n] of [['0', 0], [' 40 ', 40], ['1000', 1000]] as const) {
for (const [raw, n] of [
['0', 0],
[' 40 ', 40],
['1000', 1000],
] as const) {
const r = parseUren(raw);
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe(n);
@@ -1,14 +1,33 @@
import { describe, it, expect } from 'vitest';
import { parseAanvraagStatus, parseApplicationSummary, parseApplications, parseApplicationDetail } from './applications.adapter';
import {
parseAanvraagStatus,
parseApplicationSummary,
parseApplications,
parseApplicationDetail,
} from './applications.adapter';
const concept = { id: 'a1', type: 'registratie', status: { tag: 'Concept', stepIndex: 1, stepCount: 4 }, documentIds: [], createdAt: '2026-07-01T10:00:00Z', updatedAt: '2026-07-01T10:05:00Z' };
const concept = {
id: 'a1',
type: 'registratie',
status: { tag: 'Concept', stepIndex: 1, stepCount: 4 },
documentIds: [],
createdAt: '2026-07-01T10:00:00Z',
updatedAt: '2026-07-01T10:05:00Z',
};
describe('parseAanvraagStatus', () => {
it('parses each tag with its required fields', () => {
expect(parseAanvraagStatus({ tag: 'Concept', stepIndex: 2, stepCount: 4 })).toEqual({ ok: true, value: { tag: 'Concept', stepIndex: 2, stepCount: 4 } });
expect(parseAanvraagStatus({ tag: 'InBehandeling', referentie: 'BIG-1', manual: true }).ok).toBe(true);
expect(parseAanvraagStatus({ tag: 'Concept', stepIndex: 2, stepCount: 4 })).toEqual({
ok: true,
value: { tag: 'Concept', stepIndex: 2, stepCount: 4 },
});
expect(
parseAanvraagStatus({ tag: 'InBehandeling', referentie: 'BIG-1', manual: true }).ok,
).toBe(true);
expect(parseAanvraagStatus({ tag: 'Goedgekeurd', referentie: 'BIG-1' }).ok).toBe(true);
expect(parseAanvraagStatus({ tag: 'Afgewezen', referentie: 'BIG-1', reden: 'geen uren' }).ok).toBe(true);
expect(
parseAanvraagStatus({ tag: 'Afgewezen', referentie: 'BIG-1', reden: 'geen uren' }).ok,
).toBe(true);
});
it('rejects a missing status, unknown tag, and wrong-typed fields', () => {
@@ -9,7 +9,12 @@ import {
SubmitApplicationRequest,
SubmitApplicationResponse,
} from '@shared/infrastructure/api-client';
import { Aanvraag, AanvraagDetail, AanvraagStatus, AanvraagType } from '@registratie/domain/aanvraag';
import {
Aanvraag,
AanvraagDetail,
AanvraagStatus,
AanvraagType,
} from '@registratie/domain/aanvraag';
/**
* Infrastructure adapter for the backend-owned Aanvraag aggregate — the only place
@@ -54,20 +59,25 @@ export class ApplicationsAdapter {
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
/** Trust-boundary parse of the status union — the tag drives which fields must exist. */
export function parseAanvraagStatus(s: AanvraagStatusDto | undefined): Result<string, AanvraagStatus> {
export function parseAanvraagStatus(
s: AanvraagStatusDto | undefined,
): Result<string, AanvraagStatus> {
if (!s || typeof s.tag !== 'string') return err('aanvraag: missing status');
switch (s.tag) {
case 'Concept':
if (typeof s.stepIndex !== 'number' || typeof s.stepCount !== 'number') return err('aanvraag: bad Concept status');
if (typeof s.stepIndex !== 'number' || typeof s.stepCount !== 'number')
return err('aanvraag: bad Concept status');
return ok({ tag: 'Concept', stepIndex: s.stepIndex, stepCount: s.stepCount });
case 'InBehandeling':
if (typeof s.referentie !== 'string' || typeof s.manual !== 'boolean') return err('aanvraag: bad InBehandeling status');
if (typeof s.referentie !== 'string' || typeof s.manual !== 'boolean')
return err('aanvraag: bad InBehandeling status');
return ok({ tag: 'InBehandeling', referentie: s.referentie, manual: s.manual });
case 'Goedgekeurd':
if (typeof s.referentie !== 'string') return err('aanvraag: bad Goedgekeurd status');
return ok({ tag: 'Goedgekeurd', referentie: s.referentie });
case 'Afgewezen':
if (typeof s.referentie !== 'string' || typeof s.reden !== 'string') return err('aanvraag: bad Afgewezen status');
if (typeof s.referentie !== 'string' || typeof s.reden !== 'string')
return err('aanvraag: bad Afgewezen status');
return ok({ tag: 'Afgewezen', referentie: s.referentie, reden: s.reden });
default:
return err(`aanvraag: unknown status tag ${s.tag}`);
@@ -76,8 +86,10 @@ export function parseAanvraagStatus(s: AanvraagStatusDto | undefined): Result<st
function parseCommon(dto: ApplicationSummaryDto): Result<string, Aanvraag> {
if (typeof dto.id !== 'string') return err('aanvraag: missing id');
if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type)) return err(`aanvraag: bad type ${dto.type}`);
if (typeof dto.createdAt !== 'string' || typeof dto.updatedAt !== 'string') return err('aanvraag: missing timestamps');
if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type))
return err(`aanvraag: bad type ${dto.type}`);
if (typeof dto.createdAt !== 'string' || typeof dto.updatedAt !== 'string')
return err('aanvraag: missing timestamps');
const status = parseAanvraagStatus(dto.status);
if (!status.ok) return status;
return ok({
@@ -22,5 +22,9 @@ export class BigRegisterAdapter {
/** Map the wire DTO (all fields optional) onto our domain type. */
function toAantekening(n: AantekeningDto): Aantekening {
return { type: n.type as AantekeningType, omschrijving: n.omschrijving ?? '', datum: n.datum ?? '' };
return {
type: n.type as AantekeningType,
omschrijving: n.omschrijving ?? '',
datum: n.datum ?? '',
};
}
@@ -3,7 +3,10 @@ import { parseBrpAddress } from './brp.adapter';
describe('parseBrpAddress (trust boundary)', () => {
it('accepts a found address', () => {
const r = parseBrpAddress({ gevonden: true, adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' } });
const r = parseBrpAddress({
gevonden: true,
adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' },
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.value.adres?.postcode).toBe('2514 EA');
});
@@ -27,7 +27,12 @@ export function parseBrpAddress(json: unknown): Result<string, BrpAddressDto> {
if (typeof dto.gevonden !== 'boolean') return err('brp-address: missing/invalid gevonden');
if (dto.gevonden) {
const a = dto.adres;
if (!a || typeof a.straat !== 'string' || typeof a.postcode !== 'string' || typeof a.woonplaats !== 'string') {
if (
!a ||
typeof a.straat !== 'string' ||
typeof a.postcode !== 'string' ||
typeof a.woonplaats !== 'string'
) {
return err('brp-address: missing/invalid adres');
}
}
@@ -10,7 +10,11 @@ const valid = {
geboortedatum: '1985-03-14',
status: { tag: 'Geregistreerd', herregistratieDatum: '2027-03-01' },
},
person: { naam: 'Dr. A. de Vries', geboortedatum: '1985-03-14', adres: { straat: 'X 1', postcode: '2514 EA', woonplaats: 'Den Haag' } },
person: {
naam: 'Dr. A. de Vries',
geboortedatum: '1985-03-14',
adres: { straat: 'X 1', postcode: '2514 EA', woonplaats: 'Den Haag' },
},
decisions: { eligibleForHerregistratie: true, herregistratieReason: 'within window' },
};
@@ -27,6 +31,8 @@ describe('parseDashboardView (trust boundary)', () => {
it('rejects malformed responses instead of trusting them', () => {
expect(parseDashboardView(null).ok).toBe(false);
expect(parseDashboardView({ ...valid, registration: undefined }).ok).toBe(false);
expect(parseDashboardView({ ...valid, decisions: { eligibleForHerregistratie: 'yes' } }).ok).toBe(false);
expect(
parseDashboardView({ ...valid, decisions: { eligibleForHerregistratie: 'yes' } }).ok,
).toBe(false);
});
});
@@ -1,6 +1,9 @@
import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { DashboardViewDto, HerregistratieDecisions } from '@registratie/contracts/dashboard-view.dto';
import {
DashboardViewDto,
HerregistratieDecisions,
} from '@registratie/contracts/dashboard-view.dto';
import { Registration } from '@registratie/domain/registration';
import { Person } from '@registratie/domain/person';
import { BigProfile } from '@registratie/domain/big-profile';
@@ -49,7 +52,12 @@ export function parseDashboardView(json: unknown): Result<string, DashboardView>
const dto = json as Partial<DashboardViewDto>;
const reg = dto.registration;
if (!reg || typeof reg.bigNummer !== 'string' || !reg.status || typeof reg.status.tag !== 'string') {
if (
!reg ||
typeof reg.bigNummer !== 'string' ||
!reg.status ||
typeof reg.status.tag !== 'string'
) {
return err('dashboard-view: missing/invalid registration');
}
const person = dto.person;
@@ -72,10 +80,17 @@ export function parseDashboardView(json: unknown): Result<string, DashboardView>
geboortedatum: reg.geboortedatum,
status: reg.status,
};
const persoon: Person = { naam: person.naam, geboortedatum: person.geboortedatum, adres: person.adres };
const persoon: Person = {
naam: person.naam,
geboortedatum: person.geboortedatum,
adres: person.adres,
};
return ok({
profile: { registration, person: persoon },
decisions: { eligibleForHerregistratie: d.eligibleForHerregistratie, herregistratieReason: d.herregistratieReason },
decisions: {
eligibleForHerregistratie: d.eligibleForHerregistratie,
herregistratieReason: d.herregistratieReason,
},
});
}
@@ -3,8 +3,22 @@ import { parseDuoLookup } from './duo.adapter';
const valid = {
diplomas: [
{ id: 'd1', naam: 'Geneeskunde', instelling: 'Universiteit Leiden', jaar: 2011, beroep: 'Arts', policyQuestions: [] },
{ id: 'd2', naam: 'Medicine', instelling: 'University of Edinburgh', jaar: 2013, beroep: 'Arts', policyQuestions: [{ id: 'nl-taal', vraag: 'Toon taalvaardigheid', type: 'ja-nee' }] },
{
id: 'd1',
naam: 'Geneeskunde',
instelling: 'Universiteit Leiden',
jaar: 2011,
beroep: 'Arts',
policyQuestions: [],
},
{
id: 'd2',
naam: 'Medicine',
instelling: 'University of Edinburgh',
jaar: 2013,
beroep: 'Arts',
policyQuestions: [{ id: 'nl-taal', vraag: 'Toon taalvaardigheid', type: 'ja-nee' }],
},
],
handmatig: {
beroepen: ['Arts', 'Verpleegkundige'],
@@ -34,6 +48,14 @@ describe('parseDuoLookup (trust boundary)', () => {
expect(parseDuoLookup({}).ok).toBe(false); // no diplomas
expect(parseDuoLookup({ diplomas: [] }).ok).toBe(false); // no handmatig
expect(parseDuoLookup({ diplomas: [{ id: 'd1' }], handmatig: valid.handmatig }).ok).toBe(false); // bad diploma
expect(parseDuoLookup({ diplomas: [], handmatig: { beroepen: ['Arts'], policyQuestions: [{ id: 'x', vraag: 'y', type: 'bogus' }] } }).ok).toBe(false); // bad question type
expect(
parseDuoLookup({
diplomas: [],
handmatig: {
beroepen: ['Arts'],
policyQuestions: [{ id: 'x', vraag: 'y', type: 'bogus' }],
},
}).ok,
).toBe(false); // bad question type
});
});
@@ -1,6 +1,11 @@
import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { DuoLookupDto, DuoDiplomaDto, PolicyQuestionDto, ManualDiplomaPolicyDto } from '@registratie/contracts/duo-diplomas.dto';
import {
DuoLookupDto,
DuoDiplomaDto,
PolicyQuestionDto,
ManualDiplomaPolicyDto,
} from '@registratie/contracts/duo-diplomas.dto';
import { ApiClient } from '@shared/infrastructure/api-client';
/**
@@ -25,7 +30,12 @@ function parseQuestions(json: unknown): PolicyQuestionDto[] | null {
for (const q of json) {
if (typeof q !== 'object' || q === null) return null;
const p = q as Partial<PolicyQuestionDto>;
if (typeof p.id !== 'string' || typeof p.vraag !== 'string' || (p.type !== 'ja-nee' && p.type !== 'tekst')) return null;
if (
typeof p.id !== 'string' ||
typeof p.vraag !== 'string' ||
(p.type !== 'ja-nee' && p.type !== 'tekst')
)
return null;
out.push({ id: p.id, vraag: p.vraag, type: p.type });
}
return out;
@@ -43,10 +53,22 @@ export function parseDuoLookup(json: unknown): Result<string, DuoLookupDto> {
if (typeof item !== 'object' || item === null) return err('duo-lookup: invalid diploma');
const d = item as Partial<DuoDiplomaDto>;
const vragen = parseQuestions(d.policyQuestions);
if (typeof d.id !== 'string' || typeof d.naam !== 'string' || typeof d.beroep !== 'string' || vragen === null) {
if (
typeof d.id !== 'string' ||
typeof d.naam !== 'string' ||
typeof d.beroep !== 'string' ||
vragen === null
) {
return err('duo-lookup: missing/invalid diploma fields');
}
diplomas.push({ id: d.id, naam: d.naam, instelling: d.instelling ?? '', jaar: typeof d.jaar === 'number' ? d.jaar : 0, beroep: d.beroep, policyQuestions: vragen });
diplomas.push({
id: d.id,
naam: d.naam,
instelling: d.instelling ?? '',
jaar: typeof d.jaar === 'number' ? d.jaar : 0,
beroep: d.beroep,
policyQuestions: vragen,
});
}
const hm = dto.handmatig;
@@ -13,9 +13,16 @@ import { blockActions } from '@registratie/domain/block-actions';
@Component({
selector: 'app-aanvraag-block',
imports: [ButtonComponent, AlertComponent],
styles: [`
.actions{display:flex;align-items:center;gap:var(--rhc-space-max-md);margin-block-start:var(--rhc-space-max-sm)}
`],
styles: [
`
.actions {
display: flex;
align-items: center;
gap: var(--rhc-space-max-md);
margin-block-start: var(--rhc-space-max-sm);
}
`,
],
template: `
@if (aanvraag().status.tag === 'Concept') {
<app-alert type="warning">
@@ -23,10 +30,14 @@ import { blockActions } from '@registratie/domain/block-actions';
<p>{{ conceptText() }}</p>
<div class="actions">
@if (actions().includes('cancel')) {
<app-button variant="subtle" (click)="cancel.emit()" i18n="@@aanvraagBlock.verwijderen">Verwijderen</app-button>
<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>
<app-button (click)="resume.emit()" i18n="@@aanvraagBlock.openen"
>Aanvraag openen</app-button
>
}
</div>
</app-alert>
@@ -59,5 +70,7 @@ export class AanvraagBlockComponent {
}
function formatNL(iso?: string): string {
return iso ? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
return iso
? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })
: '';
}
@@ -38,7 +38,36 @@ 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' } } } };
export const Afgewezen: Story = { args: { aanvraag: { ...base, type: 'herregistratie', status: { tag: 'Afgewezen', referentie: 'BIG-2026-456789', reden: 'Aanvraag afgewezen: geen gewerkte uren geregistreerd.' } } } };
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' } } },
};
export const Afgewezen: Story = {
args: {
aanvraag: {
...base,
type: 'herregistratie',
status: {
tag: 'Afgewezen',
referentie: 'BIG-2026-456789',
reden: 'Aanvraag afgewezen: geen gewerkte uren geregistreerd.',
},
},
},
};
+20 -4
View File
@@ -15,14 +15,28 @@ import { detailRows } from '@registratie/domain/aanvraag-view';
handling is future work. The dashboard "Mijn aanvragen" rows link here. */
@Component({
selector: 'app-aanvraag-detail-page',
imports: [PageShellComponent, SkeletonComponent, AlertComponent, DataBlockComponent, DataRowComponent, ...ASYNC],
imports: [
PageShellComponent,
SkeletonComponent,
AlertComponent,
DataBlockComponent,
DataRowComponent,
...ASYNC,
],
template: `
<app-page-shell i18n-heading="@@aanvraagDetail.heading" heading="Aanvraag" backLink="/dashboard">
<app-page-shell
i18n-heading="@@aanvraagDetail.heading"
heading="Aanvraag"
backLink="/dashboard"
>
<app-async [data]="store.applications()">
<ng-template appAsyncLoaded let-list>
@let a = find($any(list));
@if (a) {
<app-data-block i18n-ariaLabel="@@aanvraagDetail.ariaLabel" ariaLabel="Aanvraaggegevens">
<app-data-block
i18n-ariaLabel="@@aanvraagDetail.ariaLabel"
ariaLabel="Aanvraaggegevens"
>
@for (row of rows(a); track row.key) {
<div app-data-row [key]="row.key" [value]="row.value"></div>
}
@@ -31,7 +45,9 @@ import { detailRows } from '@registratie/domain/aanvraag-view';
De volledige afhandeling van deze aanvraag is nog niet beschikbaar in deze POC.
</app-alert>
} @else {
<app-alert type="warning" i18n="@@aanvraagDetail.nietGevonden">Deze aanvraag is niet gevonden.</app-alert>
<app-alert type="warning" i18n="@@aanvraagDetail.nietGevonden"
>Deze aanvraag is niet gevonden.</app-alert
>
}
</ng-template>
<ng-template appAsyncLoading>
@@ -19,27 +19,73 @@ export type AdresErrors = Partial<Record<keyof AdresValue, string>>;
@Component({
selector: 'app-address-fields',
imports: [FormsModule, FormFieldComponent, TextInputComponent],
styles: [`
fieldset{border:0;margin:0;padding:0;min-inline-size:0}
legend{padding:0;font-weight:var(--rhc-text-font-weight-semi-bold);margin-block-end:var(--rhc-space-max-md)}
`],
styles: [
`
fieldset {
border: 0;
margin: 0;
padding: 0;
min-inline-size: 0;
}
legend {
padding: 0;
font-weight: var(--rhc-text-font-weight-semi-bold);
margin-block-end: var(--rhc-space-max-md);
}
`,
],
template: `
<fieldset>
<legend>{{ legend() }}</legend>
<app-form-field i18n-label="@@address.straat" label="Straat en huisnummer" [fieldId]="idPrefix() + '-straat'" required [error]="errors().straat">
<app-text-input [inputId]="idPrefix() + '-straat'" [invalid]="!!errors().straat"
[ngModel]="value().straat" (ngModelChange)="fieldChange.emit({ key: 'straat', value: $event })"
name="straat" [ngModelOptions]="{ standalone: true }" />
<app-form-field
i18n-label="@@address.straat"
label="Straat en huisnummer"
[fieldId]="idPrefix() + '-straat'"
required
[error]="errors().straat"
>
<app-text-input
[inputId]="idPrefix() + '-straat'"
[invalid]="!!errors().straat"
[ngModel]="value().straat"
(ngModelChange)="fieldChange.emit({ key: 'straat', value: $event })"
name="straat"
[ngModelOptions]="{ standalone: true }"
/>
</app-form-field>
<app-form-field i18n-label="@@address.postcode" label="Postcode" [fieldId]="idPrefix() + '-postcode'" required [error]="errors().postcode">
<app-text-input [inputId]="idPrefix() + '-postcode'" [invalid]="!!errors().postcode"
[ngModel]="value().postcode" (ngModelChange)="fieldChange.emit({ key: 'postcode', value: $event })"
name="postcode" i18n-placeholder="@@address.postcodePlaceholder" placeholder="1234 AB" [ngModelOptions]="{ standalone: true }" />
<app-form-field
i18n-label="@@address.postcode"
label="Postcode"
[fieldId]="idPrefix() + '-postcode'"
required
[error]="errors().postcode"
>
<app-text-input
[inputId]="idPrefix() + '-postcode'"
[invalid]="!!errors().postcode"
[ngModel]="value().postcode"
(ngModelChange)="fieldChange.emit({ key: 'postcode', value: $event })"
name="postcode"
i18n-placeholder="@@address.postcodePlaceholder"
placeholder="1234 AB"
[ngModelOptions]="{ standalone: true }"
/>
</app-form-field>
<app-form-field i18n-label="@@address.woonplaats" label="Woonplaats" [fieldId]="idPrefix() + '-woonplaats'" required [error]="errors().woonplaats">
<app-text-input [inputId]="idPrefix() + '-woonplaats'" [invalid]="!!errors().woonplaats"
[ngModel]="value().woonplaats" (ngModelChange)="fieldChange.emit({ key: 'woonplaats', value: $event })"
name="woonplaats" [ngModelOptions]="{ standalone: true }" />
<app-form-field
i18n-label="@@address.woonplaats"
label="Woonplaats"
[fieldId]="idPrefix() + '-woonplaats'"
required
[error]="errors().woonplaats"
>
<app-text-input
[inputId]="idPrefix() + '-woonplaats'"
[invalid]="!!errors().woonplaats"
[ngModel]="value().woonplaats"
(ngModelChange)="fieldChange.emit({ key: 'woonplaats', value: $event })"
name="woonplaats"
[ngModelOptions]="{ standalone: true }"
/>
</app-form-field>
</fieldset>
`,
@@ -20,12 +20,18 @@ const empty = { straat: '', postcode: '', woonplaats: '' };
export const Default: Story = { args: { value: empty, errors: {} } };
export const Prefilled: Story = {
args: { value: { straat: 'Stationsplein 1', postcode: '3511 ED', woonplaats: 'Utrecht' }, errors: {} },
args: {
value: { straat: 'Stationsplein 1', postcode: '3511 ED', woonplaats: 'Utrecht' },
errors: {},
},
};
export const WithErrors: Story = {
args: {
value: { straat: '', postcode: '12', woonplaats: '' },
errors: { straat: 'Vul een straat en huisnummer in.', postcode: 'Voer een geldige postcode in, bijv. 1234 AB.' },
errors: {
straat: 'Vul een straat en huisnummer in.',
postcode: 'Voer een geldige postcode in, bijv. 1234 AB.',
},
},
};
@@ -3,7 +3,11 @@ import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { AddressFieldsComponent, AdresValue, AdresErrors } from '@registratie/ui/address-fields/address-fields.component';
import {
AddressFieldsComponent,
AdresValue,
AdresErrors,
} from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine';
@@ -21,25 +25,37 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang
template: `
@if (state().tag === 'Submitted') {
<app-alert type="ok" i18n="@@changeRequest.success">
Uw adreswijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5 werkdagen bericht.
Uw adreswijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5
werkdagen bericht.
</app-alert>
<div class="app-section">
<app-button variant="secondary" (click)="dispatch({ tag: 'Reset' })" i18n="@@changeRequest.nieuwe">Nieuwe wijziging doorgeven</app-button>
<app-button
variant="secondary"
(click)="dispatch({ tag: 'Reset' })"
i18n="@@changeRequest.nieuwe"
>Nieuwe wijziging doorgeven</app-button
>
</div>
} @else {
<app-heading [level]="2" i18n="@@changeRequest.heading">Adreswijziging doorgeven</app-heading>
<form (ngSubmit)="onSubmit()" class="form-horizontal app-section">
<div class="form-header">
<div class="form-action"><span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span></div>
<div class="form-action">
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
</div>
</div>
<app-address-fields
idPrefix="cr"
[value]="adres()"
[errors]="errors()"
(fieldChange)="dispatch({ tag: 'SetField', key: $event.key, value: $event.value })" />
(fieldChange)="dispatch({ tag: 'SetField', key: $event.key, value: $event.value })"
/>
@if (failedError()) {
<app-alert type="error"><ng-container i18n="@@changeRequest.failed">Het indienen is niet gelukt:</ng-container> {{ failedError() }}</app-alert>
<app-alert type="error"
><ng-container i18n="@@changeRequest.failed">Het indienen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
}
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
@@ -5,7 +5,11 @@ import { ChangeRequestFormComponent } from './change-request-form.component';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { Postcode } from '@registratie/domain/value-objects/postcode';
const validData = { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' };
const validData = {
straat: 'Lange Voorhout 9',
postcode: '2514 EA' as Postcode,
woonplaats: 'Den Haag',
};
const meta: Meta<ChangeRequestFormComponent> = {
title: 'Organisms/Change Request Form',
@@ -17,16 +21,27 @@ export default meta;
type Story = StoryObj<ChangeRequestFormComponent>;
// One render per state of the machine.
export const Empty: Story = { args: { seed: { tag: 'Editing', draft: { straat: '', postcode: '', woonplaats: '' }, errors: {} } } };
export const Empty: Story = {
args: {
seed: { tag: 'Editing', draft: { straat: '', postcode: '', woonplaats: '' }, errors: {} },
},
};
export const WithErrors: Story = {
args: {
seed: {
tag: 'Editing',
draft: { straat: '', postcode: 'nope', woonplaats: '' },
errors: { straat: 'Vul straat en huisnummer in.', postcode: 'Voer een geldige postcode in, bijv. 1234 AB.' },
errors: {
straat: 'Vul straat en huisnummer in.',
postcode: 'Voer een geldige postcode in, bijv. 1234 AB.',
},
},
},
};
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData, referentie: 'BIG-2026-123456' } } };
export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } };
export const Submitted: Story = {
args: { seed: { tag: 'Submitted', data: validData, referentie: 'BIG-2026-123456' } },
};
export const Failed: Story = {
args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } },
};
+190 -83
View File
@@ -25,91 +25,163 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
@Component({
selector: 'app-dashboard-page',
imports: [
PageShellComponent, HeadingComponent, AlertComponent, SkeletonComponent,
DataRowComponent, DataBlockComponent, TaskListComponent, ApplicationListComponent, ApplicationLinkComponent,
PageShellComponent,
HeadingComponent,
AlertComponent,
SkeletonComponent,
DataRowComponent,
DataBlockComponent,
TaskListComponent,
ApplicationListComponent,
ApplicationLinkComponent,
...ASYNC,
RegistrationSummaryComponent, RegistrationTableComponent, AanvraagBlockComponent,
RegistrationSummaryComponent,
RegistrationTableComponent,
AanvraagBlockComponent,
],
template: `
<app-page-shell i18n-heading="@@dashboard.heading" heading="Mijn overzicht" i18n-intro="@@dashboard.intro" intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken.">
<div class="app-stack">
@if (aanvragen().length) {
<app-page-shell
i18n-heading="@@dashboard.heading"
heading="Mijn overzicht"
i18n-intro="@@dashboard.intro"
intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken."
>
<div class="app-stack">
@if (aanvragen().length) {
<section>
@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-heading [level]="2" class="app-section" i18n="@@dashboard.mijnAanvragen"
>Mijn aanvragen</app-heading
>
<app-application-list>
@for (a of ingediend(); track a.id) {
@let row = submittedRow(a);
<li
app-application-link
animate.enter="app-item-enter"
animate.leave="app-item-leave"
[heading]="row.heading"
[subtitle]="row.subtitle"
[status]="row.status"
[to]="'/aanvraag/' + a.id"
></li>
}
</app-application-list>
}
</section>
}
@if (store.pendingHerregistratie()) {
<app-alert type="info" i18n="@@dashboard.pendingHerregistratie"
>Uw herregistratie-aanvraag is in behandeling.</app-alert
>
}
<app-async [data]="store.profile()">
<ng-template appAsyncLoaded let-p>
@let tasks = tasksFor($any(p).registration);
<section>
@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-heading [level]="2" class="app-section" i18n="@@dashboard.mijnAanvragen">Mijn aanvragen</app-heading>
<app-application-list>
@for (a of ingediend(); track a.id) {
@let row = submittedRow(a);
<li app-application-link animate.enter="app-item-enter" animate.leave="app-item-leave" [heading]="row.heading" [subtitle]="row.subtitle" [status]="row.status" [to]="'/aanvraag/' + a.id"></li>
}
</app-application-list>
@if (tasks.length) {
<app-task-list
class="app-section"
i18n-listHeading="@@dashboard.watMoetIkRegelen"
listHeading="Wat moet ik regelen"
[tasks]="tasks"
/>
} @else {
<app-heading [level]="2" i18n="@@dashboard.watMoetIkRegelen"
>Wat moet ik regelen</app-heading
>
<p class="app-text-subtle" i18n="@@dashboard.nietsOpenstaan">
U heeft op dit moment niets openstaan.
</p>
}
</section>
}
@if (store.pendingHerregistratie()) {
<app-alert type="info" i18n="@@dashboard.pendingHerregistratie">Uw herregistratie-aanvraag is in behandeling.</app-alert>
}
<section>
<app-heading [level]="2" i18n="@@dashboard.mijnRegistratie"
>Mijn registratie</app-heading
>
<div class="app-section">
<app-registration-summary [reg]="$any(p).registration" />
</div>
<app-data-block
class="app-section"
i18n-heading="@@dashboard.persoonsgegevens"
heading="Persoonsgegevens (BRP)"
>
<div
app-data-row
i18n-key="@@dashboard.straat"
key="Straat"
[value]="$any(p).person.adres.straat"
></div>
<div
app-data-row
i18n-key="@@dashboard.postcode"
key="Postcode"
[value]="$any(p).person.adres.postcode"
></div>
<div
app-data-row
i18n-key="@@dashboard.woonplaats"
key="Woonplaats"
[value]="$any(p).person.adres.woonplaats"
></div>
</app-data-block>
</section>
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="6" />
</ng-template>
</app-async>
<app-async [data]="store.profile()">
<ng-template appAsyncLoaded let-p>
@let tasks = tasksFor($any(p).registration);
<section>
<app-heading [level]="2" i18n="@@dashboard.specialismen"
>Specialismen en aantekeningen</app-heading
>
<div class="app-section">
<app-async [data]="store.aantekeningen()">
<ng-template appAsyncLoaded let-r>
<app-registration-table [rows]="$any(r)" />
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
<ng-template appAsyncEmpty>
<p class="app-text-subtle" i18n="@@dashboard.geenSpecialismen">
U heeft nog geen specialismen of aantekeningen.
</p>
</ng-template>
</app-async>
</div>
</section>
<section>
@if (tasks.length) {
<app-task-list class="app-section" i18n-listHeading="@@dashboard.watMoetIkRegelen" listHeading="Wat moet ik regelen" [tasks]="tasks" />
} @else {
<app-heading [level]="2" i18n="@@dashboard.watMoetIkRegelen">Wat moet ik regelen</app-heading>
<p class="app-text-subtle" i18n="@@dashboard.nietsOpenstaan">U heeft op dit moment niets openstaan.</p>
}
</section>
<section>
<app-heading [level]="2" i18n="@@dashboard.mijnRegistratie">Mijn registratie</app-heading>
<div class="app-section">
<app-registration-summary [reg]="$any(p).registration" />
</div>
<app-data-block class="app-section" i18n-heading="@@dashboard.persoonsgegevens" heading="Persoonsgegevens (BRP)">
<div app-data-row i18n-key="@@dashboard.straat" key="Straat" [value]="$any(p).person.adres.straat"></div>
<div app-data-row i18n-key="@@dashboard.postcode" key="Postcode" [value]="$any(p).person.adres.postcode"></div>
<div app-data-row i18n-key="@@dashboard.woonplaats" key="Woonplaats" [value]="$any(p).person.adres.woonplaats"></div>
</app-data-block>
</section>
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="6" />
</ng-template>
</app-async>
<section>
<app-heading [level]="2" i18n="@@dashboard.specialismen">Specialismen en aantekeningen</app-heading>
<div class="app-section">
<app-async [data]="store.aantekeningen()">
<ng-template appAsyncLoaded let-r>
<app-registration-table [rows]="$any(r)" />
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
<ng-template appAsyncEmpty>
<p class="app-text-subtle" i18n="@@dashboard.geenSpecialismen">U heeft nog geen specialismen of aantekeningen.</p>
</ng-template>
</app-async>
</div>
</section>
<section>
<app-heading [level]="2" i18n="@@dashboard.watWiltUDoen">Wat wilt u doen?</app-heading>
<app-application-list class="app-section">
@for (a of acties; track a.to) {
<li app-application-link [heading]="a.titel" [subtitle]="a.tekst" [cta]="a.actie" [to]="a.to"></li>
}
</app-application-list>
</section>
</div>
<section>
<app-heading [level]="2" i18n="@@dashboard.watWiltUDoen">Wat wilt u doen?</app-heading>
<app-application-list class="app-section">
@for (a of acties; track a.to) {
<li
app-application-link
[heading]="a.titel"
[subtitle]="a.tekst"
[cta]="a.actie"
[to]="a.to"
></li>
}
</app-application-list>
</section>
</div>
</app-page-shell>
`,
})
@@ -132,7 +204,12 @@ export class DashboardPage {
protected aanvragen = computed<Aanvraag[]>(() => {
const rd = this.apps.applications();
if (rd.tag !== 'Success') return [];
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]);
});
/** A Concept ("lopende aanvraag") renders as a melding above the list; the rest
@@ -166,11 +243,41 @@ export class DashboardPage {
componenten/aanvragen). The core portal sections live in the header nav now;
the teaching pages (concepts/brief) are only reachable from here. */
protected readonly acties = [
{ to: '/registreren', titel: $localize`:@@dashboard.actie.inschrijven.titel:Inschrijven`, tekst: $localize`:@@dashboard.actie.inschrijven.tekst:Schrijf u in in het BIG-register via de registratiewizard.`, actie: $localize`:@@dashboard.actie.inschrijven.actie:Start inschrijving` },
{ to: '/herregistratie', titel: $localize`:@@dashboard.actie.herregistratie.titel:Herregistratie aanvragen`, tekst: $localize`:@@dashboard.actie.herregistratie.tekst:Verleng uw registratie voor de komende periode.`, actie: $localize`:@@dashboard.actie.herregistratie.actie:Vraag aan` },
{ to: '/intake', titel: $localize`:@@dashboard.actie.intake.titel:Herregistratie-intake`, tekst: $localize`:@@dashboard.actie.intake.tekst:Vragenlijst met vertakkingen.`, actie: $localize`:@@dashboard.actie.intake.actie:Start intake` },
{ to: '/registratie', titel: $localize`:@@dashboard.actie.wijzigen.titel:Gegevens wijzigen`, tekst: $localize`:@@dashboard.actie.wijzigen.tekst:Bekijk uw gegevens of geef een wijziging door.`, actie: $localize`:@@dashboard.actie.wijzigen.actie:Bekijk gegevens` },
{ to: '/concepts', titel: $localize`:@@dashboard.actie.concepten.titel:Functionele patronen`, tekst: $localize`:@@dashboard.actie.concepten.tekst:Bekijk de FP/TEA-bouwstenen van deze POC.`, actie: $localize`:@@dashboard.actie.concepten.actie:Bekijk patronen` },
{ to: '/brief', titel: $localize`:@@dashboard.actie.brief.titel:Brief opstellen`, tekst: $localize`:@@dashboard.actie.brief.tekst:Stel een brief samen uit vaste en vrije onderdelen.`, actie: $localize`:@@dashboard.actie.brief.actie:Start brief` },
{
to: '/registreren',
titel: $localize`:@@dashboard.actie.inschrijven.titel:Inschrijven`,
tekst: $localize`:@@dashboard.actie.inschrijven.tekst:Schrijf u in in het BIG-register via de registratiewizard.`,
actie: $localize`:@@dashboard.actie.inschrijven.actie:Start inschrijving`,
},
{
to: '/herregistratie',
titel: $localize`:@@dashboard.actie.herregistratie.titel:Herregistratie aanvragen`,
tekst: $localize`:@@dashboard.actie.herregistratie.tekst:Verleng uw registratie voor de komende periode.`,
actie: $localize`:@@dashboard.actie.herregistratie.actie:Vraag aan`,
},
{
to: '/intake',
titel: $localize`:@@dashboard.actie.intake.titel:Herregistratie-intake`,
tekst: $localize`:@@dashboard.actie.intake.tekst:Vragenlijst met vertakkingen.`,
actie: $localize`:@@dashboard.actie.intake.actie:Start intake`,
},
{
to: '/registratie',
titel: $localize`:@@dashboard.actie.wijzigen.titel:Gegevens wijzigen`,
tekst: $localize`:@@dashboard.actie.wijzigen.tekst:Bekijk uw gegevens of geef een wijziging door.`,
actie: $localize`:@@dashboard.actie.wijzigen.actie:Bekijk gegevens`,
},
{
to: '/concepts',
titel: $localize`:@@dashboard.actie.concepten.titel:Functionele patronen`,
tekst: $localize`:@@dashboard.actie.concepten.tekst:Bekijk de FP/TEA-bouwstenen van deze POC.`,
actie: $localize`:@@dashboard.actie.concepten.actie:Bekijk patronen`,
},
{
to: '/brief',
titel: $localize`:@@dashboard.actie.brief.titel:Brief opstellen`,
tekst: $localize`:@@dashboard.actie.brief.tekst:Stel een brief samen uit vaste en vrije onderdelen.`,
actie: $localize`:@@dashboard.actie.brief.actie:Start brief`,
},
];
}
@@ -9,7 +9,12 @@ import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import { WizardShellComponent, WizardError, WizardStatus, naarStapLabel } from '@shared/layout/wizard-shell/wizard-shell.component';
import {
WizardShellComponent,
WizardError,
WizardStatus,
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store';
@@ -54,138 +59,291 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
@Component({
selector: 'app-registratie-wizard',
imports: [
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,
AlertComponent, SkeletonComponent, DataRowComponent, ReviewSectionComponent, ConfirmationComponent, WizardShellComponent,
AddressFieldsComponent, DocumentUploadComponent, ...ASYNC,
FormsModule,
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
ButtonComponent,
AlertComponent,
SkeletonComponent,
DataRowComponent,
ReviewSectionComponent,
ConfirmationComponent,
WizardShellComponent,
AddressFieldsComponent,
DocumentUploadComponent,
...ASYNC,
],
template: `
<app-wizard-shell
[steps]="stepLabels"
[current]="cursor()"
[stepTitle]="stepTitle()"
i18n-processName="@@regWizard.processName" processName="Inschrijven in het BIG-register"
i18n-processName="@@regWizard.processName"
processName="Inschrijven in het BIG-register"
[status]="shellStatus()"
[primaryLabel]="primaryLabel()"
[canGoBack]="cursor() > 0"
[errors]="errorList()"
[errorMessage]="errorMessage()"
i18n-submittingLabel="@@regWizard.submitting" submittingLabel="Uw registratie wordt verwerkt…"
i18n-submittingLabel="@@regWizard.submitting"
submittingLabel="Uw registratie wordt verwerkt…"
(primary)="onPrimary()"
(back)="dispatch({ tag: 'Back' })"
(cancel)="restart()"
(retry)="onRetry()"
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })">
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
>
@switch (step()) {
@case ('adres') {
@if (adresStatus() === 'laden') {
<app-skeleton height="2.5rem" [count]="4" />
} @else {
@switch (adresStatus()) {
@case ('gevonden') {
<app-alert type="info" i18n="@@regWizard.brpGevonden">Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert>
}
@case ('geen') {
<app-alert type="warning" i18n="@@regWizard.brpGeen">We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert>
}
@case ('fout') {
<app-alert type="warning" i18n="@@regWizard.brpFout">We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in.</app-alert>
}
}
<app-address-fields
[value]="{ straat: draft().straat ?? '', postcode: draft().postcode ?? '', woonplaats: draft().woonplaats ?? '' }"
[errors]="{ straat: err('straat'), postcode: err('postcode'), woonplaats: err('woonplaats') }"
(fieldChange)="set($event.key, $event.value)" />
<app-form-field i18n-label="@@regWizard.correspondentieLabel" label="Hoe wilt u correspondentie ontvangen?" fieldId="correspondentie" required [error]="err('correspondentie')">
<app-radio-group name="correspondentie" [options]="kanalen" [invalid]="!!err('correspondentie')"
[ngModel]="draft().correspondentie ?? ''" (ngModelChange)="setKanaal($event)" />
@if (adresStatus() === 'laden') {
<app-skeleton height="2.5rem" [count]="4" />
} @else {
@switch (adresStatus()) {
@case ('gevonden') {
<app-alert type="info" i18n="@@regWizard.brpGevonden"
>Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert
>
}
@case ('geen') {
<app-alert type="warning" i18n="@@regWizard.brpGeen"
>We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert
>
}
@case ('fout') {
<app-alert type="warning" i18n="@@regWizard.brpFout"
>We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig
in.</app-alert
>
}
}
<app-address-fields
[value]="{
straat: draft().straat ?? '',
postcode: draft().postcode ?? '',
woonplaats: draft().woonplaats ?? '',
}"
[errors]="{
straat: err('straat'),
postcode: err('postcode'),
woonplaats: err('woonplaats'),
}"
(fieldChange)="set($event.key, $event.value)"
/>
<app-form-field
i18n-label="@@regWizard.correspondentieLabel"
label="Hoe wilt u correspondentie ontvangen?"
fieldId="correspondentie"
required
[error]="err('correspondentie')"
>
<app-radio-group
name="correspondentie"
[options]="kanalen"
[invalid]="!!err('correspondentie')"
[ngModel]="draft().correspondentie ?? ''"
(ngModelChange)="setKanaal($event)"
/>
</app-form-field>
@if (draft().correspondentie === 'email') {
<app-form-field
i18n-label="@@regWizard.emailLabel"
label="E-mailadres"
fieldId="email"
required
[error]="err('email')"
>
<app-text-input
inputId="email"
type="email"
[invalid]="!!err('email')"
[ngModel]="draft().email ?? ''"
(ngModelChange)="set('email', $event)"
name="email"
i18n-placeholder="@@regWizard.emailPlaceholder"
placeholder="naam@voorbeeld.nl"
/>
</app-form-field>
}
}
}
@case ('beroep') {
<app-async [data]="lookupRd()">
<ng-template appAsyncLoaded let-data>
<app-form-field
i18n-label="@@regWizard.diplomaLabel"
label="Kies het diploma waarmee u zich wilt registreren"
fieldId="diploma"
required
[error]="err('diploma')"
>
<app-radio-group
name="diploma"
[options]="diplomaOptions($any(data))"
[invalid]="!!err('diploma')"
[ngModel]="diplomaKeuze()"
(ngModelChange)="onDiplomaKeuze($any(data), $event)"
/>
</app-form-field>
@if (handmatigActief()) {
<app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing"
>Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw
beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig
beoordeeld.</app-alert
>
<app-form-field
i18n-label="@@regWizard.beroepLabel"
label="Voor welk beroep wilt u zich registreren?"
fieldId="hm-beroep"
[error]="err('diploma')"
>
<app-radio-group
name="hm-beroep"
[options]="beroepOptions($any(data))"
[invalid]="!!err('diploma')"
[ngModel]="draft().beroep ?? ''"
(ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })"
/>
</app-form-field>
@if (draft().correspondentie === 'email') {
<app-form-field i18n-label="@@regWizard.emailLabel" label="E-mailadres" fieldId="email" required [error]="err('email')">
<app-text-input inputId="email" type="email" [invalid]="!!err('email')" [ngModel]="draft().email ?? ''" (ngModelChange)="set('email', $event)" name="email" i18n-placeholder="@@regWizard.emailPlaceholder" placeholder="naam@voorbeeld.nl" />
</app-form-field>
}
} @else if (draft().beroep) {
<dl class="mb-0 app-section">
<div
app-data-row
i18n-key="@@regWizard.beroepAfgeleid"
key="Beroep (afgeleid uit diploma)"
[value]="draft().beroep ?? ''"
></div>
</dl>
}
}
@case ('beroep') {
<app-async [data]="lookupRd()">
<ng-template appAsyncLoaded let-data>
<app-form-field i18n-label="@@regWizard.diplomaLabel" label="Kies het diploma waarmee u zich wilt registreren" fieldId="diploma" required [error]="err('diploma')">
<app-radio-group name="diploma" [options]="diplomaOptions($any(data))" [invalid]="!!err('diploma')"
[ngModel]="diplomaKeuze()" (ngModelChange)="onDiplomaKeuze($any(data), $event)" />
</app-form-field>
@if (handmatigActief()) {
<app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing">Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig beoordeeld.</app-alert>
<app-form-field i18n-label="@@regWizard.beroepLabel" label="Voor welk beroep wilt u zich registreren?" fieldId="hm-beroep" [error]="err('diploma')">
<app-radio-group name="hm-beroep" [options]="beroepOptions($any(data))" [invalid]="!!err('diploma')"
[ngModel]="draft().beroep ?? ''" (ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })" />
</app-form-field>
} @else if (draft().beroep) {
<dl class="mb-0 app-section">
<div app-data-row i18n-key="@@regWizard.beroepAfgeleid" key="Beroep (afgeleid uit diploma)" [value]="draft().beroep ?? ''"></div>
</dl>
@for (q of actieveVragen($any(data)); track q.id) {
<app-form-field
[label]="q.vraag"
[fieldId]="'vraag-' + q.id"
[error]="vraagErr(q.id)"
>
@if (q.type === 'ja-nee') {
<app-radio-group
[name]="'vraag-' + q.id"
[options]="jaNee"
[invalid]="!!vraagErr(q.id)"
[ngModel]="antwoord(q.id)"
(ngModelChange)="
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
"
[ngModelOptions]="{ standalone: true }"
/>
} @else {
<app-text-input
[inputId]="'vraag-' + q.id"
[invalid]="!!vraagErr(q.id)"
[ngModel]="antwoord(q.id)"
(ngModelChange)="
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
"
[ngModelOptions]="{ standalone: true }"
/>
}
@for (q of actieveVragen($any(data)); track q.id) {
<app-form-field [label]="q.vraag" [fieldId]="'vraag-' + q.id" [error]="vraagErr(q.id)">
@if (q.type === 'ja-nee') {
<app-radio-group [name]="'vraag-' + q.id" [options]="jaNee" [invalid]="!!vraagErr(q.id)"
[ngModel]="antwoord(q.id)" (ngModelChange)="dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })" [ngModelOptions]="{ standalone: true }" />
} @else {
<app-text-input [inputId]="'vraag-' + q.id" [invalid]="!!vraagErr(q.id)" [ngModel]="antwoord(q.id)"
(ngModelChange)="dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })" [ngModelOptions]="{ standalone: true }" />
}
</app-form-field>
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
</app-async>
<app-document-upload
class="app-section"
[state]="upload()"
[previewUrlFor]="previewUrlFor"
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
(removeUpload)="uploadCtl.onRemove($event)"
(retryUpload)="uploadCtl.onRetry($event)"
(deleteUpload)="uploadCtl.onDelete($event)"
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)" />
@if (err('documenten')) {
<app-alert type="warning">{{ err('documenten') }}</app-alert>
</app-form-field>
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
</app-async>
<app-document-upload
class="app-section"
[state]="upload()"
[previewUrlFor]="previewUrlFor"
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
(removeUpload)="uploadCtl.onRemove($event)"
(retryUpload)="uploadCtl.onRetry($event)"
(deleteUpload)="uploadCtl.onDelete($event)"
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)"
/>
@if (err('documenten')) {
<app-alert type="warning">{{ err('documenten') }}</app-alert>
}
}
@case ('controle') {
<app-alert type="info" i18n="@@regWizard.controleer"
>Controleer uw gegevens en dien de registratie in.</app-alert
>
<app-review-section
i18n-heading="@@regWizard.sectie.adres"
heading="Adres en correspondentie"
i18n-editAriaLabel="@@regWizard.adresWijzigenAria"
editAriaLabel="Wijzigen adresgegevens"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
>
<div
app-data-row
i18n-key="@@regWizard.summary.adres"
key="Adres"
[value]="adresSamenvatting()"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.herkomstAdres"
key="Herkomst adres"
[value]="adresHerkomstLabel()"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.correspondentie"
key="Correspondentie"
[value]="correspondentieLabel()"
></div>
@if (draft().correspondentie === 'email') {
<div
app-data-row
i18n-key="@@regWizard.summary.email"
key="E-mailadres"
[value]="draft().email ?? ''"
></div>
}
@case ('controle') {
<app-alert type="info" i18n="@@regWizard.controleer">Controleer uw gegevens en dien de registratie in.</app-alert>
<app-review-section i18n-heading="@@regWizard.sectie.adres" heading="Adres en correspondentie"
i18n-editAriaLabel="@@regWizard.adresWijzigenAria" editAriaLabel="Wijzigen adresgegevens"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })">
<div app-data-row i18n-key="@@regWizard.summary.adres" key="Adres" [value]="adresSamenvatting()"></div>
<div app-data-row i18n-key="@@regWizard.summary.herkomstAdres" key="Herkomst adres" [value]="adresHerkomstLabel()"></div>
<div app-data-row i18n-key="@@regWizard.summary.correspondentie" key="Correspondentie" [value]="correspondentieLabel()"></div>
@if (draft().correspondentie === 'email') {
<div app-data-row i18n-key="@@regWizard.summary.email" key="E-mailadres" [value]="draft().email ?? ''"></div>
}
</app-review-section>
<app-review-section class="app-section" i18n-heading="@@regWizard.sectie.beroep" heading="Beroep en diploma"
i18n-editAriaLabel="@@regWizard.diplomaWijzigenAria" editAriaLabel="Wijzigen beroep en diploma"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })">
<div app-data-row i18n-key="@@regWizard.summary.beroep" key="Beroep" [value]="draft().beroep ?? ''"></div>
<div app-data-row i18n-key="@@regWizard.summary.herkomstDiploma" key="Herkomst diploma" [value]="diplomaHerkomstLabel()"></div>
@for (item of samenvattingVragen(); track item.vraag) {
<div app-data-row [key]="item.vraag" [value]="item.antwoord"></div>
}
</app-review-section>
</app-review-section>
<app-review-section
class="app-section"
i18n-heading="@@regWizard.sectie.beroep"
heading="Beroep en diploma"
i18n-editAriaLabel="@@regWizard.diplomaWijzigenAria"
editAriaLabel="Wijzigen beroep en diploma"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
>
<div
app-data-row
i18n-key="@@regWizard.summary.beroep"
key="Beroep"
[value]="draft().beroep ?? ''"
></div>
<div
app-data-row
i18n-key="@@regWizard.summary.herkomstDiploma"
key="Herkomst diploma"
[value]="diplomaHerkomstLabel()"
></div>
@for (item of samenvattingVragen(); track item.vraag) {
<div app-data-row [key]="item.vraag" [value]="item.antwoord"></div>
}
</app-review-section>
}
}
<div wizardSuccess>
<app-confirmation i18n-title="@@regWizard.success.title" title="Uw registratie is ontvangen">
<p class="app-section" i18n="@@regWizard.success.referentie">Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.</p>
<app-confirmation
i18n-title="@@regWizard.success.title"
title="Uw registratie is ontvangen"
>
<p class="app-section" i18n="@@regWizard.success.referentie">
Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.
</p>
<div class="app-section">
<app-button variant="secondary" (click)="restart()" i18n="@@regWizard.nieuweRegistratie">Nieuwe registratie starten</app-button>
<app-button variant="secondary" (click)="restart()" i18n="@@regWizard.nieuweRegistratie"
>Nieuwe registratie starten</app-button
>
</div>
</app-confirmation>
</div>
@@ -206,8 +364,16 @@ export class RegistratieWizardComponent {
seed = input<RegistratieState>(initial);
readonly kanalen = KANALEN;
readonly stepLabels = [$localize`:@@regWizard.step.adres:Adres`, $localize`:@@regWizard.step.beroep:Beroep`, $localize`:@@regWizard.step.controle:Controle`]; // short labels for the stepper
private stepTitles = [$localize`:@@regWizard.title.adres:Adres en correspondentievoorkeur`, $localize`:@@regWizard.title.beroep:Beroep op basis van uw diploma`, $localize`:@@regWizard.title.controle:Controleren en indienen`];
readonly stepLabels = [
$localize`:@@regWizard.step.adres:Adres`,
$localize`:@@regWizard.step.beroep:Beroep`,
$localize`:@@regWizard.step.controle:Controle`,
]; // short labels for the stepper
private stepTitles = [
$localize`:@@regWizard.title.adres:Adres en correspondentievoorkeur`,
$localize`:@@regWizard.title.beroep:Beroep op basis van uw diploma`,
$localize`:@@regWizard.title.controle:Controleren en indienen`,
];
readonly state = this.store.model;
readonly dispatch = this.store.dispatch;
@@ -234,14 +400,18 @@ export class RegistratieWizardComponent {
snapshot: () => {
const s = this.state();
if (s.tag !== 'Invullen' || !hasProgress(s)) return null;
const documentIds = deliveryRefs(s.upload).filter((r) => r.channel === 'digital' && r.documentId).map((r) => r.documentId!);
const documentIds = deliveryRefs(s.upload)
.filter((r) => r.channel === 'digital' && r.documentId)
.map((r) => r.documentId!);
return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds };
},
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as RegistratieState }),
enabled: () => this.seed() === initial,
});
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]);
protected stepTitle = computed(
() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)],
);
protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
protected failedError = computed(() => whenTag(this.state(), 'Mislukt')?.error ?? '');
@@ -251,13 +421,21 @@ export class RegistratieWizardComponent {
const next = this.cursor() + 1;
return naarStapLabel(next + 1, this.stepLabels[next]);
});
protected errorMessage = computed(() => $localize`:@@regWizard.indienenMislukt:Het indienen is niet gelukt:` + ` ${this.failedError()}`);
protected errorMessage = computed(
() =>
$localize`:@@regWizard.indienenMislukt:Het indienen is niet gelukt:` +
` ${this.failedError()}`,
);
protected shellStatus = computed<WizardStatus>(() => {
switch (this.state().tag) {
case 'Invullen': return 'editing';
case 'Indienen': return 'submitting';
case 'Ingediend': return 'submitted';
case 'Mislukt': return 'failed';
case 'Invullen':
return 'editing';
case 'Indienen':
return 'submitting';
case 'Ingediend':
return 'submitted';
case 'Mislukt':
return 'failed';
}
});
/** Current step's errors (incl. per-question), flattened for the error summary. */
@@ -274,12 +452,32 @@ export class RegistratieWizardComponent {
});
protected adresSamenvatting = computed(() => {
const d = this.draft();
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')].filter(Boolean).join(', ');
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')]
.filter(Boolean)
.join(', ');
});
// Readable labels for the controle summary (instead of raw enum values).
protected adresHerkomstLabel = computed(() => ({ brp: $localize`:@@regWizard.herkomst.adresBrp:Automatisch uit de BRP`, handmatig: $localize`:@@regWizard.herkomst.adresHandmatig:Handmatig ingevoerd` }[this.draft().adresHerkomst ?? 'handmatig']));
protected correspondentieLabel = computed(() => ({ email: $localize`:@@regWizard.corr.email:Per e-mail`, post: $localize`:@@regWizard.corr.post:Per post` }[this.draft().correspondentie ?? 'post']));
protected diplomaHerkomstLabel = computed(() => ({ duo: $localize`:@@regWizard.herkomst.diplomaDuo:Geverifieerd via DUO`, handmatig: $localize`:@@regWizard.herkomst.diplomaHandmatig:Handmatig ingevoerd (wordt beoordeeld)` }[this.draft().diplomaHerkomst ?? 'handmatig']));
protected adresHerkomstLabel = computed(
() =>
({
brp: $localize`:@@regWizard.herkomst.adresBrp:Automatisch uit de BRP`,
handmatig: $localize`:@@regWizard.herkomst.adresHandmatig:Handmatig ingevoerd`,
})[this.draft().adresHerkomst ?? 'handmatig'],
);
protected correspondentieLabel = computed(
() =>
({
email: $localize`:@@regWizard.corr.email:Per e-mail`,
post: $localize`:@@regWizard.corr.post:Per post`,
})[this.draft().correspondentie ?? 'post'],
);
protected diplomaHerkomstLabel = computed(
() =>
({
duo: $localize`:@@regWizard.herkomst.diplomaDuo:Geverifieerd via DUO`,
handmatig: $localize`:@@regWizard.herkomst.diplomaHandmatig:Handmatig ingevoerd (wordt beoordeeld)`,
})[this.draft().diplomaHerkomst ?? 'handmatig'],
);
/** BRP lookup outcome (laden/gevonden/geen/fout) and the parsed DUO lookup, both
served by the application facade — the wizard renders, it does not fetch/parse. */
@@ -295,23 +493,35 @@ export class RegistratieWizardComponent {
readonly jaNee = JA_NEE;
protected err = (k: DraftField | 'correspondentie' | 'diploma' | 'documenten') => this.invullen()?.errors[k] ?? '';
protected err = (k: DraftField | 'correspondentie' | 'diploma' | 'documenten') =>
this.invullen()?.errors[k] ?? '';
protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? '';
protected antwoord = (id: string) => this.draft().antwoorden[id] ?? ''; // runtime guard: missing key → undefined
protected set = (key: DraftField, value: string) => this.dispatch({ tag: 'SetField', key, value });
protected setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
protected set = (key: DraftField, value: string) =>
this.dispatch({ tag: 'SetField', key, value });
protected setKanaal = (value: string) =>
this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
/** True while the user is entering a diploma manually (not in the DUO list). */
protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig');
/** The radio selection: a diploma id, or the"not listed" sentinel in manual mode. */
protected diplomaKeuze = computed(() => (this.handmatigActief() ? HANDMATIG : this.draft().diplomaId ?? ''));
protected diplomaKeuze = computed(() =>
this.handmatigActief() ? HANDMATIG : (this.draft().diplomaId ?? ''),
);
protected diplomaOptions = (data: DuoLookupDto) => [
...data.diplomas.map((d) => ({ value: d.id, label: `${d.naam} — ${d.instelling} (${d.jaar})` })),
{ value: HANDMATIG, label: $localize`:@@regWizard.diplomaNietBij:Mijn diploma staat er niet bij` },
...data.diplomas.map((d) => ({
value: d.id,
label: `${d.naam} — ${d.instelling} (${d.jaar})`,
})),
{
value: HANDMATIG,
label: $localize`:@@regWizard.diplomaNietBij:Mijn diploma staat er niet bij`,
},
];
protected beroepOptions = (data: DuoLookupDto) => data.handmatig.beroepen.map((b) => ({ value: b, label: b }));
protected beroepOptions = (data: DuoLookupDto) =>
data.handmatig.beroepen.map((b) => ({ value: b, label: b }));
/** The policy questions that apply to the current choice (server-decided). */
protected actieveVragen = (data: DuoLookupDto): PolicyQuestionDto[] => {
@@ -324,24 +534,41 @@ export class RegistratieWizardComponent {
const data = this.duoData();
const d = this.draft();
if (!data) return [] as { vraag: string; antwoord: string }[];
const alle = [...data.diplomas.flatMap((x) => x.policyQuestions), ...data.handmatig.policyQuestions];
return (d.vraagIds ?? []).map((id) => ({ vraag: alle.find((q) => q.id === id)?.vraag ?? id, antwoord: d.antwoorden[id] ?? '' }));
const alle = [
...data.diplomas.flatMap((x) => x.policyQuestions),
...data.handmatig.policyQuestions,
];
return (d.vraagIds ?? []).map((id) => ({
vraag: alle.find((q) => q.id === id)?.vraag ?? id,
antwoord: d.antwoorden[id] ?? '',
}));
});
protected onDiplomaKeuze(data: DuoLookupDto, id: string) {
if (id === HANDMATIG) {
this.dispatch({ tag: 'KiesHandmatig', vraagIds: data.handmatig.policyQuestions.map((q) => q.id) });
this.dispatch({
tag: 'KiesHandmatig',
vraagIds: data.handmatig.policyQuestions.map((q) => q.id),
});
return;
}
const d = data.diplomas.find((x) => x.id === id);
if (d) this.dispatch({ tag: 'KiesDiploma', diplomaId: d.id, beroep: d.beroep, vraagIds: d.policyQuestions.map((q) => q.id) });
if (d)
this.dispatch({
tag: 'KiesDiploma',
diplomaId: d.id,
beroep: d.beroep,
vraagIds: d.policyQuestions.map((q) => q.id),
});
}
constructor() {
// An explicit seed (stories/tests) wins; otherwise resume from the backend draft
// (`?aanvraag=<id>`), or start fresh. Persistence is the draftSync controller's job.
const seeded = this.seed();
queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume()));
queueMicrotask(() =>
seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume(),
);
// Prefill the address from the BRP lookup as it arrives. Track only the facade's
// parsed prefill signal; untrack the dispatch (it reads the state signal, which
// would otherwise make this effect loop on its own write). Don't clobber
@@ -352,7 +579,12 @@ export class RegistratieWizardComponent {
untracked(() => {
const s = this.state();
if (s.tag !== 'Invullen' || s.draft.straat) return;
this.dispatch({ tag: 'PrefillAdres', straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats });
this.dispatch({
tag: 'PrefillAdres',
straat: a.straat,
postcode: a.postcode,
woonplaats: a.woonplaats,
});
});
});
// A11y: focus management (step heading on step change, error summary on a
@@ -384,7 +616,10 @@ export class RegistratieWizardComponent {
private async runIfIndienen() {
const s = this.state();
if (s.tag !== 'Indienen') return;
const r = await this.draftSync.submit({ diplomaHerkomst: s.data.diplomaHerkomst, documents: s.data.documents });
const r = await this.draftSync.submit({
diplomaHerkomst: s.data.diplomaHerkomst,
documents: s.data.documents,
});
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value.referentie ?? '' });
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
}
@@ -3,14 +3,36 @@ import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { RegistratieWizardComponent } from './registratie-wizard.component';
import { Draft, RegistratieState, ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
import {
Draft,
RegistratieState,
ValidRegistratie,
} from '@registratie/domain/registratie-wizard.machine';
import { initialUpload } from '@shared/upload/upload.machine';
import { Postcode } from '@registratie/domain/value-objects/postcode';
const adres: Partial<Draft> = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', adresHerkomst: 'brp', correspondentie: 'post' };
const filled: Partial<Draft> = { ...adres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo', vraagIds: [] };
const adres: Partial<Draft> = {
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
adresHerkomst: 'brp',
correspondentie: 'post',
};
const filled: Partial<Draft> = {
...adres,
diplomaId: 'd1',
beroep: 'Arts',
diplomaHerkomst: 'duo',
vraagIds: [],
};
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({ tag: 'Invullen', draft: { antwoorden: {}, ...draft }, cursor, errors: {}, upload: initialUpload });
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({
tag: 'Invullen',
draft: { antwoorden: {}, ...draft },
cursor,
errors: {},
upload: initialUpload,
});
const validData: ValidRegistratie = {
adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' },
@@ -34,10 +56,39 @@ type Story = StoryObj<RegistratieWizardComponent>;
export const Adres: Story = { args: { seed: invullen(adres, 0) } };
export const Beroep: Story = { args: { seed: invullen(filled, 1) } };
/** English-language diploma → the Dutch-proficiency policy question appears. */
export const BeroepEngelstalig: Story = { args: { seed: invullen({ ...adres, diplomaId: 'd2', beroep: 'Arts', diplomaHerkomst: 'duo', vraagIds: ['nl-taalvaardigheid'] }, 1) } };
export const BeroepEngelstalig: Story = {
args: {
seed: invullen(
{
...adres,
diplomaId: 'd2',
beroep: 'Arts',
diplomaHerkomst: 'duo',
vraagIds: ['nl-taalvaardigheid'],
},
1,
),
},
};
/** Diploma not in DUO → declare beroep + the maximal policy-question set. */
export const BeroepHandmatig: Story = { args: { seed: invullen({ ...adres, diplomaId: 'handmatig', diplomaHerkomst: 'handmatig', vraagIds: ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'] }, 1) } };
export const BeroepHandmatig: Story = {
args: {
seed: invullen(
{
...adres,
diplomaId: 'handmatig',
diplomaHerkomst: 'handmatig',
vraagIds: ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'],
},
1,
),
},
};
export const Controle: Story = { args: { seed: invullen(filled, 2) } };
export const Indienen: Story = { args: { seed: { tag: 'Indienen', data: validData } } };
export const Ingediend: Story = { args: { seed: { tag: 'Ingediend', data: validData, referentie: 'BIG-2026-123456' } } };
export const Mislukt: Story = { args: { seed: { tag: 'Mislukt', data: validData, error: 'Netwerkfout' } } };
export const Ingediend: Story = {
args: { seed: { tag: 'Ingediend', data: validData, referentie: 'BIG-2026-123456' } },
};
export const Mislukt: Story = {
args: { seed: { tag: 'Mislukt', data: validData, error: 'Netwerkfout' } },
};
+4 -4
View File
@@ -12,11 +12,11 @@ import { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/r
<app-page-shell
i18n-heading="@@registratie.heading"
heading="Inschrijven in het BIG-register"
backLink="/dashboard">
backLink="/dashboard"
>
<app-alert type="info" i18n="@@registratie.intro">
In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het
diploma waarmee u zich registreert, en een controle. Uw gegevens blijven bewaard
als u de pagina herlaadt.
In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het diploma waarmee
u zich registreert, en een controle. Uw gegevens blijven bewaard als u de pagina herlaadt.
</app-alert>
<div class="app-section">
<app-registratie-wizard />
@@ -9,11 +9,18 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
@Component({
selector: 'app-registration-detail-page',
imports: [
PageShellComponent, SkeletonComponent, ...ASYNC,
RegistrationSummaryComponent, ChangeRequestFormComponent,
PageShellComponent,
SkeletonComponent,
...ASYNC,
RegistrationSummaryComponent,
ChangeRequestFormComponent,
],
template: `
<app-page-shell i18n-heading="@@registratieDetail.heading" heading="Mijn gegevens" backLink="/dashboard">
<app-page-shell
i18n-heading="@@registratieDetail.heading"
heading="Mijn gegevens"
backLink="/dashboard"
>
<app-async [data]="store.profile()">
<ng-template appAsyncLoaded let-p>
<app-registration-summary [reg]="$any(p).registration" />
@@ -13,25 +13,60 @@ import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
imports: [DatePipe, DataRowComponent, StatusBadgeComponent, DataBlockComponent],
template: `
<app-data-block i18n-ariaLabel="@@summary.ariaLabel" ariaLabel="Registratiegegevens">
<div app-data-row i18n-key="@@summary.bigNummer" key="BIG-nummer" [value]="reg().bigNummer"></div>
<div
app-data-row
i18n-key="@@summary.bigNummer"
key="BIG-nummer"
[value]="reg().bigNummer"
></div>
<div app-data-row i18n-key="@@summary.naam" key="Naam" [value]="reg().naam"></div>
<div app-data-row i18n-key="@@summary.beroep" key="Beroep" [value]="reg().beroep"></div>
<div app-data-row i18n-key="@@summary.status" key="Status">
<app-status-badge [label]="label()" [color]="color()" />
</div>
<div app-data-row i18n-key="@@summary.registratiedatum" key="Registratiedatum" [value]="reg().registratiedatum | date:'longDate'"></div>
<div
app-data-row
i18n-key="@@summary.registratiedatum"
key="Registratiedatum"
[value]="reg().registratiedatum | date: 'longDate'"
></div>
<!-- Each status variant renders only the row its own data supports. -->
@switch (reg().status.tag) {
@case ('Geregistreerd') {
<div app-data-row i18n-key="@@summary.uiterste" key="Uiterste herregistratie" [value]="$any(reg().status).herregistratieDatum | date:'longDate'"></div>
<div
app-data-row
i18n-key="@@summary.uiterste"
key="Uiterste herregistratie"
[value]="$any(reg().status).herregistratieDatum | date: 'longDate'"
></div>
}
@case ('Geschorst') {
<div app-data-row i18n-key="@@summary.geschorstTot" key="Geschorst tot" [value]="$any(reg().status).geschorstTot | date:'longDate'"></div>
<div app-data-row i18n-key="@@summary.reden" key="Reden" [value]="$any(reg().status).reden"></div>
<div
app-data-row
i18n-key="@@summary.geschorstTot"
key="Geschorst tot"
[value]="$any(reg().status).geschorstTot | date: 'longDate'"
></div>
<div
app-data-row
i18n-key="@@summary.reden"
key="Reden"
[value]="$any(reg().status).reden"
></div>
}
@case ('Doorgehaald') {
<div app-data-row i18n-key="@@summary.doorgehaaldOp" key="Doorgehaald op" [value]="$any(reg().status).doorgehaaldOp | date:'longDate'"></div>
<div app-data-row i18n-key="@@summary.reden" key="Reden" [value]="$any(reg().status).reden"></div>
<div
app-data-row
i18n-key="@@summary.doorgehaaldOp"
key="Doorgehaald op"
[value]="$any(reg().status).doorgehaaldOp | date: 'longDate'"
></div>
<div
app-data-row
i18n-key="@@summary.reden"
key="Reden"
[value]="$any(reg().status).reden"
></div>
}
}
</app-data-block>
@@ -23,8 +23,18 @@ 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' } } },
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' } } },
args: {
reg: {
...base,
status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' },
},
},
};
@@ -21,7 +21,7 @@ import { Aantekening } from '@registratie/domain/registration';
<tr>
<td>{{ row.type }}</td>
<td>{{ row.omschrijving }}</td>
<td>{{ row.datum | date:'mediumDate' }}</td>
<td>{{ row.datum | date: 'mediumDate' }}</td>
</tr>
}
</tbody>
@@ -12,7 +12,11 @@ export const Default: Story = {
args: {
rows: [
{ type: 'Specialisme', omschrijving: 'Huisartsgeneeskunde', datum: '2016-04-12' },
{ type: 'Aantekening', omschrijving: 'Erkend opleider huisartsgeneeskunde', datum: '2019-01-08' },
{
type: 'Aantekening',
omschrijving: 'Erkend opleider huisartsgeneeskunde',
datum: '2019-01-08',
},
],
},
};