feat(registratie): WP-34 — phone field + BRP address read-only

Reshape the adreswijziging form into a contact-change form: the BRP address is
authoritative and shown read-only (you change it at the gemeente), and the phone
number becomes the editable/submittable field. New Telefoonnummer value object
(parse-don't-validate); backend RejectPhoneChange re-validates as authority.
POST /change-requests now carries { telefoon } (typed client regenerated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-22 20:11:26 +02:00
co-authored by Claude Opus 4.8
parent 1ed4850858
commit 0ea43af7b6
21 changed files with 329 additions and 171 deletions
@@ -3,11 +3,9 @@ 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 { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { Adres } from '@registratie/domain/person';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import {
@@ -19,19 +17,44 @@ import {
import { createSubmitChangeRequest } from '@registratie/application/submit-change-request';
/**
* Organism: change-request (adreswijziging) form. Uses the SAME idiom as the
* wizards — all state in one signal driven by the pure `reduce`
* (change-request.machine.ts), submitted via a `submit-*` command returning
* `Result`. Renders the shared `<app-address-fields>`; the server re-validates.
* Organism: contact-change (telefoonwijziging) form. The BRP address is authoritative
* and shown READ-ONLY (WP-34) — you change your address at the gemeente, not here — so
* only the phone number is editable. Uses the SAME idiom as the wizards: all state in
* one signal driven by the pure `reduce` (change-request.machine.ts), submitted via a
* `submit-*` command returning `Result`. The server re-validates.
*/
@Component({
selector: 'app-change-request-form',
imports: [FormsModule, ButtonComponent, HeadingComponent, AlertComponent, AddressFieldsComponent],
imports: [
FormsModule,
ButtonComponent,
HeadingComponent,
AlertComponent,
FormFieldComponent,
TextInputComponent,
],
styles: [
`
.brp {
margin-block-end: var(--rhc-space-max-lg);
}
.brp dt {
font-weight: var(--rhc-text-font-weight-semi-bold);
}
.brp dd {
margin: 0 0 var(--rhc-space-max-sm) 0;
}
.brp .source {
color: var(--rhc-color-grijs-700);
font-size: var(--rhc-text-font-size-sm);
}
`,
],
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 wijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5 werkdagen
bericht.
</app-alert>
<div class="app-section">
<app-button
@@ -42,19 +65,43 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang
>
</div>
} @else {
<app-heading [level]="2" i18n="@@changeRequest.heading">Adreswijziging doorgeven</app-heading>
<app-heading [level]="2" i18n="@@changeRequest.heading">Contactgegevens wijzigen</app-heading>
@if (brpAdres(); as a) {
<dl class="brp app-section">
<dt i18n="@@changeRequest.brpAdresLabel">Adres (BRP)</dt>
<dd>{{ a.straat }}<br />{{ a.postcode }} {{ a.woonplaats }}</dd>
<dd class="source" i18n="@@changeRequest.brpAdresBron">
Uw adres komt uit de Basisregistratie Personen en kan hier niet worden gewijzigd. Wijzig
het bij uw gemeente.
</dd>
</dl>
}
<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>
<app-address-fields
idPrefix="cr"
[value]="adres()"
[errors]="errors()"
(fieldChange)="dispatch({ tag: 'SetField', key: $event.key, value: $event.value })"
/>
<app-form-field
i18n-label="@@changeRequest.telefoonLabel"
label="Telefoonnummer"
fieldId="cr-telefoon"
required
[error]="errors().telefoon"
>
<app-text-input
inputId="cr-telefoon"
[invalid]="!!errors().telefoon"
[ngModel]="telefoon()"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'telefoon', value: $event })"
name="telefoon"
i18n-placeholder="@@changeRequest.telefoonPlaceholder"
placeholder="0612345678"
[ngModelOptions]="{ standalone: true }"
/>
</app-form-field>
@if (failedError()) {
<app-alert type="error"
@@ -77,6 +124,9 @@ export class ChangeRequestFormComponent {
private submit = createSubmitChangeRequest();
private store = createStore<ChangeRequestState, ChangeRequestMsg>(initial, reduce);
/** BRP address, shown read-only. Undefined until the profile loads. */
brpAdres = input<Adres | undefined>(undefined);
/** Optional seed so Storybook / tests can mount any state directly. */
seed = input<ChangeRequestState>(initial);
@@ -87,19 +137,17 @@ export class ChangeRequestFormComponent {
protected readonly submitBezigLabel = $localize`:@@changeRequest.submitBezig:Bezig met indienen…`;
private editing = computed(() => whenTag(this.state(), 'Editing'));
protected errors = computed<AdresErrors>(() => this.editing()?.errors ?? {});
protected errors = computed(() => this.editing()?.errors ?? {});
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
protected referentie = computed(() => whenTag(this.state(), 'Submitted')?.referentie ?? '');
/** The address shown in the fields — the live draft while editing, the parsed
data while submitting/failed (so the user sees what they sent). */
protected adres = computed<AdresValue>(() => {
/** The phone shown in the field — the live draft while editing, the parsed value
while submitting/failed (so the user sees what they sent). */
protected telefoon = computed(() => {
const s = this.state();
if (s.tag === 'Editing') return s.draft;
if (s.tag === 'Submitting' || s.tag === 'Failed') {
return { straat: s.data.straat, postcode: s.data.postcode, woonplaats: s.data.woonplaats };
}
return { straat: '', postcode: '', woonplaats: '' }; // Submitted shows the success alert, not the fields
if (s.tag === 'Editing') return s.draft.telefoon;
if (s.tag === 'Submitting' || s.tag === 'Failed') return s.data.telefoon;
return '';
});
constructor() {
@@ -3,38 +3,31 @@ import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { ChangeRequestFormComponent } from './change-request-form.component';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { Postcode } from '@registratie/domain/value-objects/postcode';
import { Telefoonnummer } from '@registratie/domain/value-objects/telefoonnummer';
const validData = {
straat: 'Lange Voorhout 9',
postcode: '2514 EA' as Postcode,
woonplaats: 'Den Haag',
};
const brpAdres = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' };
const validData = { telefoon: '0612345678' as Telefoonnummer };
const meta: Meta<ChangeRequestFormComponent> = {
title: 'Domein/Registratie/Change Request Form',
component: ChangeRequestFormComponent,
// The form injects ApiClient (over HttpClient) for the submit command.
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
args: { brpAdres },
};
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: {} },
},
args: { seed: { tag: 'Editing', draft: { telefoon: '' }, 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.',
},
draft: { telefoon: 'nope' },
errors: { telefoon: 'Voer een geldig telefoonnummer in, bijv. 0612345678.' },
},
},
};
@@ -33,7 +33,7 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
</app-async>
<div class="app-section">
<app-change-request-form />
<app-change-request-form [brpAdres]="profile()?.person?.adres" />
</div>
</app-page-shell>
`,