diff --git a/portal-frontend/src/app/case-detail/case-detail.types.ts b/portal-frontend/src/app/case-detail/case-detail.types.ts index 970e54a..677d680 100644 --- a/portal-frontend/src/app/case-detail/case-detail.types.ts +++ b/portal-frontend/src/app/case-detail/case-detail.types.ts @@ -61,6 +61,17 @@ export interface ApplicantDetailsRequest { preferredChannel: string; } +export function toApplicantDetailsRequest(detail: CaseDetail): ApplicantDetailsRequest { + return { + surname: detail.surname, + initials: detail.initials, + address: detail.address, + email: detail.email, + phone: detail.phone, + preferredChannel: detail.preferredChannel, + }; +} + export interface RecordAssessmentRequest { verifiedItems: string[]; exceptionReason: string | null; diff --git a/portal-frontend/src/app/case-detail/case-detail/case-detail.html b/portal-frontend/src/app/case-detail/case-detail/case-detail.html index 37fa652..097e7a4 100644 --- a/portal-frontend/src/app/case-detail/case-detail/case-detail.html +++ b/portal-frontend/src/app/case-detail/case-detail/case-detail.html @@ -62,6 +62,15 @@ +
+

Gegevens wijzigen

+ +
+

Acties

diff --git a/portal-frontend/src/app/case-detail/case-detail/case-detail.ts b/portal-frontend/src/app/case-detail/case-detail/case-detail.ts index a200f1f..a1c532d 100644 --- a/portal-frontend/src/app/case-detail/case-detail/case-detail.ts +++ b/portal-frontend/src/app/case-detail/case-detail/case-detail.ts @@ -4,11 +4,12 @@ import { ActivatedRoute } from '@angular/router'; import { CaseActions } from '../case-actions/case-actions'; import { CaseDetailService } from '../case-detail.service'; -import { CaseDetail } from '../case-detail.types'; +import { CaseDetail, toApplicantDetailsRequest } from '../case-detail.types'; +import { EditApplicantDetails } from '../edit-applicant-details/edit-applicant-details'; @Component({ selector: 'app-case-detail', - imports: [CaseActions, KeyValuePipe], + imports: [CaseActions, EditApplicantDetails, KeyValuePipe], templateUrl: './case-detail.html', styleUrl: './case-detail.css', }) @@ -17,6 +18,7 @@ export class CaseDetailPage { private readonly caseDetailService = inject(CaseDetailService); readonly detail = signal(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 @@ +
+ @if (isWriteThrough) { +

+ Gevalideerd door het legacy systeem — Angular voegt hier geen eigen validatie toe. +

+ } + + @if (banner) { + + } + + + @if (fieldErrors['surname']; as error) { +

{{ error }}

+ } + + + + + + + @if (fieldErrors['address.number']; as error) { +

{{ error }}

+ } + + + @if (fieldErrors['address.postalCode']; as error) { +

{{ error }}

+ } + + + + + @if (fieldErrors['email']; as error) { +

{{ error }}

+ } + + + @if (fieldErrors['phone']; as error) { +

{{ error }}

+ } + + + + +
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.'; + } + } +}