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 4add799..b0eaec3 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
@@ -73,7 +73,15 @@
Acties
-
+ @if (banner(); as message) {
+ {{ message }}
+ }
+
} @else {
Laden...
diff --git a/portal-frontend/src/app/case-detail/case-detail/case-detail.spec.ts b/portal-frontend/src/app/case-detail/case-detail/case-detail.spec.ts
index 659a26e..162ea27 100644
--- a/portal-frontend/src/app/case-detail/case-detail/case-detail.spec.ts
+++ b/portal-frontend/src/app/case-detail/case-detail/case-detail.spec.ts
@@ -1,7 +1,8 @@
+import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { ActivatedRoute, convertToParamMap } from '@angular/router';
-import { of } from 'rxjs';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
+import { of, throwError } from 'rxjs';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CaseDetailService } from '../case-detail.service';
import { CaseDetail } from '../case-detail.types';
@@ -9,7 +10,7 @@ import { CaseDetailPage } from './case-detail';
const legacyDetail: CaseDetail = {
origin: 'Legacy',
- legacyAanvraagId: 1001,
+ legacyAanvraagId: 1002,
registrationApplicationId: null,
surname: 'de Vries',
initials: 'A.',
@@ -26,54 +27,162 @@ const legacyDetail: CaseDetail = {
processStatus: null,
lastModifiedAt: null,
actions: {
- editApplicantDetails: { mode: 'writeThrough', href: '/api/worklist/legacy/1001/details' },
- recordAssessment: { mode: 'redirect', href: '/legacy/aanvraag/1001/beoordeling' },
- takeOwnership: { mode: 'transition', href: '/api/worklist/legacy/1001/take-ownership' },
+ editApplicantDetails: { mode: 'writeThrough', href: '/api/worklist/legacy/1002/details' },
+ recordAssessment: { mode: 'redirect', href: '/legacy/aanvraag/1002/beoordeling' },
+ takeOwnership: { mode: 'transition', href: '/api/worklist/legacy/1002/take-ownership' },
},
seams: { aanvrager: 'legacy-backend', procestijdlijn: null },
};
-function configure(routeConfigPath: string, paramMap: Record, getLegacyDetail: unknown, getOwnedDetail: unknown) {
- return TestBed.configureTestingModule({
- imports: [CaseDetailPage],
- providers: [
- { provide: CaseDetailService, useValue: { getLegacyDetail, getOwnedDetail } },
- {
- provide: ActivatedRoute,
- useValue: {
- paramMap: of(convertToParamMap(paramMap)),
- snapshot: { routeConfig: { path: routeConfigPath } },
+const ownedDetail: CaseDetail = {
+ ...legacyDetail,
+ origin: 'Owned',
+ legacyAanvraagId: null,
+ registrationApplicationId: '00000000-0000-0000-0000-000000000002',
+ actions: {
+ editApplicantDetails: { mode: 'owned', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/details' },
+ recordAssessment: { mode: 'owned', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment' },
+ releaseOwnership: { mode: 'transition', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/ownership' },
+ },
+ seams: { aanvrager: 'owned', procestijdlijn: 'case-framework-timeline' },
+};
+
+function configure(routeConfigPath: string, paramMap: Record, serviceOverrides: Record = {}) {
+ const service = {
+ getLegacyDetail: vi.fn().mockReturnValue(of(legacyDetail)),
+ getOwnedDetail: vi.fn().mockReturnValue(of(ownedDetail)),
+ updateDetails: vi.fn(),
+ recordAssessment: vi.fn(),
+ takeOwnership: vi.fn(),
+ releaseOwnership: vi.fn(),
+ ...serviceOverrides,
+ };
+ return {
+ service,
+ ready: TestBed.configureTestingModule({
+ imports: [CaseDetailPage],
+ providers: [
+ provideRouter([]),
+ { provide: CaseDetailService, useValue: service },
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ paramMap: of(convertToParamMap(paramMap)),
+ snapshot: { routeConfig: { path: routeConfigPath } },
+ },
},
- },
- ],
- }).compileComponents();
+ ],
+ }).compileComponents(),
+ };
}
describe('CaseDetailPage', () => {
let fixture: ComponentFixture;
it('given a legacy/:id route, fetches via getLegacyDetail and renders the case', async () => {
- const getLegacyDetail = vi.fn().mockReturnValue(of(legacyDetail));
- const getOwnedDetail = vi.fn();
- await configure('legacy/:id', { id: '1001' }, getLegacyDetail, getOwnedDetail);
+ const { service, ready } = configure('legacy/:id', { id: '1002' });
+ await ready;
fixture = TestBed.createComponent(CaseDetailPage);
fixture.detectChanges();
- expect(getLegacyDetail).toHaveBeenCalledWith('1001');
- expect(getOwnedDetail).not.toHaveBeenCalled();
+ expect(service.getLegacyDetail).toHaveBeenCalledWith('1002');
+ expect(service.getOwnedDetail).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('de Vries');
});
it('given an owned/:id route, fetches via getOwnedDetail', async () => {
- const getLegacyDetail = vi.fn();
- const getOwnedDetail = vi.fn().mockReturnValue(of({ ...legacyDetail, origin: 'Owned', legacyAanvraagId: null, registrationApplicationId: 'abc' }));
- await configure('owned/:id', { id: 'abc' }, getLegacyDetail, getOwnedDetail);
+ const { service, ready } = configure('owned/:id', { id: '00000000-0000-0000-0000-000000000002' });
+ await ready;
fixture = TestBed.createComponent(CaseDetailPage);
fixture.detectChanges();
- expect(getOwnedDetail).toHaveBeenCalledWith('abc');
- expect(getLegacyDetail).not.toHaveBeenCalled();
+ expect(service.getOwnedDetail).toHaveBeenCalledWith('00000000-0000-0000-0000-000000000002');
+ expect(service.getLegacyDetail).not.toHaveBeenCalled();
+ });
+
+ describe('take ownership', () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ it('does nothing when the user declines the confirm dialog', async () => {
+ vi.spyOn(window, 'confirm').mockReturnValue(false);
+ const { service, ready } = configure('legacy/:id', { id: '1002' });
+ await ready;
+ fixture = TestBed.createComponent(CaseDetailPage);
+ fixture.detectChanges();
+
+ fixture.nativeElement.querySelector('[data-testid="take-ownership-button"]').click();
+
+ expect(service.takeOwnership).not.toHaveBeenCalled();
+ });
+
+ it('on confirm, calls takeOwnership and navigates to the new owned detail', async () => {
+ vi.spyOn(window, 'confirm').mockReturnValue(true);
+ const { service, ready } = configure('legacy/:id', { id: '1002' }, {
+ takeOwnership: vi.fn().mockReturnValue(of({ registrationApplicationId: '00000000-0000-0000-0000-000000000002' })),
+ });
+ await ready;
+ fixture = TestBed.createComponent(CaseDetailPage);
+ fixture.detectChanges();
+ const navigate = vi.spyOn(TestBed.inject(Router), 'navigate');
+
+ fixture.nativeElement.querySelector('[data-testid="take-ownership-button"]').click();
+
+ expect(service.takeOwnership).toHaveBeenCalledWith('/api/worklist/legacy/1002/take-ownership');
+ expect(navigate).toHaveBeenCalledWith(['owned', '00000000-0000-0000-0000-000000000002']);
+ });
+
+ it('on a 422 InvariantViolationResponse, shows a banner instead of navigating', async () => {
+ vi.spyOn(window, 'confirm').mockReturnValue(true);
+ const { ready } = configure('legacy/:id', { id: '1002' }, {
+ takeOwnership: vi
+ .fn()
+ .mockReturnValue(throwError(() => new HttpErrorResponse({ status: 422, error: { invariant: 'Bsn.ElevenProof', message: 'BSN fails the eleven-proof.' } }))),
+ });
+ await ready;
+ fixture = TestBed.createComponent(CaseDetailPage);
+ fixture.detectChanges();
+
+ fixture.nativeElement.querySelector('[data-testid="take-ownership-button"]').click();
+ fixture.detectChanges();
+
+ const banner = fixture.nativeElement.querySelector('[data-testid="ownership-banner"]');
+ expect(banner?.textContent).toContain('Bsn.ElevenProof');
+ expect(banner?.textContent).toContain('BSN fails the eleven-proof.');
+ });
+ });
+
+ describe('release ownership', () => {
+ it('calls releaseOwnership and navigates to the worklist on success', async () => {
+ const { service, ready } = configure('owned/:id', { id: '00000000-0000-0000-0000-000000000002' }, {
+ releaseOwnership: vi.fn().mockReturnValue(of(undefined)),
+ });
+ await ready;
+ fixture = TestBed.createComponent(CaseDetailPage);
+ fixture.detectChanges();
+ const navigate = vi.spyOn(TestBed.inject(Router), 'navigate');
+
+ fixture.nativeElement.querySelector('[data-testid="release-ownership-button"]').click();
+
+ expect(service.releaseOwnership).toHaveBeenCalledWith('/api/worklist/owned/00000000-0000-0000-0000-000000000002/ownership');
+ expect(navigate).toHaveBeenCalledWith(['/']);
+ });
+
+ it('on a 409 MessageResponse, shows the server message as a banner', async () => {
+ const { ready } = configure('owned/:id', { id: '00000000-0000-0000-0000-000000000002' }, {
+ releaseOwnership: vi
+ .fn()
+ .mockReturnValue(throwError(() => new HttpErrorResponse({ status: 409, error: { message: 'Domain writes exist since adoption.' } }))),
+ });
+ await ready;
+ fixture = TestBed.createComponent(CaseDetailPage);
+ fixture.detectChanges();
+
+ fixture.nativeElement.querySelector('[data-testid="release-ownership-button"]').click();
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('[data-testid="ownership-banner"]')?.textContent).toContain('Domain writes exist since adoption.');
+ });
});
});
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 a1c532d..cf09f5d 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
@@ -1,10 +1,12 @@
+import { HttpErrorResponse } from '@angular/common/http';
import { KeyValuePipe } from '@angular/common';
import { Component, inject, signal } from '@angular/core';
-import { ActivatedRoute } from '@angular/router';
+import { ActivatedRoute, Router } from '@angular/router';
+import { InvariantViolationResponse, MessageResponse } from '../../shared/api-error.types';
import { CaseActions } from '../case-actions/case-actions';
import { CaseDetailService } from '../case-detail.service';
-import { CaseDetail, toApplicantDetailsRequest } from '../case-detail.types';
+import { ActionLink, CaseDetail, toApplicantDetailsRequest } from '../case-detail.types';
import { EditApplicantDetails } from '../edit-applicant-details/edit-applicant-details';
@Component({
@@ -15,9 +17,11 @@ import { EditApplicantDetails } from '../edit-applicant-details/edit-applicant-d
})
export class CaseDetailPage {
private readonly route = inject(ActivatedRoute);
+ private readonly router = inject(Router);
private readonly caseDetailService = inject(CaseDetailService);
readonly detail = signal(null);
+ readonly banner = signal(null);
readonly toApplicantDetailsRequest = toApplicantDetailsRequest;
constructor() {
@@ -38,4 +42,35 @@ export class CaseDetailPage {
: this.caseDetailService.getOwnedDetail(current.registrationApplicationId!);
source$.subscribe((updated) => this.detail.set(updated));
}
+
+ // Matches the placeholder's own confirm() dialog before this one-way,
+ // per-case migration step (ADR-003).
+ onTakeOwnership(link: ActionLink): void {
+ if (!confirm('Dit dossier in eigen beheer nemen?')) return;
+ this.banner.set(null);
+ this.caseDetailService.takeOwnership(link.href).subscribe({
+ next: (result) => this.router.navigate(['owned', result.registrationApplicationId]),
+ error: (error: HttpErrorResponse) => this.banner.set(this.describeError(error)),
+ });
+ }
+
+ onReleaseOwnership(link: ActionLink): void {
+ this.banner.set(null);
+ this.caseDetailService.releaseOwnership(link.href).subscribe({
+ next: () => this.router.navigate(['/']),
+ error: (error: HttpErrorResponse) => this.banner.set(this.describeError(error)),
+ });
+ }
+
+ private describeError(error: HttpErrorResponse): string {
+ if (error.status === 422) {
+ const body = error.error as InvariantViolationResponse;
+ return `${body.invariant}: ${body.message}`;
+ }
+ if (error.status === 409) {
+ const body = error.error as MessageResponse;
+ return body.message;
+ }
+ return 'Onbekende fout.';
+ }
}