diff --git a/portal-frontend/src/app/case-detail/case-actions/case-actions.html b/portal-frontend/src/app/case-detail/case-actions/case-actions.html
index 5e33595..fcaa4bc 100644
--- a/portal-frontend/src/app/case-detail/case-actions/case-actions.html
+++ b/portal-frontend/src/app/case-detail/case-actions/case-actions.html
@@ -1,9 +1,11 @@
@if (actions().recordAssessment.mode === 'redirect') {
Beoordeling vastleggen (in legacy)
} @else {
-
-
-
+
}
@if (actions().takeOwnership; as takeOwnership) {
diff --git a/portal-frontend/src/app/case-detail/case-actions/case-actions.spec.ts b/portal-frontend/src/app/case-detail/case-actions/case-actions.spec.ts
index c6f6700..82c076c 100644
--- a/portal-frontend/src/app/case-detail/case-actions/case-actions.spec.ts
+++ b/portal-frontend/src/app/case-detail/case-actions/case-actions.spec.ts
@@ -1,5 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { CaseDetailService } from '../case-detail.service';
import { CaseDetailActions } from '../case-detail.types';
import { CaseActions } from './case-actions';
@@ -14,7 +15,10 @@ describe('CaseActions', () => {
}
beforeEach(async () => {
- await TestBed.configureTestingModule({ imports: [CaseActions] }).compileComponents();
+ await TestBed.configureTestingModule({
+ imports: [CaseActions],
+ providers: [{ provide: CaseDetailService, useValue: { recordAssessment: () => { throw new Error('not stubbed'); } } }],
+ }).compileComponents();
});
const legacyActions: CaseDetailActions = {
diff --git a/portal-frontend/src/app/case-detail/case-actions/case-actions.ts b/portal-frontend/src/app/case-detail/case-actions/case-actions.ts
index fd46daa..8925f92 100644
--- a/portal-frontend/src/app/case-detail/case-actions/case-actions.ts
+++ b/portal-frontend/src/app/case-detail/case-actions/case-actions.ts
@@ -1,10 +1,11 @@
import { Component, input, output } from '@angular/core';
import { ActionLink, CaseDetailActions } from '../case-detail.types';
+import { RecordAssessmentForm } from '../record-assessment-form/record-assessment-form';
@Component({
selector: 'app-case-actions',
- imports: [],
+ imports: [RecordAssessmentForm],
templateUrl: './case-actions.html',
styleUrl: './case-actions.css',
})
@@ -13,4 +14,5 @@ export class CaseActions {
readonly takeOwnershipRequested = output();
readonly releaseOwnershipRequested = output();
+ readonly savedRequested = output();
}
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 097e7a4..4add799 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,7 @@
} @else {
Laden...
diff --git a/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.css b/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.css
new file mode 100644
index 0000000..e69de29
diff --git a/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.html b/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.html
new file mode 100644
index 0000000..b2e8ed0
--- /dev/null
+++ b/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.html
@@ -0,0 +1,46 @@
+@if (recorded) {
+
+ Beoordeling vastgelegd.
+ @if (closurePending) {
+ Administratieve afsluiting in behandeling.
+ }
+
+} @else {
+
+}
diff --git a/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.spec.ts b/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.spec.ts
new file mode 100644
index 0000000..20dd738
--- /dev/null
+++ b/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.spec.ts
@@ -0,0 +1,85 @@
+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 { RecordAssessmentForm } from './record-assessment-form';
+
+describe('RecordAssessmentForm', () => {
+ let fixture: ComponentFixture;
+ let recordAssessment: ReturnType;
+
+ async function render(): Promise {
+ fixture = TestBed.createComponent(RecordAssessmentForm);
+ fixture.componentRef.setInput('actionLink', { mode: 'owned', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment' });
+ fixture.detectChanges();
+ // NgModel/NgForm finish registering their controls on a microtask after
+ // the first detectChanges() - without this, the first simulated input
+ // event lands before the control is wired up and gets silently dropped.
+ await fixture.whenStable();
+ return fixture.nativeElement as HTMLElement;
+ }
+
+ async function fillAndSubmit(el: HTMLElement, values: { verifiedItems?: string; motivation?: string }) {
+ const setValue = async (name: string, value: string) => {
+ const input = el.querySelector(`[name="${name}"]`)!;
+ input.value = value;
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ fixture.detectChanges();
+ await fixture.whenStable();
+ };
+ if (values.verifiedItems !== undefined) await setValue('verifiedItems', values.verifiedItems);
+ if (values.motivation !== undefined) await setValue('motivation', values.motivation);
+ el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
+ fixture.detectChanges();
+ await fixture.whenStable();
+ }
+
+ beforeEach(async () => {
+ recordAssessment = vi.fn();
+ await TestBed.configureTestingModule({
+ imports: [RecordAssessmentForm],
+ providers: [{ provide: CaseDetailService, useValue: { recordAssessment } }],
+ }).compileComponents();
+ });
+
+ it('submits verifiedItems split on commas and trimmed', async () => {
+ recordAssessment.mockReturnValue(of({ closurePending: false }));
+ const el = await render();
+
+ await fillAndSubmit(el, { verifiedItems: 'document, land , datum', motivation: 'Alles gecontroleerd.' });
+
+ expect(recordAssessment).toHaveBeenCalledWith('/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment', {
+ verifiedItems: ['document', 'land', 'datum'],
+ exceptionReason: null,
+ outcome: 'Approved',
+ rejectionCategory: null,
+ motivation: 'Alles gecontroleerd.',
+ });
+ });
+
+ it('shows closurePending as a neutral note, not an error, on success', async () => {
+ recordAssessment.mockReturnValue(of({ closurePending: true }));
+ const el = await render();
+
+ await fillAndSubmit(el, { verifiedItems: 'document', motivation: 'Alles gecontroleerd.' });
+
+ expect(el.querySelector('[data-testid="assessment-recorded"]')).toBeTruthy();
+ expect(el.querySelector('[data-testid="closure-pending-note"]')?.textContent).toContain('Administratieve afsluiting in behandeling.');
+ expect(el.querySelector('[data-testid="assessment-banner"]')).toBeFalsy();
+ });
+
+ it('shows a 422 InvariantViolationResponse as a banner, not a curated message', async () => {
+ recordAssessment.mockReturnValue(
+ throwError(() => new HttpErrorResponse({ status: 422, error: { invariant: 'Assessment.MotivationTooShort', message: 'Motivation is too short.' } })),
+ );
+ const el = await render();
+
+ await fillAndSubmit(el, { verifiedItems: 'document', motivation: 'te kort' });
+
+ expect(el.querySelector('[data-testid="assessment-banner"]')?.textContent).toContain('Assessment.MotivationTooShort');
+ expect(el.querySelector('[data-testid="assessment-banner"]')?.textContent).toContain('Motivation is too short.');
+ expect(el.querySelector('[data-testid="assessment-recorded"]')).toBeFalsy();
+ });
+});
diff --git a/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.ts b/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.ts
new file mode 100644
index 0000000..21a21e3
--- /dev/null
+++ b/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.ts
@@ -0,0 +1,59 @@
+import { HttpErrorResponse } from '@angular/common/http';
+import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+
+import { InvariantViolationResponse } from '../../shared/api-error.types';
+import { CaseDetailService } from '../case-detail.service';
+import { ActionLink, RecordAssessmentRequest } from '../case-detail.types';
+
+@Component({
+ selector: 'app-record-assessment-form',
+ imports: [FormsModule],
+ templateUrl: './record-assessment-form.html',
+ styleUrl: './record-assessment-form.css',
+})
+export class RecordAssessmentForm {
+ private readonly caseDetailService = inject(CaseDetailService);
+
+ @Input({ required: true }) actionLink!: ActionLink;
+ @Output() readonly saved = new EventEmitter();
+
+ verifiedItemsText = '';
+ exceptionReason = '';
+ outcome: 'Approved' | 'Rejected' = 'Approved';
+ rejectionCategory = '';
+ motivation = '';
+
+ banner: string | null = null;
+ recorded = false;
+ closurePending = false;
+
+ submit(): void {
+ this.banner = null;
+
+ const verifiedItems = this.verifiedItemsText
+ .split(',')
+ .map((item) => item.trim())
+ .filter((item) => item.length > 0);
+
+ const body: RecordAssessmentRequest = {
+ verifiedItems,
+ exceptionReason: verifiedItems.length === 0 ? this.exceptionReason || null : null,
+ outcome: this.outcome,
+ rejectionCategory: this.outcome === 'Rejected' ? this.rejectionCategory || null : null,
+ motivation: this.motivation,
+ };
+
+ this.caseDetailService.recordAssessment(this.actionLink.href, body).subscribe({
+ next: (result) => {
+ this.recorded = true;
+ this.closurePending = result.closurePending;
+ this.saved.emit();
+ },
+ error: (error: HttpErrorResponse) => {
+ const invariant = error.error as InvariantViolationResponse;
+ this.banner = `${invariant.invariant}: ${invariant.message}`;
+ },
+ });
+ }
+}