(null);
+ readonly toApplicantDetailsRequest = toApplicantDetailsRequest;
constructor() {
this.route.paramMap.subscribe((params) => {
@@ -26,4 +28,14 @@ export class CaseDetailPage {
source$.subscribe((detail) => this.detail.set(detail));
});
}
+
+ reload(): void {
+ const current = this.detail();
+ if (!current) return;
+ const source$ =
+ current.origin === 'Legacy'
+ ? this.caseDetailService.getLegacyDetail(String(current.legacyAanvraagId))
+ : this.caseDetailService.getOwnedDetail(current.registrationApplicationId!);
+ source$.subscribe((updated) => this.detail.set(updated));
+ }
}
diff --git a/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.css b/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.css
new file mode 100644
index 0000000..e69de29
diff --git a/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.html b/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.html
new file mode 100644
index 0000000..724a7eb
--- /dev/null
+++ b/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.html
@@ -0,0 +1,73 @@
+
diff --git a/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.spec.ts b/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.spec.ts
new file mode 100644
index 0000000..141c19f
--- /dev/null
+++ b/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.spec.ts
@@ -0,0 +1,118 @@
+import { HttpErrorResponse } from '@angular/common/http';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { of, throwError } from 'rxjs';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { CaseDetailService } from '../case-detail.service';
+import { ActionLink, ApplicantDetailsRequest } from '../case-detail.types';
+import { EditApplicantDetails } from './edit-applicant-details';
+
+const initial: ApplicantDetailsRequest = {
+ surname: 'de Vries',
+ initials: 'A.',
+ address: { street: 'Kerkweg', number: '12', postalCode: '3512JK', city: 'Utrecht' },
+ email: 'anna@example.nl',
+ phone: null,
+ preferredChannel: 'Post',
+};
+
+describe('EditApplicantDetails', () => {
+ let fixture: ComponentFixture;
+ let updateDetails: ReturnType;
+
+ function render(actionLink: ActionLink): HTMLElement {
+ fixture = TestBed.createComponent(EditApplicantDetails);
+ fixture.componentRef.setInput('actionLink', actionLink);
+ fixture.componentRef.setInput('initial', initial);
+ fixture.detectChanges();
+ return fixture.nativeElement as HTMLElement;
+ }
+
+ function submitForm(el: HTMLElement): void {
+ el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
+ fixture.detectChanges();
+ }
+
+ beforeEach(async () => {
+ updateDetails = vi.fn();
+ await TestBed.configureTestingModule({
+ imports: [EditApplicantDetails],
+ providers: [{ provide: CaseDetailService, useValue: { updateDetails } }],
+ }).compileComponents();
+ });
+
+ it('shows the write-through disclaimer only in writeThrough mode', () => {
+ const writeThroughEl = render({ mode: 'writeThrough', href: '/api/worklist/legacy/1001/details' });
+ expect(writeThroughEl.querySelector('[data-testid="write-through-disclaimer"]')).toBeTruthy();
+
+ const ownedEl = render({ mode: 'owned', href: '/api/worklist/owned/abc/details' });
+ expect(ownedEl.querySelector('[data-testid="write-through-disclaimer"]')).toBeFalsy();
+ });
+
+ it('submits the form body to the given href and emits saved on success', () => {
+ updateDetails.mockReturnValue(of(undefined));
+ const el = render({ mode: 'owned', href: '/api/worklist/owned/abc/details' });
+ const savedSpy = vi.fn();
+ fixture.componentInstance.saved.subscribe(savedSpy);
+
+ submitForm(el);
+
+ expect(updateDetails).toHaveBeenCalledWith('/api/worklist/owned/abc/details', {
+ surname: 'de Vries',
+ initials: 'A.',
+ address: { street: 'Kerkweg', number: '12', postalCode: '3512JK', city: 'Utrecht' },
+ email: 'anna@example.nl',
+ phone: null,
+ preferredChannel: 'Post',
+ });
+ expect(savedSpy).toHaveBeenCalled();
+ });
+
+ it('maps a 400 ErrorsResponse to per-field inline errors', () => {
+ updateDetails.mockReturnValue(
+ throwError(
+ () =>
+ new HttpErrorResponse({
+ status: 400,
+ error: {
+ errors: [
+ { field: 'surname', message: 'Achternaam is verplicht.' },
+ { field: 'address.number', message: 'Huisnummer is verplicht.' },
+ { field: 'address.postalCode', message: 'Ongeldige postcode.' },
+ ],
+ },
+ }),
+ ),
+ );
+ const el = render({ mode: 'writeThrough', href: '/api/worklist/legacy/1001/details' });
+
+ submitForm(el);
+
+ expect(el.querySelector('[data-testid="field-error-surname"]')?.textContent).toContain('Achternaam is verplicht.');
+ expect(el.querySelector('[data-testid="field-error-address.number"]')?.textContent).toContain('Huisnummer is verplicht.');
+ expect(el.querySelector('[data-testid="field-error-address.postalCode"]')?.textContent).toContain('Ongeldige postcode.');
+ });
+
+ it('shows a 422 InvariantViolationResponse as an invariant + message banner', () => {
+ updateDetails.mockReturnValue(
+ throwError(() => new HttpErrorResponse({ status: 422, error: { invariant: 'Address.AllPartsRequired', message: 'All address parts are required.' } })),
+ );
+ const el = render({ mode: 'owned', href: '/api/worklist/owned/abc/details' });
+
+ submitForm(el);
+
+ expect(el.querySelector('[data-testid="edit-details-banner"]')?.textContent).toContain('Address.AllPartsRequired');
+ expect(el.querySelector('[data-testid="edit-details-banner"]')?.textContent).toContain('All address parts are required.');
+ });
+
+ it('shows a 409 MessageResponse as a banner', () => {
+ updateDetails.mockReturnValue(
+ throwError(() => new HttpErrorResponse({ status: 409, error: { message: 'This aanvraag has already been migrated.' } })),
+ );
+ const el = render({ mode: 'writeThrough', href: '/api/worklist/legacy/1002/details' });
+
+ submitForm(el);
+
+ expect(el.querySelector('[data-testid="edit-details-banner"]')?.textContent).toContain('This aanvraag has already been migrated.');
+ });
+});
diff --git a/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.ts b/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.ts
new file mode 100644
index 0000000..61ee218
--- /dev/null
+++ b/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.ts
@@ -0,0 +1,92 @@
+import { HttpErrorResponse } from '@angular/common/http';
+import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+
+import { ErrorsResponse, InvariantViolationResponse, MessageResponse } from '../../shared/api-error.types';
+import { CaseDetailService } from '../case-detail.service';
+import { ActionLink, ApplicantDetailsRequest } from '../case-detail.types';
+
+@Component({
+ selector: 'app-edit-applicant-details',
+ imports: [FormsModule],
+ templateUrl: './edit-applicant-details.html',
+ styleUrl: './edit-applicant-details.css',
+})
+export class EditApplicantDetails implements OnInit {
+ private readonly caseDetailService = inject(CaseDetailService);
+
+ @Input({ required: true }) actionLink!: ActionLink;
+ @Input({ required: true }) initial!: ApplicantDetailsRequest;
+
+ @Output() readonly saved = new EventEmitter();
+
+ surname = '';
+ initials = '';
+ street = '';
+ number = '';
+ postalCode = '';
+ city = '';
+ email = '';
+ phone = '';
+ preferredChannel = '';
+
+ fieldErrors: Record = {};
+ banner: string | null = null;
+
+ get isWriteThrough(): boolean {
+ return this.actionLink.mode === 'writeThrough';
+ }
+
+ ngOnInit(): void {
+ this.surname = this.initial.surname;
+ this.initials = this.initial.initials;
+ this.street = this.initial.address?.street ?? '';
+ this.number = this.initial.address?.number ?? '';
+ this.postalCode = this.initial.address?.postalCode ?? '';
+ this.city = this.initial.address?.city ?? '';
+ this.email = this.initial.email ?? '';
+ this.phone = this.initial.phone ?? '';
+ this.preferredChannel = this.initial.preferredChannel;
+ }
+
+ submit(): void {
+ this.fieldErrors = {};
+ this.banner = null;
+
+ const body: ApplicantDetailsRequest = {
+ surname: this.surname,
+ initials: this.initials,
+ address: this.street ? { street: this.street, number: this.number, postalCode: this.postalCode, city: this.city } : null,
+ email: this.email || null,
+ phone: this.phone || null,
+ preferredChannel: this.preferredChannel,
+ };
+
+ this.caseDetailService.updateDetails(this.actionLink.href, body).subscribe({
+ next: () => this.saved.emit(),
+ error: (error: HttpErrorResponse) => this.handleError(error),
+ });
+ }
+
+ private handleError(error: HttpErrorResponse): void {
+ switch (error.status) {
+ case 400: {
+ const body = error.error as ErrorsResponse;
+ this.fieldErrors = Object.fromEntries(body.errors.map((fieldError) => [fieldError.field, fieldError.message]));
+ break;
+ }
+ case 422: {
+ const body = error.error as InvariantViolationResponse;
+ this.banner = `${body.invariant}: ${body.message}`;
+ break;
+ }
+ case 409: {
+ const body = error.error as MessageResponse;
+ this.banner = body.message;
+ break;
+ }
+ default:
+ this.banner = 'Onbekende fout bij opslaan.';
+ }
+ }
+}